From 8a6a19b4eaeac603644417448252439001307101 Mon Sep 17 00:00:00 2001 From: Jess Izen <44884346+jlizen@users.noreply.github.com> Date: Mon, 25 May 2026 07:31:09 -0700 Subject: [PATCH 1/3] feat(body): add UnsyncBoxBody::new constructor and From conversion (#682) --- tower-http/CHANGELOG.md | 4 +++ tower-http/src/body.rs | 23 +++++++++++++- tower-http/src/catch_panic.rs | 10 +++---- .../src/decompression/request/future.rs | 4 +-- .../src/services/fs/serve_dir/future.rs | 10 +++---- tower-http/src/services/fs/serve_dir/mod.rs | 8 ++++- tower-http/src/services/fs/serve_dir/tests.rs | 30 +++++++++++++++++++ 7 files changed, 74 insertions(+), 15 deletions(-) diff --git a/tower-http/CHANGELOG.md b/tower-http/CHANGELOG.md index 54c8ad59..c1ac0e7d 100644 --- a/tower-http/CHANGELOG.md +++ b/tower-http/CHANGELOG.md @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#628]: https://github.com/tower-rs/tower-http/pull/628 [#642]: https://github.com/tower-rs/tower-http/pull/642 +## Added + +- `body`: `UnsyncBoxBody::new()` constructor and `From` conversion to avoid double-boxing when combining `ServeDir` responses with other body types ([#537]) + # 0.6.11 ## Added diff --git a/tower-http/src/body.rs b/tower-http/src/body.rs index 815a0d10..45923b69 100644 --- a/tower-http/src/body.rs +++ b/tower-http/src/body.rs @@ -105,11 +105,32 @@ where impl UnsyncBoxBody { #[allow(dead_code)] - pub(crate) fn new(inner: http_body_util::combinators::UnsyncBoxBody) -> Self { + pub(crate) fn from_inner(inner: http_body_util::combinators::UnsyncBoxBody) -> Self { Self { inner } } } +impl UnsyncBoxBody +where + D: Buf + 'static, +{ + /// Create a new `UnsyncBoxBody` by erasing the type of the given body. + /// + /// This is useful when you need a common body type across different response branches + /// without double-boxing bodies that are already boxed (like [`ServeDir`]'s response body, + /// which has a [`From`] impl for zero-cost conversion). + /// + /// [`ServeDir`]: crate::services::ServeDir + pub fn new(body: B) -> Self + where + B: Body + Send + 'static, + { + Self { + inner: http_body_util::combinators::UnsyncBoxBody::new(body), + } + } +} + impl Body for UnsyncBoxBody where D: Buf, diff --git a/tower-http/src/catch_panic.rs b/tower-http/src/catch_panic.rs index 3f1c2279..333c81f1 100644 --- a/tower-http/src/catch_panic.rs +++ b/tower-http/src/catch_panic.rs @@ -267,11 +267,9 @@ where future, panic_handler, } => match ready!(future.poll(cx)) { - Ok(Ok(res)) => { - Poll::Ready(Ok(res.map(|body| { - UnsyncBoxBody::new(body.map_err(Into::into).boxed_unsync()) - }))) - } + Ok(Ok(res)) => Poll::Ready(Ok(res.map(|body| { + UnsyncBoxBody::from_inner(body.map_err(Into::into).boxed_unsync()) + }))), Ok(Err(svc_err)) => Poll::Ready(Err(svc_err)), Err(panic_err) => Poll::Ready(Ok(response_for_panic( panic_handler @@ -295,7 +293,7 @@ where { panic_handler .response_for_panic(err) - .map(|body| UnsyncBoxBody::new(body.map_err(Into::into).boxed_unsync())) + .map(|body| UnsyncBoxBody::from_inner(body.map_err(Into::into).boxed_unsync())) } /// Trait for creating responses from panics. diff --git a/tower-http/src/decompression/request/future.rs b/tower-http/src/decompression/request/future.rs index bdb22f8b..1d1a8925 100644 --- a/tower-http/src/decompression/request/future.rs +++ b/tower-http/src/decompression/request/future.rs @@ -76,7 +76,7 @@ where fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { match self.project().kind.project() { StateProj::Inner { fut } => fut.poll(cx).map_ok(|res| { - res.map(|body| UnsyncBoxBody::new(body.map_err(Into::into).boxed_unsync())) + res.map(|body| UnsyncBoxBody::from_inner(body.map_err(Into::into).boxed_unsync())) }), StateProj::Unsupported { accept } => { let res = Response::builder() @@ -87,7 +87,7 @@ where .unwrap_or(HeaderValue::from_static("identity")), ) .status(StatusCode::UNSUPPORTED_MEDIA_TYPE) - .body(UnsyncBoxBody::new( + .body(UnsyncBoxBody::from_inner( Empty::new().map_err(Into::into).boxed_unsync(), )) .unwrap(); diff --git a/tower-http/src/services/fs/serve_dir/future.rs b/tower-http/src/services/fs/serve_dir/future.rs index 7df32b0d..4abb0b9a 100644 --- a/tower-http/src/services/fs/serve_dir/future.rs +++ b/tower-http/src/services/fs/serve_dir/future.rs @@ -208,7 +208,7 @@ where .map_ok(|response| { response .map(|body| { - UnsyncBoxBody::new( + UnsyncBoxBody::from_inner( body.map_err(|err| match err.into().downcast::() { Ok(err) => *err, Err(err) => io::Error::new(io::ErrorKind::Other, err), @@ -258,7 +258,7 @@ fn build_response(output: FileOpened) -> Response { } else { let body = if let Some(file) = maybe_file { let range_size = range.end() - range.start() + 1; - ResponseBody::new(UnsyncBoxBody::new( + ResponseBody::new(UnsyncBoxBody::from_inner( AsyncReadBody::with_capacity_limited( file, output.chunk_size, @@ -306,7 +306,7 @@ fn build_response(output: FileOpened) -> Response { // Not a range request None => { let body = if let Some(file) = maybe_file { - ResponseBody::new(UnsyncBoxBody::new( + ResponseBody::new(UnsyncBoxBody::from_inner( AsyncReadBody::with_capacity(file, output.chunk_size).boxed_unsync(), )) } else { @@ -323,10 +323,10 @@ fn build_response(output: FileOpened) -> Response { fn body_from_bytes(bytes: Bytes) -> ResponseBody { let body = Full::from(bytes).map_err(|err| match err {}).boxed_unsync(); - ResponseBody::new(UnsyncBoxBody::new(body)) + ResponseBody::new(UnsyncBoxBody::from_inner(body)) } fn empty_body() -> ResponseBody { let body = Empty::new().map_err(|err| match err {}).boxed_unsync(); - ResponseBody::new(UnsyncBoxBody::new(body)) + ResponseBody::new(UnsyncBoxBody::from_inner(body)) } diff --git a/tower-http/src/services/fs/serve_dir/mod.rs b/tower-http/src/services/fs/serve_dir/mod.rs index 1fc82c51..4c060321 100644 --- a/tower-http/src/services/fs/serve_dir/mod.rs +++ b/tower-http/src/services/fs/serve_dir/mod.rs @@ -415,7 +415,7 @@ where #[cfg(feature = "tracing")] tracing::error!(error = %_err, "Failed to read file"); - let body = ResponseBody::new(UnsyncBoxBody::new( + let body = ResponseBody::new(UnsyncBoxBody::from_inner( Empty::new().map_err(|err| match err {}).boxed_unsync(), )); Response::builder() @@ -613,6 +613,12 @@ opaque_body! { pub type ResponseBody = UnsyncBoxBody; } +impl From for UnsyncBoxBody { + fn from(body: ResponseBody) -> Self { + body.inner + } +} + /// The default fallback service used with [`ServeDir`]. #[derive(Debug, Clone, Copy)] pub struct DefaultServeDirFallback(Infallible); diff --git a/tower-http/src/services/fs/serve_dir/tests.rs b/tower-http/src/services/fs/serve_dir/tests.rs index 72c567a6..55f0eb93 100644 --- a/tower-http/src/services/fs/serve_dir/tests.rs +++ b/tower-http/src/services/fs/serve_dir/tests.rs @@ -1146,3 +1146,33 @@ async fn identity_encoding_does_not_strip_extension_head_request() { assert_eq!(res.status(), StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn unsync_box_body_new() { + use crate::body::UnsyncBoxBody; + use http_body_util::Full; + + let body: UnsyncBoxBody = + UnsyncBoxBody::new(Full::new(Bytes::from("hello"))); + let collected = body.collect().await.unwrap().to_bytes(); + assert_eq!(collected, "hello"); +} + +#[tokio::test] +async fn response_body_into_unsync_box_body() { + use crate::body::UnsyncBoxBody; + + let svc = ServeDir::new(".."); + let req = Request::builder() + .uri("/README.md") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + // Convert the ServeDir response body into UnsyncBoxBody without double-boxing + let boxed: UnsyncBoxBody = res.into_body().into(); + let collected = boxed.collect().await.unwrap().to_bytes(); + + let expected = std::fs::read_to_string("../README.md").unwrap(); + assert_eq!(collected, expected); +} From 763517851ae53b6af7e08b3818dc3634e736544e Mon Sep 17 00:00:00 2001 From: Jess Izen <44884346+jlizen@users.noreply.github.com> Date: Mon, 25 May 2026 11:11:17 -0700 Subject: [PATCH 2/3] Fix: Add Vary: Accept-Encoding header for precompressed file serving (#692) * test: add tests for missing Vary header in precompressed serving * fix: emit Vary: Accept-Encoding when serving precompressed files --- tower-http/CHANGELOG.md | 6 ++ .../src/services/fs/serve_dir/future.rs | 6 ++ tower-http/src/services/fs/serve_dir/mod.rs | 2 + .../src/services/fs/serve_dir/open_file.rs | 4 + tower-http/src/services/fs/serve_dir/tests.rs | 75 +++++++++++++++++++ 5 files changed, 93 insertions(+) diff --git a/tower-http/CHANGELOG.md b/tower-http/CHANGELOG.md index c1ac0e7d..d59ab922 100644 --- a/tower-http/CHANGELOG.md +++ b/tower-http/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased +## Fixed + +- `fs`: `ServeDir` and `ServeFile` now emit a `Vary: Accept-Encoding` response + header when precompressed serving is configured, ensuring caches correctly + distinguish between compressed and uncompressed variants. + ## Changed - `trace`: `DefaultOnRequest`, `DefaultOnResponse`, `DefaultOnFailure`, and diff --git a/tower-http/src/services/fs/serve_dir/future.rs b/tower-http/src/services/fs/serve_dir/future.rs index 4abb0b9a..6386ead4 100644 --- a/tower-http/src/services/fs/serve_dir/future.rs +++ b/tower-http/src/services/fs/serve_dir/future.rs @@ -240,6 +240,12 @@ fn build_response(output: FileOpened) -> Response { builder = builder.header(header::CONTENT_ENCODING, encoding.into_header_value()); } + // Per RFC 9110 ยง12.5.3, Vary must be sent when the response could differ + // based on Accept-Encoding, even if this particular response is uncompressed. + if output.precompression_configured { + builder = builder.header(header::VARY, "accept-encoding"); + } + if let Some(last_modified) = output.last_modified { builder = builder.header(header::LAST_MODIFIED, last_modified.0.to_string()); } diff --git a/tower-http/src/services/fs/serve_dir/mod.rs b/tower-http/src/services/fs/serve_dir/mod.rs index 4c060321..4cd8c846 100644 --- a/tower-http/src/services/fs/serve_dir/mod.rs +++ b/tower-http/src/services/fs/serve_dir/mod.rs @@ -366,6 +366,7 @@ impl ServeDir { .and_then(|value| value.to_str().ok()) .map(|s| s.to_owned()); + let precompression_configured = self.precompressed_variants.is_some(); let negotiated_encodings: Vec<_> = encodings( req.headers(), self.precompressed_variants.unwrap_or_default(), @@ -381,6 +382,7 @@ impl ServeDir { negotiated_encodings, range_header, buf_chunk_size, + precompression_configured, )); ResponseFuture::open_file_future(open_file_future, fallback_and_request) 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 54d778de..8f2f2de6 100644 --- a/tower-http/src/services/fs/serve_dir/open_file.rs +++ b/tower-http/src/services/fs/serve_dir/open_file.rs @@ -33,6 +33,7 @@ pub(super) struct FileOpened { pub(super) maybe_encoding: Option, pub(super) maybe_range: Option>, RangeUnsatisfiableError>>, pub(super) last_modified: Option, + pub(super) precompression_configured: bool, } pub(super) enum FileRequestExtent { @@ -47,6 +48,7 @@ pub(super) async fn open_file( negotiated_encodings: Vec<(Encoding, QValue)>, range_header: Option, buf_chunk_size: usize, + precompression_configured: bool, ) -> io::Result { let if_unmodified_since = req .headers() @@ -108,6 +110,7 @@ pub(super) async fn open_file( maybe_encoding, maybe_range, last_modified, + precompression_configured, }))) } else { let (mut file, maybe_encoding) = @@ -147,6 +150,7 @@ pub(super) async fn open_file( maybe_encoding, maybe_range, last_modified, + precompression_configured, }))) } } diff --git a/tower-http/src/services/fs/serve_dir/tests.rs b/tower-http/src/services/fs/serve_dir/tests.rs index 55f0eb93..0704f8b7 100644 --- a/tower-http/src/services/fs/serve_dir/tests.rs +++ b/tower-http/src/services/fs/serve_dir/tests.rs @@ -1147,6 +1147,81 @@ async fn identity_encoding_does_not_strip_extension_head_request() { assert_eq!(res.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn precompressed_response_includes_vary_header() { + let svc = ServeDir::new(TEST_FILES_DIR).precompressed_gzip(); + + let req = Request::builder() + .uri("/precompressed.txt") + .header("Accept-Encoding", "gzip") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.headers()["content-encoding"], "gzip"); + assert_eq!(res.headers()["vary"], "accept-encoding"); +} + +#[tokio::test] +async fn no_vary_header_without_precompressed_serving() { + let svc = ServeDir::new(REPO_ROOT); + + let req = Request::builder() + .uri("/README.md") + .header("Accept-Encoding", "gzip") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert!(res.headers().get("vary").is_none()); +} + +#[tokio::test] +async fn vary_header_present_when_precompressed_configured_but_fallback_to_uncompressed() { + let svc = ServeDir::new(TEST_FILES_DIR).precompressed_gzip(); + + let req = Request::builder() + .uri("/precompressed.txt") + .header("Accept-Encoding", "br") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert!(res.headers().get("content-encoding").is_none()); + assert_eq!(res.headers()["vary"], "accept-encoding"); +} + +#[tokio::test] +async fn vary_header_present_when_precompressed_configured_but_no_accept_encoding() { + let svc = ServeDir::new(TEST_FILES_DIR).precompressed_gzip(); + + let req = Request::builder() + .uri("/precompressed.txt") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert!(res.headers().get("content-encoding").is_none()); + assert_eq!(res.headers()["vary"], "accept-encoding"); +} + +#[tokio::test] +async fn precompressed_head_request_includes_vary_header() { + let svc = ServeDir::new(TEST_FILES_DIR).precompressed_gzip(); + + let req = Request::builder() + .uri("/precompressed.txt") + .method(Method::HEAD) + .header("Accept-Encoding", "gzip") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.headers()["content-encoding"], "gzip"); + assert_eq!(res.headers()["vary"], "accept-encoding"); +} + #[tokio::test] async fn unsync_box_body_new() { use crate::body::UnsyncBoxBody; From b5366318c0661693e15de6d75e15a2f7924b5036 Mon Sep 17 00:00:00 2001 From: Tim Kurdov Date: Thu, 28 May 2026 23:23:16 +0000 Subject: [PATCH 3/3] Add `html_as_default_extension` option to `ServeDir` (#519) * Add `html_as_default_extension` option to `ServeDir` This mirrors the common behaviour of file servers * test: add tests for html_as_default_extension and fix unreachable logic --------- Co-authored-by: Jess Izen <44884346+jlizen@users.noreply.github.com> --- test-files/page.html | 1 + tower-http/src/services/fs/serve_dir/mod.rs | 20 +++++++++ .../src/services/fs/serve_dir/open_file.rs | 18 ++++++-- tower-http/src/services/fs/serve_dir/tests.rs | 43 +++++++++++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 test-files/page.html diff --git a/test-files/page.html b/test-files/page.html new file mode 100644 index 00000000..65c593b0 --- /dev/null +++ b/test-files/page.html @@ -0,0 +1 @@ +page diff --git a/tower-http/src/services/fs/serve_dir/mod.rs b/tower-http/src/services/fs/serve_dir/mod.rs index 4cd8c846..b243202c 100644 --- a/tower-http/src/services/fs/serve_dir/mod.rs +++ b/tower-http/src/services/fs/serve_dir/mod.rs @@ -76,6 +76,7 @@ impl ServeDir { precompressed_variants: None, variant: ServeVariant::Directory { append_index_html_on_directories: true, + html_as_default_extension: false, }, fallback: None, call_fallback_on_method_not_allowed: false, @@ -107,6 +108,7 @@ impl ServeDir { match &mut self.variant { ServeVariant::Directory { append_index_html_on_directories, + .. } => { *append_index_html_on_directories = append; self @@ -115,6 +117,22 @@ impl ServeDir { } } + /// If the requested path doesn't specify a file extension, append `.html`. + /// + /// Defaults to `false`. + pub fn html_as_default_extension(mut self, append: bool) -> Self { + match &mut self.variant { + ServeVariant::Directory { + html_as_default_extension, + .. + } => { + *html_as_default_extension = append; + self + } + ServeVariant::SingleFile { mime: _ } => self, + } + } + /// Set a specific read buffer chunk size. /// /// The default capacity is 64kb. @@ -447,6 +465,7 @@ opaque_future! { enum ServeVariant { Directory { append_index_html_on_directories: bool, + html_as_default_extension: bool, }, SingleFile { mime: HeaderValue, @@ -458,6 +477,7 @@ impl ServeVariant { match self { ServeVariant::Directory { append_index_html_on_directories: _, + html_as_default_extension: _, } => { let path = requested_path.trim_start_matches('/'); 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 8f2f2de6..d7e76084 100644 --- a/tower-http/src/services/fs/serve_dir/open_file.rs +++ b/tower-http/src/services/fs/serve_dir/open_file.rs @@ -63,6 +63,7 @@ pub(super) async fn open_file( let mime = match variant { ServeVariant::Directory { append_index_html_on_directories, + html_as_default_extension, } => { // Might already at this point know a redirect or not found result should be // returned which corresponds to a Some(output). Otherwise the path might be @@ -71,6 +72,7 @@ pub(super) async fn open_file( &mut path_to_file, req.uri(), append_index_html_on_directories, + html_as_default_extension, ) .await { @@ -291,16 +293,23 @@ async fn maybe_redirect_or_append_path( path_to_file: &mut PathBuf, uri: &Uri, append_index_html_on_directories: bool, + html_as_default_extension: bool, ) -> Option { let uri_path = uri.path(); let is_directory = is_dir(path_to_file).await; - if uri_path.ends_with('/') && uri_path != "/" && !is_directory { + if uri_path.ends_with('/') && uri_path != "/" && is_directory != Some(true) { return Some(OpenFileOutput::FileNotFound); } - if !is_directory { + // If the path has no extension and doesn't exist as a file, try appending .html + if html_as_default_extension && is_directory.is_none() && path_to_file.extension().is_none() { + path_to_file.set_extension("html"); + return None; + } + + if is_directory != Some(true) { return None; } @@ -331,10 +340,11 @@ fn try_parse_range( }) } -async fn is_dir(path_to_file: &Path) -> bool { +async fn is_dir(path_to_file: &Path) -> Option { tokio::fs::metadata(path_to_file) .await - .map_or(false, |meta_data| meta_data.is_dir()) + .ok() + .map(|meta_data| meta_data.is_dir()) } fn append_slash_on_path(uri: Uri) -> Result { diff --git a/tower-http/src/services/fs/serve_dir/tests.rs b/tower-http/src/services/fs/serve_dir/tests.rs index 0704f8b7..3dd9a086 100644 --- a/tower-http/src/services/fs/serve_dir/tests.rs +++ b/tower-http/src/services/fs/serve_dir/tests.rs @@ -1101,6 +1101,7 @@ fn test_build_and_validate_path_reserved_dos_names() { let variant = ServeVariant::Directory { append_index_html_on_directories: true, + html_as_default_extension: false, }; let base = Path::new("/base"); @@ -1251,3 +1252,45 @@ async fn response_body_into_unsync_box_body() { let expected = std::fs::read_to_string("../README.md").unwrap(); assert_eq!(collected, expected); } + +#[tokio::test] +async fn html_as_default_extension() { + let svc = ServeDir::new(TEST_FILES_DIR).html_as_default_extension(true); + + let req = Request::builder().uri("/page").body(Body::empty()).unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers()["content-type"], "text/html"); + + let body = body_into_text(res.into_body()).await; + assert_eq!(body, "page\n"); +} + +#[tokio::test] +async fn html_as_default_extension_not_found() { + let svc = ServeDir::new(TEST_FILES_DIR).html_as_default_extension(true); + + let req = Request::builder() + .uri("/nonexistent") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn html_as_default_extension_does_not_apply_when_extension_present() { + let svc = ServeDir::new(TEST_FILES_DIR).html_as_default_extension(true); + + // Request a file that exists with its extension; should serve normally + let req = Request::builder() + .uri("/precompressed.txt") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers()["content-type"], "text/plain"); +}