From 80d7d9c01a63d787ca9d027d752566161b69ca26 Mon Sep 17 00:00:00 2001 From: Yuki Okushi Date: Sun, 8 Feb 2026 10:30:19 +0900 Subject: [PATCH 1/7] chore(awc): address clippy warnings (#3909) --- awc/src/request.rs | 10 +++------- awc/src/ws.rs | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/awc/src/request.rs b/awc/src/request.rs index 71ea2ef1e..02ff4ef57 100644 --- a/awc/src/request.rs +++ b/awc/src/request.rs @@ -263,13 +263,9 @@ impl ClientRequest { /// ``` #[cfg(feature = "cookies")] pub fn cookie(mut self, cookie: Cookie<'_>) -> Self { - if self.cookies.is_none() { - let mut jar = CookieJar::new(); - jar.add(cookie.into_owned()); - self.cookies = Some(jar) - } else { - self.cookies.as_mut().unwrap().add(cookie.into_owned()); - } + self.cookies + .get_or_insert_with(CookieJar::new) + .add(cookie.into_owned()); self } diff --git a/awc/src/ws.rs b/awc/src/ws.rs index 3ce1d286a..c33531d41 100644 --- a/awc/src/ws.rs +++ b/awc/src/ws.rs @@ -125,13 +125,9 @@ impl WebsocketsRequest { /// Set a cookie #[cfg(feature = "cookies")] pub fn cookie(mut self, cookie: Cookie<'_>) -> Self { - if self.cookies.is_none() { - let mut jar = CookieJar::new(); - jar.add(cookie.into_owned()); - self.cookies = Some(jar) - } else { - self.cookies.as_mut().unwrap().add(cookie.into_owned()); - } + self.cookies + .get_or_insert_with(CookieJar::new) + .add(cookie.into_owned()); self } From 9856a3b056adc7c3747a2a41c95ec4599a63da67 Mon Sep 17 00:00:00 2001 From: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:15:38 -0800 Subject: [PATCH 2/7] Support serving pre-compressed files for static sites (#2615) * support serving pre-compressed files for static sites * Update CHANGES.md * fix behavior change for audio file * follow-up some inconsistency * test(files): make encoding test independent of fixture line endings --------- Co-authored-by: Rob Ede Co-authored-by: Yuki Okushi --- actix-files/CHANGES.md | 4 + actix-files/src/files.rs | 13 ++++ actix-files/src/named.rs | 107 +++++++++++++++----------- actix-files/src/service.rs | 141 +++++++++++++++++++++++++++++++++- actix-files/tests/encoding.rs | 130 +++++++++++++++++++++++++++++++ actix-files/tests/utf8.txt.br | Bin 0 -> 49 bytes actix-files/tests/utf8.txt.gz | Bin 0 -> 76 bytes 7 files changed, 346 insertions(+), 49 deletions(-) create mode 100644 actix-files/tests/utf8.txt.br create mode 100644 actix-files/tests/utf8.txt.gz diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index 281e05312..3cc5afb53 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -2,6 +2,10 @@ ## Unreleased +- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] + +[#2615]: https://github.com/actix/actix-web/pull/2615 + ## 0.6.10 ### Security Notice diff --git a/actix-files/src/files.rs b/actix-files/src/files.rs index a5e22a6d0..7440a43e7 100644 --- a/actix-files/src/files.rs +++ b/actix-files/src/files.rs @@ -50,6 +50,7 @@ pub struct Files { use_guards: Option>, guards: Vec>, hidden_files: bool, + try_compressed: bool, read_mode_threshold: u64, } @@ -76,6 +77,7 @@ impl Clone for Files { use_guards: self.use_guards.clone(), guards: self.guards.clone(), hidden_files: self.hidden_files, + try_compressed: self.try_compressed, read_mode_threshold: self.read_mode_threshold, } } @@ -128,6 +130,7 @@ impl Files { use_guards: None, guards: Vec::new(), hidden_files: false, + try_compressed: false, read_mode_threshold: 0, } } @@ -351,6 +354,15 @@ impl Files { self.hidden_files = true; self } + + /// Attempts to search for a suitable pre-compressed version of a file on disk before falling + /// back to the uncompressed version. + /// + /// Currently, `.gz`, `.br`, and `.zst` files are supported. + pub fn try_compressed(mut self) -> Self { + self.try_compressed = true; + self + } } impl HttpServiceFactory for Files { @@ -402,6 +414,7 @@ impl ServiceFactory for Files { file_flags: self.file_flags, guards: self.use_guards.clone(), hidden_files: self.hidden_files, + try_compressed: self.try_compressed, size_threshold: self.read_mode_threshold, with_permanent_redirect: self.with_permanent_redirect, }; diff --git a/actix-files/src/named.rs b/actix-files/src/named.rs index 3588ae8bc..77ae53d1c 100644 --- a/actix-files/src/named.rs +++ b/actix-files/src/named.rs @@ -91,6 +91,55 @@ pub(crate) use tokio_uring::fs::File; use super::chunked; +pub(crate) fn get_content_type_and_disposition( + path: &Path, +) -> Result<(mime::Mime, ContentDisposition), io::Error> { + let filename = match path.file_name() { + Some(name) => name.to_string_lossy(), + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Provided path has no filename", + )); + } + }; + + let ct = mime_guess::from_path(path).first_or_octet_stream(); + + let disposition = match ct.type_() { + mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline, + mime::APPLICATION => match ct.subtype() { + mime::JAVASCRIPT | mime::JSON => DispositionType::Inline, + name if name == "wasm" || name == "xhtml" => DispositionType::Inline, + _ => DispositionType::Attachment, + }, + _ => DispositionType::Attachment, + }; + + // replace special characters in filenames which could occur on some filesystems + let filename_s = filename + .replace('\n', "%0A") // \n line break + .replace('\x0B', "%0B") // \v vertical tab + .replace('\x0C', "%0C") // \f form feed + .replace('\r', "%0D"); // \r carriage return + let mut parameters = vec![DispositionParam::Filename(filename_s)]; + + if !filename.is_ascii() { + parameters.push(DispositionParam::FilenameExt(ExtendedValue { + charset: Charset::Ext(String::from("UTF-8")), + language_tag: None, + value: filename.into_owned().into_bytes(), + })) + } + + let cd = ContentDisposition { + disposition, + parameters, + }; + + Ok((ct, cd)) +} + impl NamedFile { /// Creates an instance from a previously opened file. /// @@ -117,52 +166,7 @@ impl NamedFile { // Get the name of the file and use it to construct default Content-Type // and Content-Disposition values - let (content_type, content_disposition) = { - let filename = match path.file_name() { - Some(name) => name.to_string_lossy(), - None => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Provided path has no filename", - )); - } - }; - - let ct = mime_guess::from_path(&path).first_or_octet_stream(); - - let disposition = match ct.type_() { - mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline, - mime::APPLICATION => match ct.subtype() { - mime::JAVASCRIPT | mime::JSON => DispositionType::Inline, - name if name == "wasm" || name == "xhtml" => DispositionType::Inline, - _ => DispositionType::Attachment, - }, - _ => DispositionType::Attachment, - }; - - // replace special characters in filenames which could occur on some filesystems - let filename_s = filename - .replace('\n', "%0A") // \n line break - .replace('\x0B', "%0B") // \v vertical tab - .replace('\x0C', "%0C") // \f form feed - .replace('\r', "%0D"); // \r carriage return - let mut parameters = vec![DispositionParam::Filename(filename_s)]; - - if !filename.is_ascii() { - parameters.push(DispositionParam::FilenameExt(ExtendedValue { - charset: Charset::Ext(String::from("UTF-8")), - language_tag: None, - value: filename.into_owned().into_bytes(), - })) - } - - let cd = ContentDisposition { - disposition, - parameters, - }; - - (ct, cd) - }; + let (content_type, content_disposition) = get_content_type_and_disposition(&path)?; let md = { #[cfg(not(feature = "experimental-io-uring"))] @@ -710,3 +714,14 @@ impl HttpServiceFactory for NamedFile { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audio_files_use_inline_content_disposition() { + let (_ct, cd) = get_content_type_and_disposition(Path::new("sound.mp3")).unwrap(); + assert_eq!(cd.disposition, DispositionType::Inline); + } +} diff --git a/actix-files/src/service.rs b/actix-files/src/service.rs index f63ba46c6..ae6725385 100644 --- a/actix-files/src/service.rs +++ b/actix-files/src/service.rs @@ -1,4 +1,9 @@ -use std::{fmt, io, ops::Deref, path::PathBuf, rc::Rc}; +use std::{ + fmt, io, + ops::Deref, + path::{Path, PathBuf}, + rc::Rc, +}; use actix_web::{ body::BoxBody, @@ -39,6 +44,7 @@ pub struct FilesServiceInner { pub(crate) file_flags: named::Flags, pub(crate) guards: Option>, pub(crate) hidden_files: bool, + pub(crate) try_compressed: bool, pub(crate) size_threshold: u64, pub(crate) with_permanent_redirect: bool, } @@ -64,7 +70,12 @@ impl FilesService { } } - fn serve_named_file(&self, req: ServiceRequest, mut named_file: NamedFile) -> ServiceResponse { + fn serve_named_file_with_encoding( + &self, + req: ServiceRequest, + mut named_file: NamedFile, + encoding: header::ContentEncoding, + ) -> ServiceResponse { if let Some(ref mime_override) = self.mime_override { let new_disposition = mime_override(&named_file.content_type.type_()); named_file.content_disposition.disposition = new_disposition; @@ -72,12 +83,36 @@ impl FilesService { named_file.flags = self.file_flags; let (req, _) = req.into_parts(); - let res = named_file + let mut res = named_file .read_mode_threshold(self.size_threshold) .into_response(&req); + + let header_value = match encoding { + header::ContentEncoding::Brotli => Some("br"), + header::ContentEncoding::Gzip => Some("gzip"), + header::ContentEncoding::Zstd => Some("zstd"), + header::ContentEncoding::Identity => None, + // Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here + _ => unreachable!(), + }; + if let Some(header_value) = header_value { + res.headers_mut().insert( + header::CONTENT_ENCODING, + header::HeaderValue::from_static(header_value), + ); + // Response representation varies by Accept-Encoding when serving pre-compressed assets. + res.headers_mut().append( + header::VARY, + header::HeaderValue::from_static("accept-encoding"), + ); + } ServiceResponse::new(req, res) } + fn serve_named_file(&self, req: ServiceRequest, named_file: NamedFile) -> ServiceResponse { + self.serve_named_file_with_encoding(req, named_file, header::ContentEncoding::Identity) + } + fn show_index(&self, req: ServiceRequest, path: PathBuf) -> ServiceResponse { let dir = Directory::new(self.directory.clone(), path); @@ -138,6 +173,15 @@ impl Service for FilesService { // full file path let path = this.directory.join(&path_on_disk); + + // Try serving pre-compressed file even if the uncompressed file doesn't exist yet. + // Still handle directories (index/listing) through the normal branch below. + if this.try_compressed && !path.is_dir() { + if let Some((named_file, encoding)) = find_compressed(&req, &path).await { + return Ok(this.serve_named_file_with_encoding(req, named_file, encoding)); + } + } + if let Err(err) = path.canonicalize() { return this.handle_err(err, req).await; } @@ -163,6 +207,16 @@ impl Service for FilesService { match this.index { Some(ref index) => { let named_path = path.join(index); + if this.try_compressed { + if let Some((named_file, encoding)) = + find_compressed(&req, &named_path).await + { + return Ok( + this.serve_named_file_with_encoding(req, named_file, encoding) + ); + } + } + // fallback to the uncompressed version match NamedFile::open_async(named_path).await { Ok(named_file) => Ok(this.serve_named_file(req, named_file)), Err(_) if this.show_index => Ok(this.show_index(req, path)), @@ -184,3 +238,84 @@ impl Service for FilesService { }) } } + +/// Flate doesn't have an accepted file extension, so it is not included here. +const SUPPORTED_PRECOMPRESSION_ENCODINGS: &[header::ContentEncoding] = &[ + header::ContentEncoding::Brotli, + header::ContentEncoding::Gzip, + header::ContentEncoding::Zstd, + header::ContentEncoding::Identity, +]; + +/// Searches disk for an acceptable alternate encoding of the content at the given path, as +/// preferred by the request's `Accept-Encoding` header. Returns the corresponding `NamedFile` with +/// the most appropriate supported encoding, if any exist. +async fn find_compressed( + req: &ServiceRequest, + original_path: &Path, +) -> Option<(NamedFile, header::ContentEncoding)> { + use actix_web::HttpMessage; + use header::{AcceptEncoding, ContentEncoding, Encoding}; + + // Retrieve the content type and content disposition based on the original filename. If we + // can't get these successfully, don't even try to find a compressed file. + let (content_type, content_disposition) = + match crate::named::get_content_type_and_disposition(original_path) { + Ok(values) => values, + Err(_) => return None, + }; + + let accept_encoding = req.get_header::()?; + + let mut supported = SUPPORTED_PRECOMPRESSION_ENCODINGS + .iter() + .copied() + .map(Encoding::Known) + .collect::>(); + + // Only move the original content-type/disposition into the chosen compressed file once. + let mut content_type = Some(content_type); + let mut content_disposition = Some(content_disposition); + + loop { + // Select next acceptable encoding (honouring q=0 rejections) from remaining supported set. + let chosen = accept_encoding.negotiate(supported.iter())?; + + let encoding = match chosen { + Encoding::Known(enc) => enc, + // No supported encoding should ever be unknown here. + Encoding::Unknown(_) => return None, + }; + + // Identity indicates there is no acceptable pre-compressed representation. + if encoding == ContentEncoding::Identity { + return None; + } + + let extension = match encoding { + ContentEncoding::Brotli => ".br", + ContentEncoding::Gzip => ".gz", + ContentEncoding::Zstd => ".zst", + ContentEncoding::Identity => unreachable!(), + // Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here. + _ => unreachable!(), + }; + + let mut compressed_path = original_path.to_owned(); + let mut filename = compressed_path.file_name()?.to_owned(); + filename.push(extension); + compressed_path.set_file_name(filename); + + match NamedFile::open_async(&compressed_path).await { + Ok(mut named_file) => { + named_file.content_type = content_type.take().unwrap(); + named_file.content_disposition = content_disposition.take().unwrap(); + return Some((named_file, encoding)); + } + // Ignore errors while searching disk for a suitable encoding. + Err(_) => { + supported.retain(|enc| enc != &chosen); + } + } + } +} diff --git a/actix-files/tests/encoding.rs b/actix-files/tests/encoding.rs index 3c8bdb59b..019abfb57 100644 --- a/actix-files/tests/encoding.rs +++ b/actix-files/tests/encoding.rs @@ -36,6 +36,136 @@ async fn test_utf8_file_contents() { ); } +#[actix_web::test] +async fn test_compression_encodings() { + use actix_web::body::MessageBody; + + let utf8_txt_len = std::fs::metadata("./tests/utf8.txt").unwrap().len(); + let utf8_txt_br_len = std::fs::metadata("./tests/utf8.txt.br").unwrap().len(); + let utf8_txt_gz_len = std::fs::metadata("./tests/utf8.txt.gz").unwrap().len(); + + let srv = + test::init_service(App::new().service(Files::new("/", "./tests").try_compressed())).await; + + // Select the requested encoding when present + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!( + res.headers().get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static("gzip")), + ); + assert_eq!( + res.headers().get(header::VARY), + Some(&HeaderValue::from_static("accept-encoding")), + ); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(utf8_txt_gz_len), + ); + + // Select the highest priority encoding + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip;q=0.6,br;q=0.8,*"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!( + res.headers().get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static("br")), + ); + assert_eq!( + res.headers().get(header::VARY), + Some(&HeaderValue::from_static("accept-encoding")), + ); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(utf8_txt_br_len), + ); + + // Request encoding that doesn't exist on disk and fallback to no encoding + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("zstd"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(utf8_txt_len), + ); + + // Do not select an encoding explicitly refused via q=0 + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("zstd;q=1, gzip;q=0"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(utf8_txt_len), + ); + + // Can still request a compressed file directly + let req = TestRequest::with_uri("/utf8.txt.gz").to_request(); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("application/gzip")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,); + + // Don't try compressed files + let srv = test::init_service(App::new().service(Files::new("/", "./tests"))).await; + + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None); +} + #[actix_web::test] async fn partial_range_response_encoding() { let srv = test::init_service(App::new().default_service(web::to(|| async { diff --git a/actix-files/tests/utf8.txt.br b/actix-files/tests/utf8.txt.br new file mode 100644 index 0000000000000000000000000000000000000000..c06efd6c97eb60626b2fbba25c908057bd066131 GIT binary patch literal 49 zcmV-10M7p*tN;Y$xUJ@vhvkNa<*vErn7-$vy5_B;=b^6SfPxANMQ&$oX>({GX>%Y? HOD+ln$5I&k literal 0 HcmV?d00001 diff --git a/actix-files/tests/utf8.txt.gz b/actix-files/tests/utf8.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..3fbf022644d7893f40b9262fe2eabec30662ce50 GIT binary patch literal 76 zcmV-S0JHxeiwFovi1T3p19fy}I4*Q}bN~S?0Mq~ExUJ@vhvkNa<*vErn7-$vy5_B; i=b^6SfPxANMQ&$oX>({GX>%Y?OD+nJ%#b830000{wIWvl literal 0 HcmV?d00001 From 41e4863748ac0c8e446d96528fa1c31b2b20aae1 Mon Sep 17 00:00:00 2001 From: Yuki Okushi Date: Sun, 8 Feb 2026 16:03:04 +0900 Subject: [PATCH 3/7] fix(awc): do not request as chunked if body is empty (#3910) --- awc/CHANGES.md | 1 + awc/src/client/h1proto.rs | 46 ++++++++++++++--- awc/tests/test_empty_stream.rs | 91 ++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 awc/tests/test_empty_stream.rs diff --git a/awc/CHANGES.md b/awc/CHANGES.md index 7d09212c2..35a3dde2f 100644 --- a/awc/CHANGES.md +++ b/awc/CHANGES.md @@ -3,6 +3,7 @@ ## Unreleased - Minimum supported Rust version (MSRV) is now 1.88. +- Fix empty streaming request bodies being sent with chunked transfer encoding. ## 3.8.1 diff --git a/awc/src/client/h1proto.rs b/awc/src/client/h1proto.rs index 3f4c9f979..3d8d8db08 100644 --- a/awc/src/client/h1proto.rs +++ b/awc/src/client/h1proto.rs @@ -34,6 +34,35 @@ where B: MessageBody, B::Error: Into, { + actix_rt::pin!(body); + + let orig_length = body.size(); + let mut length = orig_length; + let mut first_chunk = None; + + // This avoids sending `Transfer-Encoding: chunked` for requests with an empty body stream. + // https://github.com/actix/actix-web/issues/2320 + if matches!(orig_length, BodySize::Stream) { + enum Peek { + Pending, + Item(Result), + Eof, + } + + match poll_fn(|cx| match body.as_mut().poll_next(cx) { + Poll::Pending => Poll::Ready(Peek::Pending), + Poll::Ready(Some(res)) => Poll::Ready(Peek::Item(res)), + Poll::Ready(None) => Poll::Ready(Peek::Eof), + }) + .await + { + Peek::Pending => {} + Peek::Eof => length = BodySize::Sized(0), + Peek::Item(Ok(chunk)) => first_chunk = Some(chunk), + Peek::Item(Err(err)) => return Err(SendRequestError::Body(err.into())), + } + } + // set request host header if !head.as_ref().headers.contains_key(HOST) && !head.extra_headers().iter().any(|h| h.contains_key(HOST)) @@ -67,7 +96,7 @@ where // Check EXPECT header and enable expect handle flag accordingly. // See https://datatracker.ietf.org/doc/html/rfc7231#section-5.1.1 let is_expect = if head.as_ref().headers.contains_key(EXPECT) { - match body.size() { + match orig_length { BodySize::None | BodySize::Sized(0) => { let keep_alive = framed.codec_ref().keep_alive(); framed.io_mut().on_release(keep_alive); @@ -86,7 +115,7 @@ where // special handle for EXPECT request. let (do_send, mut res_head) = if is_expect { - pin_framed.send((head, body.size()).into()).await?; + pin_framed.send((head, length).into()).await?; let head = poll_fn(|cx| pin_framed.as_mut().poll_next(cx)) .await @@ -96,18 +125,18 @@ where // and current head would be used as final response head. (head.status == StatusCode::CONTINUE, Some(head)) } else { - pin_framed.feed((head, body.size()).into()).await?; + pin_framed.feed((head, length).into()).await?; (true, None) }; if do_send { // send request body - match body.size() { + match length { BodySize::None | BodySize::Sized(0) => { poll_fn(|cx| pin_framed.as_mut().flush(cx)).await?; } - _ => send_body(body, pin_framed.as_mut()).await?, + _ => send_body(body.as_mut(), pin_framed.as_mut(), first_chunk).await?, }; // read response and init read body @@ -157,15 +186,18 @@ where /// send request body to the peer pub(crate) async fn send_body( - body: B, + mut body: Pin<&mut B>, mut framed: Pin<&mut Framed>, + first_chunk: Option, ) -> Result<(), SendRequestError> where Io: ConnectionIo, B: MessageBody, B::Error: Into, { - actix_rt::pin!(body); + if let Some(chunk) = first_chunk { + framed.as_mut().write(h1::Message::Chunk(Some(chunk)))?; + } let mut eof = false; while !eof { diff --git a/awc/tests/test_empty_stream.rs b/awc/tests/test_empty_stream.rs new file mode 100644 index 000000000..76f6337ca --- /dev/null +++ b/awc/tests/test_empty_stream.rs @@ -0,0 +1,91 @@ +use std::{convert::Infallible, time::Duration}; + +use actix_rt::net::TcpListener; +use awc::Client; +use bytes::Bytes; +use futures_util::stream; +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + time::timeout, +}; + +#[actix_rt::test] +async fn empty_body_stream_does_not_use_chunked_encoding() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Minimal HTTP/1.1 server that rejects chunked requests. + let srv = actix_rt::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + + let mut buf = Vec::with_capacity(1024); + let mut tmp = [0u8; 1024]; + + let header_end = loop { + let n = timeout(Duration::from_secs(2), sock.read(&mut tmp)) + .await + .unwrap() + .unwrap(); + if n == 0 { + break None; + } + + buf.extend_from_slice(&tmp[..n]); + + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break Some(pos + 4); + } + + if buf.len() > 16 * 1024 { + break None; + } + } + .expect("did not receive complete request headers"); + + let headers_lower = String::from_utf8_lossy(&buf[..header_end]).to_ascii_lowercase(); + let has_chunked = headers_lower.contains("\r\ntransfer-encoding: chunked\r\n"); + + if has_chunked { + // Drain terminating chunk so client doesn't error on write before response is read. + let terminator = b"0\r\n\r\n"; + while !buf[header_end..] + .windows(terminator.len()) + .any(|w| w == terminator) + { + let n = match timeout(Duration::from_secs(2), sock.read(&mut tmp)).await { + Ok(Ok(n)) => n, + _ => break, + }; + + if n == 0 { + break; + } + + buf.extend_from_slice(&tmp[..n]); + + if buf.len() > 32 * 1024 { + break; + } + } + } + + let status = if has_chunked { + "400 Bad Request" + } else { + "200 OK" + }; + let resp = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + sock.write_all(resp.as_bytes()).await.unwrap(); + }); + + let url = format!("http://{addr}/"); + let res = Client::default() + .get(url) + .send_stream(stream::empty::>()) + .await + .unwrap(); + + assert!(res.status().is_success()); + + srv.await.unwrap(); +} From b2523fb1cc8d9de8cbd73a2577c8df68d6223a74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:32:59 +0900 Subject: [PATCH 4/7] build(deps): bump taiki-e/install-action from 2.67.18 to 2.67.25 (#3912) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.18 to 2.67.25. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/650c5ca14212efbbf3e580844b04bdccf68dac31...f176c07a0a40cbfdd08ee9aa8bf1655701d11e69) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.67.25 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-post-merge.yml | 4 ++-- .github/workflows/ci.yml | 4 ++-- .github/workflows/coverage.yml | 2 +- .github/workflows/lint.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index acf0e6135..cfdb53daa 100644 --- a/.github/workflows/ci-post-merge.yml +++ b/.github/workflows/ci-post-merge.yml @@ -49,7 +49,7 @@ jobs: toolchain: ${{ matrix.version.version }} - name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean - uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 with: tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean @@ -83,7 +83,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 - name: Install just, cargo-hack - uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 with: tool: just,cargo-hack diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db82b6e36..2f8f0695c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: toolchain: ${{ matrix.version.version }} - name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean - uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 with: tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean @@ -117,7 +117,7 @@ jobs: toolchain: nightly - name: Install just - uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 with: tool: just diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6fba6ad08..e5759292b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -24,7 +24,7 @@ jobs: components: llvm-tools - name: Install just, cargo-llvm-cov, cargo-nextest - uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 with: tool: just,cargo-llvm-cov,cargo-nextest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a630b9ba8..85f4434a0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -77,7 +77,7 @@ jobs: toolchain: ${{ vars.RUST_VERSION_EXTERNAL_TYPES }} - name: Install just - uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 with: tool: just From 747d7c0def41c1ad564dc85f183fe874df9aa52c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:58:49 +0000 Subject: [PATCH 5/7] build(deps): bump memchr from 2.7.6 to 2.8.0 (#3913) Bumps [memchr](https://github.com/BurntSushi/memchr) from 2.7.6 to 2.8.0. - [Commits](https://github.com/BurntSushi/memchr/compare/2.7.6...2.8.0) --- updated-dependencies: - dependency-name: memchr dependency-version: 2.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Yuki Okushi --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04f0cbd72..aa289d720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1903,9 +1903,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" From 32cb3b8361356e75b0b043798cc46efb0d9a85bf Mon Sep 17 00:00:00 2001 From: Filip Gregor <44952616+gregofi@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:53:40 +0100 Subject: [PATCH 6/7] feat: ignore unparsable cookies in Cookie header (#3814) fix: ignore unparsable cookies in Cookie header Co-authored-by: Rob Ede --- actix-web/CHANGES.md | 1 + actix-web/src/request.rs | 25 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index 47624967d..173d08224 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -4,6 +4,7 @@ - Minimum supported Rust version (MSRV) is now 1.88. - Add `HttpRequest::url_for_map` and `HttpRequest::url_for_iter` methods for named URL parameters. [#3895] +- Ignore unparsable cookies in `Cookie` request header. [#3895]: https://github.com/actix/actix-web/pull/3895 diff --git a/actix-web/src/request.rs b/actix-web/src/request.rs index 3a3e539bf..90a437928 100644 --- a/actix-web/src/request.rs +++ b/actix-web/src/request.rs @@ -414,6 +414,9 @@ impl HttpRequest { } /// Load request cookies. + /// + /// Any cookie that cannot be parsed is omitted from the result. + /// This includes cookies with an empty name (e.g. `document.cookie = "=value"`). #[cfg(feature = "cookies")] pub fn cookies(&self) -> Result>>, CookieParseError> { use actix_http::header::COOKIE; @@ -422,9 +425,9 @@ impl HttpRequest { let mut cookies = Vec::new(); for hdr in self.headers().get_all(COOKIE) { let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?; - for cookie_str in s.split(';').map(|s| s.trim()) { - if !cookie_str.is_empty() { - cookies.push(Cookie::parse_encoded(cookie_str)?.into_owned()); + for cookie_str in s.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Ok(cookie) = Cookie::parse_encoded(cookie_str) { + cookies.push(cookie.into_owned()); } } } @@ -677,6 +680,22 @@ mod tests { assert!(cookie.is_none()); } + #[test] + #[cfg(feature = "cookies")] + fn test_empty_key() { + let req = TestRequest::default() + .append_header((header::COOKIE, "cookie1=value1; value2; cookie3=value3")) + .to_http_request(); + { + let cookies = req.cookies().unwrap(); + assert_eq!(cookies.len(), 2); + assert_eq!(cookies[0].name(), "cookie1"); + assert_eq!(cookies[0].value(), "value1"); + assert_eq!(cookies[1].name(), "cookie3"); + assert_eq!(cookies[1].value(), "value3"); + } + } + #[test] fn test_request_query() { let req = TestRequest::with_uri("/?id=test").to_http_request(); From 5548fadc7d5d14151658be5630d449045e0bc368 Mon Sep 17 00:00:00 2001 From: Yuki Okushi Date: Tue, 10 Feb 2026 06:40:29 +0900 Subject: [PATCH 7/7] fix(files): handle `bytes=0-` nicely (#3914) --- actix-files/CHANGES.md | 1 + actix-files/src/lib.rs | 24 ++++++++++++++++++++++++ actix-files/src/named.rs | 4 +++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index 3cc5afb53..ef0b79ae4 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -3,6 +3,7 @@ ## Unreleased - Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] +- Fix handling of `bytes=0-` [#2615]: https://github.com/actix/actix-web/pull/2615 diff --git a/actix-files/src/lib.rs b/actix-files/src/lib.rs index 5ea578c0d..bf5397ecf 100644 --- a/actix-files/src/lib.rs +++ b/actix-files/src/lib.rs @@ -513,6 +513,30 @@ mod tests { assert_eq!(content_range.to_str().unwrap(), "bytes */100"); } + #[actix_rt::test] + async fn test_named_file_range_header_from_zero_to_end_returns_partial_content() { + let srv = actix_test::start(|| App::new().service(Files::new("/", "."))); + + let response = srv + .get("/tests/test.binary") + .insert_header((header::RANGE, "bytes=0-")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + + let content_range = response.headers().get(header::CONTENT_RANGE).unwrap(); + assert_eq!(content_range.to_str().unwrap(), "bytes 0-99/100"); + + let content_length = response.headers().get(header::CONTENT_LENGTH).unwrap(); + assert_eq!(content_length.to_str().unwrap(), "100"); + + // Should be no transfer-encoding + let transfer_encoding = response.headers().get(header::TRANSFER_ENCODING); + assert!(transfer_encoding.is_none()); + } + #[actix_rt::test] async fn test_named_file_content_length_headers() { let srv = actix_test::start(|| App::new().service(Files::new("/", "."))); diff --git a/actix-files/src/named.rs b/actix-files/src/named.rs index 77ae53d1c..1a3c2b3a1 100644 --- a/actix-files/src/named.rs +++ b/actix-files/src/named.rs @@ -550,6 +550,7 @@ impl NamedFile { let mut length = self.md.len(); let mut offset = 0; + let mut ranged_req = false; // check for range header if let Some(ranges) = req.headers().get(header::RANGE) { @@ -558,6 +559,7 @@ impl NamedFile { .ok() .and_then(|ranges| ranges.first().copied()) { + ranged_req = true; length = range.length; offset = range.start; @@ -606,7 +608,7 @@ impl NamedFile { let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold); - if offset != 0 || length != self.md.len() { + if ranged_req { res.status(StatusCode::PARTIAL_CONTENT); }