From ac7b47c455f3fc285343794dd47dc68171e54cc5 Mon Sep 17 00:00:00 2001
From: Jess Izen <44884346+jlizen@users.noreply.github.com>
Date: Fri, 22 May 2026 14:17:05 +0200
Subject: [PATCH 01/11] fix(compression): forward trailers from inner body
after compression finishes (#685)
---
tower-http/src/compression/mod.rs | 85 ++++++++++++++++++++++++++++-
tower-http/src/compression_utils.rs | 12 +---
2 files changed, 85 insertions(+), 12 deletions(-)
diff --git a/tower-http/src/compression/mod.rs b/tower-http/src/compression/mod.rs
index 420a9e88..c32fc579 100644
--- a/tower-http/src/compression/mod.rs
+++ b/tower-http/src/compression/mod.rs
@@ -94,6 +94,7 @@ mod tests {
use super::*;
use crate::test_helpers::{Body, WithTrailers};
use async_compression::tokio::write::{BrotliDecoder, BrotliEncoder};
+ use bytes::Bytes;
use flate2::read::GzDecoder;
use http::header::{
ACCEPT_ENCODING, ACCEPT_RANGES, CONTENT_ENCODING, CONTENT_RANGE, CONTENT_TYPE, RANGE,
@@ -106,7 +107,7 @@ mod tests {
use std::sync::{Arc, RwLock};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_util::io::StreamReader;
- use tower::{service_fn, Service, ServiceExt};
+ use tower::{service_fn, BoxError, Service, ServiceExt};
// Compression filter allows every other request to be compressed
#[derive(Clone)]
@@ -522,6 +523,88 @@ mod tests {
assert_eq!(decompressed, "Hello, World!");
}
+ #[tokio::test]
+ async fn trailers_with_empty_body() {
+ let svc = service_fn(|_req: Request
| async {
+ let mut trailers = HeaderMap::new();
+ trailers.insert("grpc-status", "0".parse().unwrap());
+ trailers.insert("grpc-message", "OK".parse().unwrap());
+ let body = Body::empty().with_trailers(trailers);
+ Ok::<_, Infallible>(Response::builder().body(body).unwrap())
+ });
+ let mut svc = Compression::new(svc).compress_when(Always);
+
+ let req = Request::builder()
+ .header("accept-encoding", "gzip")
+ .body(Body::empty())
+ .unwrap();
+ let res = svc.ready().await.unwrap().call(req).await.unwrap();
+
+ let collected = res.into_body().collect().await.unwrap();
+ let trailers = collected.trailers().cloned().unwrap();
+ assert_eq!(trailers["grpc-status"], "0");
+ assert_eq!(trailers["grpc-message"], "OK");
+ }
+
+ #[tokio::test]
+ async fn trailers_with_streamed_body() {
+ // Simulate a gRPC-like streamed response: multiple data frames followed by trailers
+ let svc = service_fn(|_req: Request| async {
+ let stream = futures_util::stream::iter(vec![
+ Ok::<_, BoxError>(Bytes::from("chunk1")),
+ Ok(Bytes::from("chunk2")),
+ Ok(Bytes::from("chunk3")),
+ ]);
+ let mut trailers = HeaderMap::new();
+ trailers.insert("grpc-status", "0".parse().unwrap());
+ let body = Body::from_stream(stream).with_trailers(trailers);
+ Ok::<_, Infallible>(Response::builder().body(body).unwrap())
+ });
+ let mut svc = Compression::new(svc).compress_when(Always);
+
+ let req = Request::builder()
+ .header("accept-encoding", "gzip")
+ .body(Body::empty())
+ .unwrap();
+ let res = svc.ready().await.unwrap().call(req).await.unwrap();
+
+ let collected = res.into_body().collect().await.unwrap();
+ let trailers = collected.trailers().cloned().unwrap();
+ let compressed_data = collected.to_bytes();
+
+ let mut decoder = GzDecoder::new(&compressed_data[..]);
+ let mut decompressed = String::new();
+ decoder.read_to_string(&mut decompressed).unwrap();
+
+ assert_eq!(decompressed, "chunk1chunk2chunk3");
+ assert_eq!(trailers["grpc-status"], "0");
+ }
+
+ #[tokio::test]
+ async fn trailers_with_grpc_web_content_type() {
+ let svc = service_fn(|_req: Request| async {
+ let mut trailers = HeaderMap::new();
+ trailers.insert("grpc-status", "0".parse().unwrap());
+ let body = Body::from("a".repeat((SizeAbove::DEFAULT_MIN_SIZE * 2) as usize))
+ .with_trailers(trailers);
+ let mut res = Response::new(body);
+ res.headers_mut()
+ .insert(CONTENT_TYPE, "application/grpc-web+proto".parse().unwrap());
+ Ok::<_, Infallible>(res)
+ });
+ let mut svc = Compression::new(svc).compress_when(Always);
+
+ let req = Request::builder()
+ .header("accept-encoding", "gzip")
+ .body(Body::empty())
+ .unwrap();
+ let res = svc.ready().await.unwrap().call(req).await.unwrap();
+
+ let collected = res.into_body().collect().await.unwrap();
+ let trailers = collected.trailers().cloned().unwrap();
+ assert_eq!(trailers["grpc-status"], "0");
+ }
+
#[tokio::test]
async fn size_hint_identity() {
let msg = "Hello, world!";
diff --git a/tower-http/src/compression_utils.rs b/tower-http/src/compression_utils.rs
index 1fbccb85..9c5cdc20 100644
--- a/tower-http/src/compression_utils.rs
+++ b/tower-http/src/compression_utils.rs
@@ -238,19 +238,9 @@ where
// poll any remaining frames, such as trailers
let body = M::get_pin_mut(this.read).get_pin_mut().get_pin_mut();
match ready!(body.poll_frame(cx)) {
- Some(Ok(frame)) if frame.is_trailers() => Poll::Ready(Some(Ok(
+ Some(Ok(frame)) => Poll::Ready(Some(Ok(
frame.map_data(|mut data| data.copy_to_bytes(data.remaining()))
))),
- Some(Ok(frame)) => {
- if let Ok(bytes) = frame.into_data() {
- if bytes.has_remaining() {
- return Poll::Ready(Some(Err(
- "there are extra bytes after body has been decompressed".into(),
- )));
- }
- }
- Poll::Ready(None)
- }
Some(Err(err)) => Poll::Ready(Some(Err(err.into()))),
None => Poll::Ready(None),
}
From aefbc1da456c21623a8b29d0fac10dc3218cc608 Mon Sep 17 00:00:00 2001
From: Jess Izen <44884346+jlizen@users.noreply.github.com>
Date: Fri, 22 May 2026 14:17:27 +0200
Subject: [PATCH 02/11] Fix ServeDir stripping extension with identity encoding
(#686)
* test: reproduce identity encoding extension stripping (issue #664)
* fix: prevent identity encoding from stripping file extensions
---
.../src/services/fs/serve_dir/open_file.rs | 8 +++--
tower-http/src/services/fs/serve_dir/tests.rs | 31 +++++++++++++++++++
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/tower-http/src/services/fs/serve_dir/open_file.rs b/tower-http/src/services/fs/serve_dir/open_file.rs
index ff1d7e46..54d778de 100644
--- a/tower-http/src/services/fs/serve_dir/open_file.rs
+++ b/tower-http/src/services/fs/serve_dir/open_file.rs
@@ -239,7 +239,9 @@ async fn open_file_with_fallback(
let encoding = preferred_encoding(&mut path, &negotiated_encoding);
match (File::open(&path).await, encoding) {
(Ok(file), maybe_encoding) => break (file, maybe_encoding),
- (Err(err), Some(encoding)) if err.kind() == io::ErrorKind::NotFound => {
+ (Err(err), Some(encoding))
+ if err.kind() == io::ErrorKind::NotFound && encoding != Encoding::Identity =>
+ {
// Remove the extension corresponding to a precompressed file (.gz, .br, .zz)
// to reset the path before the next iteration.
path.set_extension(OsStr::new(""));
@@ -265,7 +267,9 @@ async fn file_metadata_with_fallback(
let encoding = preferred_encoding(&mut path, &negotiated_encoding);
match (tokio::fs::metadata(&path).await, encoding) {
(Ok(file), maybe_encoding) => break (file, maybe_encoding),
- (Err(err), Some(encoding)) if err.kind() == io::ErrorKind::NotFound => {
+ (Err(err), Some(encoding))
+ if err.kind() == io::ErrorKind::NotFound && encoding != Encoding::Identity =>
+ {
// Remove the extension corresponding to a precompressed file (.gz, .br, .zz)
// to reset the path before the next iteration.
path.set_extension(OsStr::new(""));
diff --git a/tower-http/src/services/fs/serve_dir/tests.rs b/tower-http/src/services/fs/serve_dir/tests.rs
index 3023bdf8..f785b45d 100644
--- a/tower-http/src/services/fs/serve_dir/tests.rs
+++ b/tower-http/src/services/fs/serve_dir/tests.rs
@@ -1100,3 +1100,34 @@ fn test_build_and_validate_path_reserved_dos_names() {
}
}
}
+
+// Regression test for https://github.com/tower-rs/tower-http/issues/664
+// Accept-Encoding: identity should not cause extension stripping
+#[tokio::test]
+async fn identity_encoding_does_not_strip_extension() {
+ let svc = ServeDir::new("../test-files");
+
+ let req = Request::builder()
+ .uri("/extensionless_precompressed.foobar")
+ .header("Accept-Encoding", "identity")
+ .body(Body::empty())
+ .unwrap();
+ let res = svc.oneshot(req).await.unwrap();
+
+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
+}
+
+#[tokio::test]
+async fn identity_encoding_does_not_strip_extension_head_request() {
+ let svc = ServeDir::new("../test-files");
+
+ let req = Request::builder()
+ .uri("/extensionless_precompressed.foobar")
+ .method(Method::HEAD)
+ .header("Accept-Encoding", "identity")
+ .body(Body::empty())
+ .unwrap();
+ let res = svc.oneshot(req).await.unwrap();
+
+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
+}
From d64ecb72d91c377f746e7cf16dbb13e72dd1e0d8 Mon Sep 17 00:00:00 2001
From: Jess Izen <44884346+jlizen@users.noreply.github.com>
Date: Fri, 22 May 2026 14:17:44 +0200
Subject: [PATCH 03/11] Fix on_eos not triggering when body has precise
content-length (#687)
* test(trace): add tests for on_eos with content-length bodies
* fix(trace): fire on_eos when inner body reports is_end_stream
* fix(on-early-drop): suppress guard when is_end_stream after data frame
---
tower-http/src/on_early_drop/body.rs | 10 ++-
tower-http/src/on_early_drop/service.rs | 35 ++++++++
tower-http/src/trace/body.rs | 20 ++++-
tower-http/src/trace/mod.rs | 103 ++++++++++++++++++++++++
4 files changed, 164 insertions(+), 4 deletions(-)
diff --git a/tower-http/src/on_early_drop/body.rs b/tower-http/src/on_early_drop/body.rs
index 0d76268e..3dd476b3 100644
--- a/tower-http/src/on_early_drop/body.rs
+++ b/tower-http/src/on_early_drop/body.rs
@@ -58,8 +58,8 @@ where
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll