//! Response compression for the public router. //! //! Topcoat's built-in compression is off for the public router (boot.rs and `test_support`), because //! it has no content-type policy and would also compress git smart-HTTP streams, LFS, archives and //! raw downloads. `.layer(TowerLayer::new(ResponseCompression))` is added instead, and it compresses only text responses (HTML, CSS, JSON, //! SVG, plain text, JavaScript) whose size is known and at most [`MAX_BODY`] bytes. A response is //! left as it is when: //! //! - the request path is a git smart-HTTP, LFS, raw, archive or download route (checked by path, //! because the content type alone cannot tell a git stream from other text), //! - it already has `Content-Encoding` or `Content-Range`, //! - its content type is not on the allow list (binary, archives, `application/x-git-*`, ...), or //! - its body is streamed (unknown size) or smaller than [`MIN_BODY`]. //! //! The path check works through a marker: [`PathMark`] runs next to the routes, sees the request //! path, and tags the response extensions. The compression predicate only sees the response, so it //! reads that tag. use std::pin::Pin; use std::task::{Context, Poll}; use http::header::{CONTENT_ENCODING, CONTENT_RANGE, CONTENT_TYPE}; use http::{Request, Response}; use tower_http::CompressionLevel; use tower_http::compression::predicate::Predicate; /// Bodies below this size are not worth the framing overhead. pub const MIN_BODY: u64 = 32; /// Bodies above this size are streamed uncompressed to bound CPU per response. pub const MAX_BODY: u64 = 4 * 1024 * 1024; /// Response-extension tag set on responses whose request path must never be compressed. #[derive(Clone, Copy, Debug)] struct NoCompression; /// Whether a request path belongs to a route that is never compressed. fn is_excluded_path(path: &str) -> bool { path.contains("/api/v1/ci/") || path.contains("/info/refs") || path.contains("/git-upload-pack") || path.contains("/git-receive-pack") || path.contains("/info/lfs") || path.split('/').any(|segment| { matches!( segment, "lfs" | "raw" | "archive" | "download" | "downloads" ) }) } /// Whether a content type (parameters ignored) is text worth compressing. fn is_compressible(content_type: &str) -> bool { let media = content_type .split(';') .next() .unwrap_or_default() .trim() .to_ascii_lowercase(); matches!( media.as_str(), "text/html" | "text/css" | "text/plain" | "text/javascript" | "application/javascript" | "application/json" | "image/svg+xml" ) } /// Decides, from the response alone, whether its body is compressed. #[derive(Clone, Copy, Debug)] pub struct Policy; impl Predicate for Policy { fn should_compress(&self, response: &Response) -> bool where B: http_body::Body, { if response.extensions().get::().is_some() { return false; } let headers = response.headers(); if headers.contains_key(CONTENT_ENCODING) || headers.contains_key(CONTENT_RANGE) { return false; } let Some(content_type) = headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()) else { return false; }; if !is_compressible(content_type) { return false; } let size = response.body().size_hint(); size.lower() >= MIN_BODY && size.upper().is_some_and(|upper| upper <= MAX_BODY) } } /// Builds the compressing service around the routes. #[derive(Clone, Copy, Debug)] pub struct ResponseCompression; impl tower::Layer for ResponseCompression { type Service = tower_http::compression::Compression, Policy>; fn layer(&self, inner: S) -> Self::Service { tower_http::compression::Compression::new(PathMark(inner)) .gzip(true) .br(true) .quality(CompressionLevel::Default) .compress_when(Policy) } } /// Tags responses to excluded request paths so [`Policy`] skips them. #[derive(Clone, Debug)] pub struct PathMark(S); impl tower::Service> for PathMark where S: tower::Service, Response = Response>, S::Future: Send + 'static, S::Error: Send + 'static, ResB: Send + 'static, { type Response = Response; type Error = S::Error; type Future = Pin> + Send>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { self.0.poll_ready(cx) } fn call(&mut self, request: Request) -> Self::Future { let excluded = is_excluded_path(request.uri().path()); let future = self.0.call(request); Box::pin(async move { let mut response = future.await?; if excluded { response.extensions_mut().insert(NoCompression); } Ok(response) }) } } #[cfg(test)] mod tests { use super::*; use http::Method; use http::header::{ACCEPT_ENCODING, CONTENT_TYPE}; use topcoat::context::Cx; use topcoat::router::tower::TowerLayer; use topcoat::router::{Body, Compression, RouteFn, RouteFuture, Router}; /// Long enough to clear [`MIN_BODY`] by a wide margin. const BIG: &str = "forge compression test body line\n"; fn big_body() -> String { BIG.repeat(64) } macro_rules! typed_route { ($name:ident, $content_type:expr) => { fn $name(_cx: &Cx, _body: Body) -> RouteFuture<'_> { Box::pin(async move { Ok(http::Response::builder() .header(CONTENT_TYPE, $content_type) .body(Body::from(big_body())) .expect("static response")) }) } }; } typed_route!(html_page, "text/html; charset=utf-8"); typed_route!(git_refs, "text/html; charset=utf-8"); typed_route!(git_pack, "application/x-git-upload-pack-result"); typed_route!(raw_text, "text/plain; charset=utf-8"); typed_route!(binary, "application/octet-stream"); fn router() -> Router { Router::builder() .route(RouteFn::new(Method::GET, "/alice/demo/page", html_page)) .route(RouteFn::new( Method::GET, "/alice/demo.git/info/refs", git_refs, )) .route(RouteFn::new( Method::GET, "/alice/demo/git-upload-pack", git_pack, )) .route(RouteFn::new( Method::GET, "/alice/demo/raw/main/a.txt", raw_text, )) .route(RouteFn::new(Method::GET, "/alice/demo/blob.bin", binary)) .layer(TowerLayer::new(ResponseCompression)) .compression(Compression::off()) .build() } async fn encoding(router: &Router, path: &str) -> Option { let request = http::Request::builder() .uri(path) .header(ACCEPT_ENCODING, "gzip") .body(Body::empty()) .expect("request"); let response = router.handle(request).await; response .headers() .get(http::header::CONTENT_ENCODING) .map(|v| v.to_str().expect("ascii").to_owned()) } #[tokio::test] async fn html_page_is_gzipped() { let router = router(); assert_eq!( encoding(&router, "/alice/demo/page").await.as_deref(), Some("gzip") ); } #[tokio::test] async fn git_smart_http_is_never_compressed() { let router = router(); assert_eq!(encoding(&router, "/alice/demo.git/info/refs").await, None); assert_eq!(encoding(&router, "/alice/demo/git-upload-pack").await, None); } #[tokio::test] async fn raw_and_binary_are_not_compressed() { let router = router(); assert_eq!(encoding(&router, "/alice/demo/raw/main/a.txt").await, None); assert_eq!(encoding(&router, "/alice/demo/blob.bin").await, None); } #[tokio::test] async fn public_css_asset_is_gzipped() { let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let router = crate::test_support::app_router(state); assert_eq!( encoding(&router, "/assets/main.css").await.as_deref(), Some("gzip") ); } #[test] fn policy_skips_small_streamed_and_already_encoded_bodies() { let small = Response::builder() .header(CONTENT_TYPE, "text/html") .body(Body::from("tiny")) .expect("response"); assert!(!Policy.should_compress(&small)); let encoded = Response::builder() .header(CONTENT_TYPE, "text/html") .header(CONTENT_ENCODING, "br") .body(Body::from(big_body())) .expect("response"); assert!(!Policy.should_compress(&encoded)); let plain = Response::builder() .header(CONTENT_TYPE, "text/html") .body(Body::from(big_body())) .expect("response"); assert!(Policy.should_compress(&plain)); } #[test] fn path_exclusions_cover_git_lfs_raw_and_archives() { assert!(is_excluded_path("/alice/demo.git/info/refs")); assert!(is_excluded_path("/alice/demo.git/git-receive-pack")); assert!(is_excluded_path("/alice/demo.git/info/lfs/objects/batch")); assert!(is_excluded_path("/alice/demo/raw/main/a.txt")); assert!(is_excluded_path("/alice/demo/archive/main.zip")); assert!(is_excluded_path( "/alice/demo/releases/download/v1/x.tar.gz" )); assert!(!is_excluded_path("/alice/demo/tree/main")); } }