| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | use std::pin::Pin; |
| 20 | use std::task::{Context, Poll}; |
| 21 | |
| 22 | use http::header::{CONTENT_ENCODING, CONTENT_RANGE, CONTENT_TYPE}; |
| 23 | use http::{Request, Response}; |
| 24 | use tower_http::CompressionLevel; |
| 25 | use tower_http::compression::predicate::Predicate; |
| 26 | |
| 27 | |
| 28 | pub const MIN_BODY: u64 = 32; |
| 29 | |
| 30 | |
| 31 | pub const MAX_BODY: u64 = 4 * 1024 * 1024; |
| 32 | |
| 33 | |
| 34 | #[derive(Clone, Copy, Debug)] |
| 35 | struct NoCompression; |
| 36 | |
| 37 | |
| 38 | fn 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 | |
| 53 | fn 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 | |
| 73 | #[derive(Clone, Copy, Debug)] |
| 74 | pub struct Policy; |
| 75 | |
| 76 | impl 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 | |
| 100 | #[derive(Clone, Copy, Debug)] |
| 101 | pub struct ResponseCompression; |
| 102 | |
| 103 | impl<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 | |
| 116 | #[derive(Clone, Debug)] |
| 117 | pub struct PathMark<S>(S); |
| 118 | |
| 119 | impl<S, B, ResB> tower::Service<Request<B>> for PathMark<S> |
| 120 | where |
| 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)] |
| 149 | mod 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 | |
| 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 | } |