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/CHANGELOG.md b/tower-http/CHANGELOG.md
index 54c8ad59..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
@@ -24,6 +30,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..6386ead4 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),
@@ -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());
}
@@ -258,7 +264,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 +312,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 +329,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..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.
@@ -366,6 +384,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 +400,7 @@ impl ServeDir {
negotiated_encodings,
range_header,
buf_chunk_size,
+ precompression_configured,
));
ResponseFuture::open_file_future(open_file_future, fallback_and_request)
@@ -415,7 +435,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()
@@ -445,6 +465,7 @@ opaque_future! {
enum ServeVariant {
Directory {
append_index_html_on_directories: bool,
+ html_as_default_extension: bool,
},
SingleFile {
mime: HeaderValue,
@@ -456,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('/');
@@ -613,6 +635,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/open_file.rs b/tower-http/src/services/fs/serve_dir/open_file.rs
index 54d778de..d7e76084 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()
@@ -61,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
@@ -69,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
{
@@ -108,6 +112,7 @@ pub(super) async fn open_file(
maybe_encoding,
maybe_range,
last_modified,
+ precompression_configured,
})))
} else {
let (mut file, maybe_encoding) =
@@ -147,6 +152,7 @@ pub(super) async fn open_file(
maybe_encoding,
maybe_range,
last_modified,
+ precompression_configured,
})))
}
}
@@ -287,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;
}
@@ -327,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 72c567a6..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");
@@ -1146,3 +1147,150 @@ 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;
+ 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);
+}
+
+#[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");
+}