forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
9.9 KiB
1//! Response compression for the public router.
2//!
3//! Topcoat's built-in compression is off for the public router (boot.rs and `test_support`), because
4//! it has no content-type policy and would also compress git smart-HTTP streams, LFS, archives and
5//! raw downloads. `.layer(TowerLayer::new(ResponseCompression))` is added instead, and it compresses only text responses (HTML, CSS, JSON,
6//! SVG, plain text, JavaScript) whose size is known and at most [`MAX_BODY`] bytes. A response is
7//! left as it is when:
8//!
9//! - the request path is a git smart-HTTP, LFS, raw, archive or download route (checked by path,
10//! because the content type alone cannot tell a git stream from other text),
11//! - it already has `Content-Encoding` or `Content-Range`,
12//! - its content type is not on the allow list (binary, archives, `application/x-git-*`, ...), or
13//! - its body is streamed (unknown size) or smaller than [`MIN_BODY`].
14//!
15//! The path check works through a marker: [`PathMark`] runs next to the routes, sees the request
16//! path, and tags the response extensions. The compression predicate only sees the response, so it
17//! reads that tag.
18
19use std::pin::Pin;
20use std::task::{Context, Poll};
21
22use http::header::{CONTENT_ENCODING, CONTENT_RANGE, CONTENT_TYPE};
23use http::{Request, Response};
24use tower_http::CompressionLevel;
25use tower_http::compression::predicate::Predicate;
26
27/// Bodies below this size are not worth the framing overhead.
28pub const MIN_BODY: u64 = 32;
29
30/// Bodies above this size are streamed uncompressed to bound CPU per response.
31pub const MAX_BODY: u64 = 4 * 1024 * 1024;
32
33/// Response-extension tag set on responses whose request path must never be compressed.
34#[derive(Clone, Copy, Debug)]
35struct NoCompression;
36
37/// Whether a request path belongs to a route that is never compressed.
38fn is_excluded_path(path: &str) -> bool {
39 path.contains("/api/v1/ci/")
40 || path.contains("/info/refs")
41 || path.contains("/git-upload-pack")
42 || path.contains("/git-receive-pack")
43 || path.contains("/info/lfs")
44 || path.split('/').any(|segment| {
45 matches!(
46 segment,
47 "lfs" | "raw" | "archive" | "download" | "downloads"
48 )
49 })
50}
51
52/// Whether a content type (parameters ignored) is text worth compressing.
53fn is_compressible(content_type: &str) -> bool {
54 let media = content_type
55 .split(';')
56 .next()
57 .unwrap_or_default()
58 .trim()
59 .to_ascii_lowercase();
60 matches!(
61 media.as_str(),
62 "text/html"
63 | "text/css"
64 | "text/plain"
65 | "text/javascript"
66 | "application/javascript"
67 | "application/json"
68 | "image/svg+xml"
69 )
70}
71
72/// Decides, from the response alone, whether its body is compressed.
73#[derive(Clone, Copy, Debug)]
74pub struct Policy;
75
76impl Predicate for Policy {
77 fn should_compress<B>(&self, response: &Response<B>) -> bool
78 where
79 B: http_body::Body,
80 {
81 if response.extensions().get::<NoCompression>().is_some() {
82 return false;
83 }
84 let headers = response.headers();
85 if headers.contains_key(CONTENT_ENCODING) || headers.contains_key(CONTENT_RANGE) {
86 return false;
87 }
88 let Some(content_type) = headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()) else {
89 return false;
90 };
91 if !is_compressible(content_type) {
92 return false;
93 }
94 let size = response.body().size_hint();
95 size.lower() >= MIN_BODY && size.upper().is_some_and(|upper| upper <= MAX_BODY)
96 }
97}
98
99/// Builds the compressing service around the routes.
100#[derive(Clone, Copy, Debug)]
101pub struct ResponseCompression;
102
103impl<S> tower::Layer<S> for ResponseCompression {
104 type Service = tower_http::compression::Compression<PathMark<S>, Policy>;
105
106 fn layer(&self, inner: S) -> Self::Service {
107 tower_http::compression::Compression::new(PathMark(inner))
108 .gzip(true)
109 .br(true)
110 .quality(CompressionLevel::Default)
111 .compress_when(Policy)
112 }
113}
114
115/// Tags responses to excluded request paths so [`Policy`] skips them.
116#[derive(Clone, Debug)]
117pub struct PathMark<S>(S);
118
119impl<S, B, ResB> tower::Service<Request<B>> for PathMark<S>
120where
121 S: tower::Service<Request<B>, Response = Response<ResB>>,
122 S::Future: Send + 'static,
123 S::Error: Send + 'static,
124 ResB: Send + 'static,
125{
126 type Response = Response<ResB>;
127 type Error = S::Error;
128 type Future =
129 Pin<Box<dyn std::future::Future<Output = Result<Self::Response, S::Error>> + Send>>;
130
131 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
132 self.0.poll_ready(cx)
133 }
134
135 fn call(&mut self, request: Request<B>) -> Self::Future {
136 let excluded = is_excluded_path(request.uri().path());
137 let future = self.0.call(request);
138 Box::pin(async move {
139 let mut response = future.await?;
140 if excluded {
141 response.extensions_mut().insert(NoCompression);
142 }
143 Ok(response)
144 })
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use http::Method;
152 use http::header::{ACCEPT_ENCODING, CONTENT_TYPE};
153 use topcoat::context::Cx;
154 use topcoat::router::tower::TowerLayer;
155 use topcoat::router::{Body, Compression, RouteFn, RouteFuture, Router};
156
157 /// Long enough to clear [`MIN_BODY`] by a wide margin.
158 const BIG: &str = "forge compression test body line\n";
159
160 fn big_body() -> String {
161 BIG.repeat(64)
162 }
163
164 macro_rules! typed_route {
165 ($name:ident, $content_type:expr) => {
166 fn $name(_cx: &Cx, _body: Body) -> RouteFuture<'_> {
167 Box::pin(async move {
168 Ok(http::Response::builder()
169 .header(CONTENT_TYPE, $content_type)
170 .body(Body::from(big_body()))
171 .expect("static response"))
172 })
173 }
174 };
175 }
176
177 typed_route!(html_page, "text/html; charset=utf-8");
178 typed_route!(git_refs, "text/html; charset=utf-8");
179 typed_route!(git_pack, "application/x-git-upload-pack-result");
180 typed_route!(raw_text, "text/plain; charset=utf-8");
181 typed_route!(binary, "application/octet-stream");
182
183 fn router() -> Router {
184 Router::builder()
185 .route(RouteFn::new(Method::GET, "/alice/demo/page", html_page))
186 .route(RouteFn::new(
187 Method::GET,
188 "/alice/demo.git/info/refs",
189 git_refs,
190 ))
191 .route(RouteFn::new(
192 Method::GET,
193 "/alice/demo/git-upload-pack",
194 git_pack,
195 ))
196 .route(RouteFn::new(
197 Method::GET,
198 "/alice/demo/raw/main/a.txt",
199 raw_text,
200 ))
201 .route(RouteFn::new(Method::GET, "/alice/demo/blob.bin", binary))
202 .layer(TowerLayer::new(ResponseCompression))
203 .compression(Compression::off())
204 .build()
205 }
206
207 async fn encoding(router: &Router, path: &str) -> Option<String> {
208 let request = http::Request::builder()
209 .uri(path)
210 .header(ACCEPT_ENCODING, "gzip")
211 .body(Body::empty())
212 .expect("request");
213 let response = router.handle(request).await;
214 response
215 .headers()
216 .get(http::header::CONTENT_ENCODING)
217 .map(|v| v.to_str().expect("ascii").to_owned())
218 }
219
220 #[tokio::test]
221 async fn html_page_is_gzipped() {
222 let router = router();
223 assert_eq!(
224 encoding(&router, "/alice/demo/page").await.as_deref(),
225 Some("gzip")
226 );
227 }
228
229 #[tokio::test]
230 async fn git_smart_http_is_never_compressed() {
231 let router = router();
232 assert_eq!(encoding(&router, "/alice/demo.git/info/refs").await, None);
233 assert_eq!(encoding(&router, "/alice/demo/git-upload-pack").await, None);
234 }
235
236 #[tokio::test]
237 async fn raw_and_binary_are_not_compressed() {
238 let router = router();
239 assert_eq!(encoding(&router, "/alice/demo/raw/main/a.txt").await, None);
240 assert_eq!(encoding(&router, "/alice/demo/blob.bin").await, None);
241 }
242
243 #[tokio::test]
244 async fn public_css_asset_is_gzipped() {
245 let dir = tempfile::tempdir().expect("tempdir");
246 let state = crate::test_support::test_state(dir.path()).await;
247 let router = crate::test_support::app_router(state);
248 assert_eq!(
249 encoding(&router, "/assets/main.css").await.as_deref(),
250 Some("gzip")
251 );
252 }
253
254 #[test]
255 fn policy_skips_small_streamed_and_already_encoded_bodies() {
256 let small = Response::builder()
257 .header(CONTENT_TYPE, "text/html")
258 .body(Body::from("tiny"))
259 .expect("response");
260 assert!(!Policy.should_compress(&small));
261
262 let encoded = Response::builder()
263 .header(CONTENT_TYPE, "text/html")
264 .header(CONTENT_ENCODING, "br")
265 .body(Body::from(big_body()))
266 .expect("response");
267 assert!(!Policy.should_compress(&encoded));
268
269 let plain = Response::builder()
270 .header(CONTENT_TYPE, "text/html")
271 .body(Body::from(big_body()))
272 .expect("response");
273 assert!(Policy.should_compress(&plain));
274 }
275
276 #[test]
277 fn path_exclusions_cover_git_lfs_raw_and_archives() {
278 assert!(is_excluded_path("/alice/demo.git/info/refs"));
279 assert!(is_excluded_path("/alice/demo.git/git-receive-pack"));
280 assert!(is_excluded_path("/alice/demo.git/info/lfs/objects/batch"));
281 assert!(is_excluded_path("/alice/demo/raw/main/a.txt"));
282 assert!(is_excluded_path("/alice/demo/archive/main.zip"));
283 assert!(is_excluded_path(
284 "/alice/demo/releases/download/v1/x.tar.gz"
285 ));
286 assert!(!is_excluded_path("/alice/demo/tree/main"));
287 }
288}