From 99bccb74acaad8ae9d725b5470e8556c34ff8810 Mon Sep 17 00:00:00 2001 From: "William R. Arellano" Date: Tue, 7 Mar 2023 20:32:20 -0500 Subject: [PATCH 1/8] misc: add temporary nix file --- shell.nix | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 shell.nix diff --git a/shell.nix b/shell.nix new file mode 100644 index 000000000..18dfd2590 --- /dev/null +++ b/shell.nix @@ -0,0 +1,19 @@ +{ pkgs ? import { } }: + +with pkgs; + +mkShell rec { + nativeBuildInputs = [ + pkg-config + emacs + rust-analyzer + openssl + ripgrep + ]; + buildInputs = [ + udev alsa-lib vulkan-loader + xorg.libX11 xorg.libXcursor xorg.libXi xorg.libXrandr # To use the x11 feature + libxkbcommon wayland # To use the wayland feature + ]; + LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs; +} From 68adcf657af6d467b461912f42504d1d7922df25 Mon Sep 17 00:00:00 2001 From: "William R. Arellano" Date: Tue, 7 Mar 2023 20:32:37 -0500 Subject: [PATCH 2/8] Add test to check content type image/* --- actix-web/src/http/header/content_type.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/actix-web/src/http/header/content_type.rs b/actix-web/src/http/header/content_type.rs index 1fc75d0e2..7e399a1d0 100644 --- a/actix-web/src/http/header/content_type.rs +++ b/actix-web/src/http/header/content_type.rs @@ -53,6 +53,11 @@ crate::http::header::common_header! { test1, vec![b"text/html"], Some(HeaderField(mime::TEXT_HTML))); + crate::http::header::common_header_test!( + test2, + vec![b"image/*"], + Some(HeaderField(mime::IMAGE_STAR))); + } } From c96844ab14e30e65d5d4b3aa0c9c1c76528469da Mon Sep 17 00:00:00 2001 From: "William R. Arellano" Date: Tue, 7 Mar 2023 21:11:41 -0500 Subject: [PATCH 3/8] misc: add unit test for expected behaviour jpeg --- actix-web/src/middleware/compress.rs | 29 +++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index 51b44c6ef..2c81698ef 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -17,7 +17,7 @@ use pin_project_lite::pin_project; use crate::{ body::{EitherBody, MessageBody}, http::{ - header::{self, AcceptEncoding, Encoding, HeaderValue}, + header::{self, AcceptEncoding, ContentType, Encoding, HeaderValue}, StatusCode, }, service::{ServiceRequest, ServiceResponse}, @@ -324,4 +324,31 @@ mod tests { assert!(vary_headers.contains(&HeaderValue::from_static("x-test"))); assert!(vary_headers.contains(&HeaderValue::from_static("accept-encoding"))); } + + #[actix_rt::test] + async fn prevents_compression_jpeg() { + const D: &str = "test image"; + const DATA: &str = const_str::repeat!(D, 100); + let app = test::init_service({ + App::new().wrap(Compress::default()).route( + "/image", + web::get().to(move || { + let mut builder = HttpResponse::Ok(); + builder.body(DATA); + builder.insert_header(ContentType::jpeg()); + builder + }), + ) + }) + .await; + let req = test::TestRequest::default() + .uri("/image") + .insert_header((header::ACCEPT_ENCODING, "gzip")) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers().get(header::CONTENT_TYPE).unwrap(), "gzip"); + let bytes = test::read_body(res).await; + assert_eq!(bytes, DATA.as_bytes()); + } } From 2bfc170fb0c1acb11f8851370df382c84e734f1a Mon Sep 17 00:00:00 2001 From: "William R. Arellano" Date: Tue, 14 Mar 2023 16:38:14 -0500 Subject: [PATCH 4/8] feat(compress): add compress function to middleware --- actix-web/src/middleware/compress.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index 2c81698ef..dce9fc378 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -13,6 +13,7 @@ use actix_utils::future::{ok, Either, Ready}; use futures_core::ready; use once_cell::sync::Lazy; use pin_project_lite::pin_project; +use mime::Mime; use crate::{ body::{EitherBody, MessageBody}, @@ -71,9 +72,19 @@ use crate::{ /// ``` /// /// [feature flags]: ../index.html#crate-features -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] #[non_exhaustive] -pub struct Compress; +pub struct Compress { + pub compress: fn(Mime) -> bool, +} + +impl Default for Compress { + fn default() -> Self { + Compress { + compress: |_| { true } + } + } +} impl Transform for Compress where @@ -87,12 +98,13 @@ where type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { - ok(CompressMiddleware { service }) + ok(CompressMiddleware { service, compress: self.compress }) } } pub struct CompressMiddleware { service: S, + compress: fn(Mime) -> bool, } impl Service for CompressMiddleware From 14b09e35d109d7dba409f328a8f72ed899ebbd3c Mon Sep 17 00:00:00 2001 From: "William R. Arellano" Date: Tue, 14 Mar 2023 21:55:15 -0500 Subject: [PATCH 5/8] feat(compress): use response content type to decide compress --- actix-web/src/middleware/compress.rs | 62 ++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index dce9fc378..de6219e2b 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -1,6 +1,7 @@ //! For middleware documentation, see [`Compress`]. use std::{ + fmt, future::Future, marker::PhantomData, pin::Pin, @@ -11,14 +12,14 @@ use actix_http::encoding::Encoder; use actix_service::{Service, Transform}; use actix_utils::future::{ok, Either, Ready}; use futures_core::ready; +use mime::Mime; use once_cell::sync::Lazy; use pin_project_lite::pin_project; -use mime::Mime; use crate::{ body::{EitherBody, MessageBody}, http::{ - header::{self, AcceptEncoding, ContentType, Encoding, HeaderValue}, + header::{self, AcceptEncoding, ContentEncoding, Encoding, HeaderValue}, StatusCode, }, service::{ServiceRequest, ServiceResponse}, @@ -72,17 +73,22 @@ use crate::{ /// ``` /// /// [feature flags]: ../index.html#crate-features -#[derive(Debug, Clone)] +#[derive(Clone)] #[non_exhaustive] pub struct Compress { - pub compress: fn(Mime) -> bool, + pub compress: fn(&HeaderValue) -> bool, } +impl fmt::Debug for Compress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Compress").finish() + } +} impl Default for Compress { fn default() -> Self { - Compress { - compress: |_| { true } - } + Compress { + compress: |_| false, + } } } @@ -98,13 +104,16 @@ where type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { - ok(CompressMiddleware { service, compress: self.compress }) + ok(CompressMiddleware { + service, + compress: self.compress, + }) } } pub struct CompressMiddleware { service: S, - compress: fn(Mime) -> bool, + compress: fn(&HeaderValue) -> bool, } impl Service for CompressMiddleware @@ -131,6 +140,7 @@ where encoding: Encoding::identity(), fut: self.service.call(req), _phantom: PhantomData, + compress: self.compress, }) } @@ -158,6 +168,7 @@ where fut: self.service.call(req), encoding, _phantom: PhantomData, + compress: self.compress, }), } } @@ -172,6 +183,7 @@ pin_project! { fut: S::Future, encoding: Encoding, _phantom: PhantomData, + compress: fn(&HeaderValue) -> bool, } } @@ -182,8 +194,8 @@ where { type Output = Result>>, Error>; - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.project(); + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.as_mut().project(); match ready!(this.fut.poll(cx)) { Ok(resp) => { @@ -195,7 +207,19 @@ where }; Poll::Ready(Ok(resp.map_body(move |head, body| { - EitherBody::left(Encoder::response(enc, head, body)) + let content_type = head.headers.get(header::CONTENT_TYPE); + let should_compress = content_type + .map(|value| (self.compress)(value)) + .unwrap_or(true); + if should_compress { + EitherBody::left(Encoder::response(enc, head, body)) + } else { + EitherBody::left(Encoder::response( + ContentEncoding::Identity, + head, + body, + )) + } }))) } @@ -259,6 +283,7 @@ mod tests { use std::collections::HashSet; use super::*; + use crate::http::header::ContentType; use crate::{middleware::DefaultHeaders, test, web, App}; pub fn gzip_decode(bytes: impl AsRef<[u8]>) -> Vec { @@ -345,9 +370,9 @@ mod tests { App::new().wrap(Compress::default()).route( "/image", web::get().to(move || { - let mut builder = HttpResponse::Ok(); - builder.body(DATA); - builder.insert_header(ContentType::jpeg()); + let builder = HttpResponse::Ok() + .insert_header(ContentType::jpeg()) + .body(DATA); builder }), ) @@ -357,9 +382,12 @@ mod tests { .uri("/image") .insert_header((header::ACCEPT_ENCODING, "gzip")) .to_request(); - let res = test::call_service(&app, req).await; + let res = test::call_service(&app, req).await; assert_eq!(res.status(), StatusCode::OK); - assert_eq!(res.headers().get(header::CONTENT_TYPE).unwrap(), "gzip"); + assert_eq!( + res.headers().get(header::CONTENT_TYPE).unwrap(), + "image/jpeg" + ); let bytes = test::read_body(res).await; assert_eq!(bytes, DATA.as_bytes()); } From e9a1aeebcea25b91636b565a2a5f30fe78169ce9 Mon Sep 17 00:00:00 2001 From: "William R. Arellano" Date: Tue, 14 Mar 2023 22:00:08 -0500 Subject: [PATCH 6/8] feat(compress): give more control to the user --- actix-web/src/middleware/compress.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index de6219e2b..14325de73 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -12,7 +12,6 @@ use actix_http::encoding::Encoder; use actix_service::{Service, Transform}; use actix_utils::future::{ok, Either, Ready}; use futures_core::ready; -use mime::Mime; use once_cell::sync::Lazy; use pin_project_lite::pin_project; @@ -76,7 +75,7 @@ use crate::{ #[derive(Clone)] #[non_exhaustive] pub struct Compress { - pub compress: fn(&HeaderValue) -> bool, + pub compress: fn(Option<&HeaderValue>) -> bool, } impl fmt::Debug for Compress { @@ -113,7 +112,7 @@ where pub struct CompressMiddleware { service: S, - compress: fn(&HeaderValue) -> bool, + compress: fn(Option<&HeaderValue>) -> bool, } impl Service for CompressMiddleware @@ -183,7 +182,7 @@ pin_project! { fut: S::Future, encoding: Encoding, _phantom: PhantomData, - compress: fn(&HeaderValue) -> bool, + compress: fn(Option<&HeaderValue>) -> bool, } } @@ -208,9 +207,7 @@ where Poll::Ready(Ok(resp.map_body(move |head, body| { let content_type = head.headers.get(header::CONTENT_TYPE); - let should_compress = content_type - .map(|value| (self.compress)(value)) - .unwrap_or(true); + let should_compress = (self.compress)(content_type); if should_compress { EitherBody::left(Encoder::response(enc, head, body)) } else { From a3ec0ccf99a3a48f5fd5d216d71f7b087463bf7d Mon Sep 17 00:00:00 2001 From: "William R. Arellano" Date: Tue, 14 Mar 2023 22:21:18 -0500 Subject: [PATCH 7/8] misc: improve default compress function --- actix-web/src/middleware/compress.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index 14325de73..c35e7cb6c 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -12,6 +12,7 @@ use actix_http::encoding::Encoder; use actix_service::{Service, Transform}; use actix_utils::future::{ok, Either, Ready}; use futures_core::ready; +use mime::Mime; use once_cell::sync::Lazy; use pin_project_lite::pin_project; @@ -86,7 +87,16 @@ impl fmt::Debug for Compress { impl Default for Compress { fn default() -> Self { Compress { - compress: |_| false, + compress: |content_type| match content_type { + None => true, + Some(value) => { + let response_mime: Mime = value.to_str().unwrap().parse::().unwrap(); + match response_mime.type_().as_str() { + "image" => false, + _ => true, + } + } + }, } } } @@ -207,7 +217,7 @@ where Poll::Ready(Ok(resp.map_body(move |head, body| { let content_type = head.headers.get(header::CONTENT_TYPE); - let should_compress = (self.compress)(content_type); + let should_compress = (self.compress)(content_type); if should_compress { EitherBody::left(Encoder::response(enc, head, body)) } else { From 0216cb186ea95e4c8f2e645dafe74f5df383c2d3 Mon Sep 17 00:00:00 2001 From: Rob Ede Date: Sun, 2 Apr 2023 05:54:34 +0100 Subject: [PATCH 8/8] add Compress::with_predicate --- actix-web/CHANGES.md | 2 + actix-web/src/http/header/content_type.rs | 79 +++---- actix-web/src/middleware/compress.rs | 257 +++++++++++++++++----- shell.nix | 19 -- 4 files changed, 234 insertions(+), 123 deletions(-) delete mode 100644 shell.nix diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index 757e31eeb..03ea23330 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -5,10 +5,12 @@ ### Added - Add `Resource::{get, post, etc...}` methods for more concisely adding routes that don't need additional guards. +- Add `Compress::with_predicate()` method for customizing when compression is applied. ### Changed - Handler functions can now receive up to 16 extractor parameters. +- The `Compress` no longer compresses image or video content by default. ## 4.3.1 - 2023-02-26 diff --git a/actix-web/src/http/header/content_type.rs b/actix-web/src/http/header/content_type.rs index 7e399a1d0..bf86afffa 100644 --- a/actix-web/src/http/header/content_type.rs +++ b/actix-web/src/http/header/content_type.rs @@ -1,60 +1,54 @@ -use super::CONTENT_TYPE; use mime::Mime; +use super::CONTENT_TYPE; + crate::http::header::common_header! { - /// `Content-Type` header, defined - /// in [RFC 7231 §3.1.1.5](https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.5) + /// `Content-Type` header, defined in [RFC 9110 §8.3]. /// - /// The `Content-Type` header field indicates the media type of the - /// associated representation: either the representation enclosed in the - /// message payload or the selected representation, as determined by the - /// message semantics. The indicated media type defines both the data - /// format and how that data is intended to be processed by a recipient, - /// within the scope of the received message semantics, after any content - /// codings indicated by Content-Encoding are decoded. + /// The `Content-Type` header field indicates the media type of the associated representation: + /// either the representation enclosed in the message payload or the selected representation, + /// as determined by the message semantics. The indicated media type defines both the data + /// format and how that data is intended to be processed by a recipient, within the scope of the + /// received message semantics, after any content codings indicated by Content-Encoding are + /// decoded. /// - /// Although the `mime` crate allows the mime options to be any slice, this crate - /// forces the use of Vec. This is to make sure the same header can't have more than 1 type. If - /// this is an issue, it's possible to implement `Header` on a custom struct. + /// Although the `mime` crate allows the mime options to be any slice, this crate forces the use + /// of Vec. This is to make sure the same header can't have more than 1 type. If this is an + /// issue, it's possible to implement `Header` on a custom struct. /// /// # ABNF + /// /// ```plain /// Content-Type = media-type /// ``` /// /// # Example Values - /// * `text/html; charset=utf-8` - /// * `application/json` + /// + /// - `text/html; charset=utf-8` + /// - `application/json` /// /// # Examples - /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::ContentType; - /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// ContentType::json() - /// ); - /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::ContentType; + /// use actix_web::{http::header::ContentType, HttpResponse}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// ContentType(mime::TEXT_HTML) - /// ); + /// let res_json = HttpResponse::Ok() + /// .insert_header(ContentType::json()); + /// + /// let res_html = HttpResponse::Ok() + /// .insert_header(ContentType(mime::TEXT_HTML)); /// ``` + /// + /// [RFC 9110 §8.3]: https://datatracker.ietf.org/doc/html/rfc9110#section-8.3 (ContentType, CONTENT_TYPE) => [Mime] test_parse_and_format { crate::http::header::common_header_test!( - test1, + test_text_html, vec![b"text/html"], Some(HeaderField(mime::TEXT_HTML))); crate::http::header::common_header_test!( - test2, + test_image_star, vec![b"image/*"], Some(HeaderField(mime::IMAGE_STAR))); @@ -62,54 +56,49 @@ crate::http::header::common_header! { } impl ContentType { - /// A constructor to easily create a `Content-Type: application/json` - /// header. + /// Constructs a `Content-Type: application/json` header. #[inline] pub fn json() -> ContentType { ContentType(mime::APPLICATION_JSON) } - /// A constructor to easily create a `Content-Type: text/plain; - /// charset=utf-8` header. + /// Constructs a `Content-Type: text/plain; charset=utf-8` header. #[inline] pub fn plaintext() -> ContentType { ContentType(mime::TEXT_PLAIN_UTF_8) } - /// A constructor to easily create a `Content-Type: text/html; charset=utf-8` - /// header. + /// Constructs a `Content-Type: text/html; charset=utf-8` header. #[inline] pub fn html() -> ContentType { ContentType(mime::TEXT_HTML_UTF_8) } - /// A constructor to easily create a `Content-Type: text/xml` header. + /// Constructs a `Content-Type: text/xml` header. #[inline] pub fn xml() -> ContentType { ContentType(mime::TEXT_XML) } - /// A constructor to easily create a `Content-Type: - /// application/www-form-url-encoded` header. + /// Constructs a `Content-Type: application/www-form-url-encoded` header. #[inline] pub fn form_url_encoded() -> ContentType { ContentType(mime::APPLICATION_WWW_FORM_URLENCODED) } - /// A constructor to easily create a `Content-Type: image/jpeg` header. + /// Constructs a `Content-Type: image/jpeg` header. #[inline] pub fn jpeg() -> ContentType { ContentType(mime::IMAGE_JPEG) } - /// A constructor to easily create a `Content-Type: image/png` header. + /// Constructs a `Content-Type: image/png` header. #[inline] pub fn png() -> ContentType { ContentType(mime::IMAGE_PNG) } - /// A constructor to easily create a `Content-Type: - /// application/octet-stream` header. + /// Constructs a `Content-Type: application/octet-stream` header. #[inline] pub fn octet_stream() -> ContentType { ContentType(mime::APPLICATION_OCTET_STREAM) diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index c35e7cb6c..76c3d968b 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -5,6 +5,7 @@ use std::{ future::Future, marker::PhantomData, pin::Pin, + rc::Rc, task::{Context, Poll}, }; @@ -26,6 +27,8 @@ use crate::{ Error, HttpMessage, HttpResponse, }; +type CompressPredicateFn = Rc) -> bool>; + /// Middleware for compressing response payloads. /// /// # Encoding Negotiation @@ -76,27 +79,74 @@ use crate::{ #[derive(Clone)] #[non_exhaustive] pub struct Compress { - pub compress: fn(Option<&HeaderValue>) -> bool, + predicate: CompressPredicateFn, +} + +impl Compress { + /// Sets the `predicate` function to use when deciding if response should be compressed or not. + /// + /// The `predicate` function receives the response's current `Content-Type` header, if set. + /// Returning true from the predicate will instruct this middleware to compress the response. + /// + /// By default, video and image responses are unaffected (since they are typically compressed + /// already) and responses without a Content-Type header will be compressed. Custom predicate + /// functions should try to maintain these default rules. + /// + /// # Examples + /// + /// ``` + /// use actix_web::{App, middleware::Compress}; + /// + /// App::new() + /// .wrap(Compress::default().with_predicate(|content_type| { + /// // preserve that missing Content-Type header compresses content + /// let ct = match content_type.and_then(|ct| ct.to_str().ok()) { + /// None => return true, + /// Some(ct) => ct, + /// }; + /// + /// // parse Content-Type as MIME type + /// let ct_mime = match ct.parse::() { + /// Err(_) => return true, + /// Ok(mime) => mime, + /// }; + /// + /// // compress everything except HTML documents + /// ct_mime.subtype() != mime::HTML + /// })) + /// # ; + /// ``` + pub fn with_predicate( + self, + predicate: impl Fn(Option<&HeaderValue>) -> bool + 'static, + ) -> Self { + Self { + predicate: Rc::new(predicate), + } + } } impl fmt::Debug for Compress { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Compress").finish() + f.debug_struct("Compress").finish_non_exhaustive() } } + impl Default for Compress { fn default() -> Self { - Compress { - compress: |content_type| match content_type { + fn default_compress_predicate(content_type: Option<&HeaderValue>) -> bool { + match content_type { None => true, - Some(value) => { - let response_mime: Mime = value.to_str().unwrap().parse::().unwrap(); - match response_mime.type_().as_str() { - "image" => false, - _ => true, - } - } - }, + Some(hdr) => match hdr.to_str().ok().and_then(|hdr| hdr.parse::().ok()) { + Some(mime) if mime.type_().as_str() == "image" => false, + Some(mime) if mime.type_().as_str() == "video" => false, + _ => true, + }, + } + } + + Compress { + predicate: Rc::new(default_compress_predicate), } } } @@ -115,14 +165,14 @@ where fn new_transform(&self, service: S) -> Self::Future { ok(CompressMiddleware { service, - compress: self.compress, + predicate: Rc::clone(&self.predicate), }) } } pub struct CompressMiddleware { service: S, - compress: fn(Option<&HeaderValue>) -> bool, + predicate: CompressPredicateFn, } impl Service for CompressMiddleware @@ -148,8 +198,8 @@ where return Either::left(CompressResponse { encoding: Encoding::identity(), fut: self.service.call(req), + predicate: Rc::clone(&self.predicate), _phantom: PhantomData, - compress: self.compress, }) } @@ -176,8 +226,8 @@ where Some(encoding) => Either::left(CompressResponse { fut: self.service.call(req), encoding, + predicate: Rc::clone(&self.predicate), _phantom: PhantomData, - compress: self.compress, }), } } @@ -191,8 +241,8 @@ pin_project! { #[pin] fut: S::Future, encoding: Encoding, + predicate: CompressPredicateFn, _phantom: PhantomData, - compress: fn(Option<&HeaderValue>) -> bool, } } @@ -211,22 +261,22 @@ where let enc = match this.encoding { Encoding::Known(enc) => *enc, Encoding::Unknown(enc) => { - unimplemented!("encoding {} should not be here", enc); + unimplemented!("encoding '{enc}' should not be here"); } }; Poll::Ready(Ok(resp.map_body(move |head, body| { let content_type = head.headers.get(header::CONTENT_TYPE); - let should_compress = (self.compress)(content_type); - if should_compress { - EitherBody::left(Encoder::response(enc, head, body)) + + let should_compress = (self.predicate)(content_type); + + let enc = if should_compress { + enc } else { - EitherBody::left(Encoder::response( - ContentEncoding::Identity, - head, - body, - )) - } + ContentEncoding::Identity + }; + + EitherBody::left(Encoder::response(enc, head, body)) }))) } @@ -289,10 +339,20 @@ static SUPPORTED_ENCODINGS: &[Encoding] = &[ mod tests { use std::collections::HashSet; + // use static_assertions::assert_impl_all; + use super::*; use crate::http::header::ContentType; use crate::{middleware::DefaultHeaders, test, web, App}; + const HTML_DATA_PART: &str = "

hello world

) -> Vec { use std::io::Read as _; let mut decoder = flate2::read::GzDecoder::new(bytes.as_ref()); @@ -301,23 +361,55 @@ mod tests { buf } + #[track_caller] + fn assert_successful_res_with_content_type(res: &ServiceResponse, ct: &str) { + assert!(res.status().is_success()); + assert!( + res.headers() + .get(header::CONTENT_TYPE) + .expect("content-type header should be present") + .to_str() + .expect("content-type header should be utf-8") + .contains(ct), + "response's content-type did not match {}", + ct + ); + } + + #[track_caller] + fn assert_successful_gzip_res_with_content_type(res: &ServiceResponse, ct: &str) { + assert_successful_res_with_content_type(res, ct); + assert_eq!( + res.headers() + .get(header::CONTENT_ENCODING) + .expect("response should be gzip compressed"), + "gzip", + ); + } + + #[track_caller] + fn assert_successful_identity_res_with_content_type(res: &ServiceResponse, ct: &str) { + assert_successful_res_with_content_type(res, ct); + assert!( + res.headers().get(header::CONTENT_ENCODING).is_none(), + "response should not be compressed", + ); + } + #[actix_rt::test] async fn prevents_double_compressing() { - const D: &str = "hello world "; - const DATA: &str = const_str::repeat!(D, 100); - let app = test::init_service({ App::new() .wrap(Compress::default()) .route( "/single", - web::get().to(move || HttpResponse::Ok().body(DATA)), + web::get().to(move || HttpResponse::Ok().body(TEXT_DATA)), ) .service( web::resource("/double") .wrap(Compress::default()) .wrap(DefaultHeaders::new().add(("x-double", "true"))) - .route(web::get().to(move || HttpResponse::Ok().body(DATA))), + .route(web::get().to(move || HttpResponse::Ok().body(TEXT_DATA))), ) }) .await; @@ -331,7 +423,7 @@ mod tests { assert_eq!(res.headers().get("x-double"), None); assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip"); let bytes = test::read_body(res).await; - assert_eq!(gzip_decode(bytes), DATA.as_bytes()); + assert_eq!(gzip_decode(bytes), TEXT_DATA.as_bytes()); let req = test::TestRequest::default() .uri("/double") @@ -342,7 +434,7 @@ mod tests { assert_eq!(res.headers().get("x-double").unwrap(), "true"); assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip"); let bytes = test::read_body(res).await; - assert_eq!(gzip_decode(bytes), DATA.as_bytes()); + assert_eq!(gzip_decode(bytes), TEXT_DATA.as_bytes()); } #[actix_rt::test] @@ -369,33 +461,80 @@ mod tests { assert!(vary_headers.contains(&HeaderValue::from_static("accept-encoding"))); } + fn configure_predicate_test(cfg: &mut web::ServiceConfig) { + cfg.route( + "/html", + web::get().to(|| { + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(HTML_DATA) + }), + ) + .route( + "/image", + web::get().to(|| { + HttpResponse::Ok() + .content_type(ContentType::jpeg()) + .body(TEXT_DATA) + }), + ); + } + #[actix_rt::test] async fn prevents_compression_jpeg() { - const D: &str = "test image"; - const DATA: &str = const_str::repeat!(D, 100); - let app = test::init_service({ - App::new().wrap(Compress::default()).route( - "/image", - web::get().to(move || { - let builder = HttpResponse::Ok() - .insert_header(ContentType::jpeg()) - .body(DATA); - builder - }), - ) - }) + let app = test::init_service( + App::new() + .wrap(Compress::default()) + .configure(configure_predicate_test), + ) .await; - let req = test::TestRequest::default() - .uri("/image") - .insert_header((header::ACCEPT_ENCODING, "gzip")) - .to_request(); - let res = test::call_service(&app, req).await; - assert_eq!(res.status(), StatusCode::OK); - assert_eq!( - res.headers().get(header::CONTENT_TYPE).unwrap(), - "image/jpeg" - ); - let bytes = test::read_body(res).await; - assert_eq!(bytes, DATA.as_bytes()); + + let req = test::TestRequest::with_uri("/html") + .insert_header((header::ACCEPT_ENCODING, "gzip")); + let res = test::call_service(&app, req.to_request()).await; + assert_successful_gzip_res_with_content_type(&res, "text/html"); + assert_ne!(test::read_body(res).await, HTML_DATA.as_bytes()); + + let req = test::TestRequest::with_uri("/image") + .insert_header((header::ACCEPT_ENCODING, "gzip")); + let res = test::call_service(&app, req.to_request()).await; + assert_successful_identity_res_with_content_type(&res, "image/jpeg"); + assert_eq!(test::read_body(res).await, TEXT_DATA.as_bytes()); + } + + #[actix_rt::test] + async fn prevents_compression_custom_predicate() { + let app = test::init_service( + App::new() + .wrap(Compress::default().with_predicate(|hdr| { + // preserve that missing CT header compresses content + let hdr = match hdr.and_then(|hdr| hdr.to_str().ok()) { + None => return true, + Some(hdr) => hdr, + }; + + let mime = match hdr.parse::() { + Err(_) => return true, + Ok(mime) => mime, + }; + + // compress everything except HTML documents + mime.subtype() != mime::HTML + })) + .configure(configure_predicate_test), + ) + .await; + + let req = test::TestRequest::with_uri("/html") + .insert_header((header::ACCEPT_ENCODING, "gzip")); + let res = test::call_service(&app, req.to_request()).await; + assert_successful_identity_res_with_content_type(&res, "text/html"); + assert_eq!(test::read_body(res).await, HTML_DATA.as_bytes()); + + let req = test::TestRequest::with_uri("/image") + .insert_header((header::ACCEPT_ENCODING, "gzip")); + let res = test::call_service(&app, req.to_request()).await; + assert_successful_gzip_res_with_content_type(&res, "image/jpeg"); + assert_ne!(test::read_body(res).await, TEXT_DATA.as_bytes()); } } diff --git a/shell.nix b/shell.nix deleted file mode 100644 index 18dfd2590..000000000 --- a/shell.nix +++ /dev/null @@ -1,19 +0,0 @@ -{ pkgs ? import { } }: - -with pkgs; - -mkShell rec { - nativeBuildInputs = [ - pkg-config - emacs - rust-analyzer - openssl - ripgrep - ]; - buildInputs = [ - udev alsa-lib vulkan-loader - xorg.libX11 xorg.libXcursor xorg.libXi xorg.libXrandr # To use the x11 feature - libxkbcommon wayland # To use the wayland feature - ]; - LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs; -}