churust_compression/lib.rs
1//! Response compression for the [Churust] web framework.
2//!
3//! [`Compression`] negotiates a content coding from the request's
4//! `Accept-Encoding`, compresses the response body, and sets
5//! `Content-Encoding`. Brotli, gzip and deflate are supported. A streamed body
6//! stays streamed: it is compressed chunk by chunk rather than collected first.
7//!
8//! ```
9//! use churust_core::{Call, Churust, TestClient};
10//! use churust_compression::Compression;
11//!
12//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
13//! let app = Churust::server()
14//! .install(Compression::new())
15//! .routing(|r| {
16//! r.get("/", |_c: Call| async { "hello ".repeat(500) });
17//! })
18//! .build();
19//!
20//! let res = TestClient::new(app)
21//! .get("/")
22//! .header("accept-encoding", "gzip")
23//! .send()
24//! .await;
25//!
26//! assert_eq!(res.header("content-encoding"), Some("gzip"));
27//! assert_eq!(res.header("vary"), Some("accept-encoding"));
28//! # });
29//! ```
30//!
31//! # What is deliberately not compressed
32//!
33//! Compression that saves nothing still costs CPU on both ends, and in a few
34//! cases it is wrong rather than merely wasteful. The plugin skips:
35//!
36//! - Bodies below [`min_size`](Compression::min_size) (1 KiB by default).
37//! Below roughly that size a gzip member's own header eats the saving.
38//! - Content types that are already compressed (images, video, audio, archives)
39//! or that are not known to be text, per
40//! [`compressible_by_default`]. Override with
41//! [`compressible`](Compression::compressible).
42//! - Responses that already carry a `Content-Encoding`.
43//! - `206 Partial Content` and anything carrying `Content-Range`. The range was
44//! computed against the identity representation, so compressing the selected
45//! span would make the offsets describe bytes that are not there.
46//! - Statuses that carry no body at all (`1xx`, `204`, `304`).
47//!
48//! `Vary: Accept-Encoding` is added to **every** response the plugin sees, not
49//! only compressed ones. A cache that stored one variant without it would serve
50//! a brotli body to a client that never asked for one.
51//!
52//! A `HEAD` reply is a case of its own. Its body is already gone by the time the
53//! plugin runs, so there is nothing to encode, but the headers are still
54//! rewritten exactly as they would be for the matching `GET`:
55//! `Content-Encoding` set, the identity `Content-Length` and any
56//! `Accept-Ranges` removed, a strong `ETag` weakened. RFC 9110 §9.3.2 asks a
57//! `HEAD` to answer with the fields its `GET` would send, and a client or cache
58//! that sizes a resource with `HEAD` before fetching it must not be told about
59//! a representation the server will not deliver.
60//!
61//! # Should this live in the application at all
62//!
63//! Often it should not. A reverse proxy in front of the service can compress
64//! once for every backend behind it, and it is usually already terminating TLS
65//! and doing the buffering. Reach for this plugin when there is no such proxy,
66//! when the proxy is not under your control, or when a handler produces a
67//! stream long enough that you want it compressed before it crosses the
68//! network hop the proxy sits on.
69//!
70//! [Churust]: churust_core::Churust
71
72#![deny(missing_docs)]
73
74use async_trait::async_trait;
75use bytes::Bytes;
76use churust_core::{AppBuilder, Body, Call, Middleware, Next, Phase, Plugin, Response};
77use futures_util::StreamExt;
78use http::header::{
79 HeaderValue, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE,
80 ETAG, VARY,
81};
82use http::{Method, StatusCode};
83use std::sync::Arc;
84use tokio::io::AsyncRead;
85use tokio_util::io::{ReaderStream, StreamReader};
86
87/// Pieces a buffered body is cut into before being fed to the encoder.
88///
89/// Compressing a large buffer in one call would occupy the runtime worker for
90/// the whole of it, and the default level is brotli quality 11 — seconds of CPU
91/// for a body of a few megabytes, during which nothing else scheduled on that
92/// worker runs, timers and the accept loop included. Yielding does not make the
93/// encode any cheaper; it makes it interruptible, so the cost lands on one task
94/// rather than on everything sharing the worker with it.
95///
96/// The slicing alone did not achieve that, which is what this comment used to
97/// claim. The pieces were handed to `futures_util::stream::iter`, whose
98/// `poll_next` is unconditionally `Ready`, and nothing below it returns
99/// `Pending` either: async-compression's bufread encoders are pure state
100/// machines, and tokio's cooperative budget only fires for tokio's own
101/// resources, none of which are in this path. Awaiting a future that is always
102/// ready never reaches the scheduler, so the whole encode ran inside a single
103/// poll and the await point described here did not exist.
104///
105/// It now does, at both ends of the encoder, because neither end covers the
106/// other's case. See [`encode`].
107const CHUNK: usize = 16 * 1024;
108
109/// A supported content coding.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum Encoding {
112 /// Brotli (`br`). The best ratio of the three, and universally supported by
113 /// current browsers over both plaintext and TLS.
114 Brotli,
115 /// gzip (`gzip`). The safe default: understood by everything.
116 Gzip,
117 /// deflate (`deflate`), which per RFC 9110 §8.4.1.2 means the zlib format
118 /// of RFC 1950 rather than a raw deflate stream.
119 Deflate,
120}
121
122impl Encoding {
123 /// The token as it appears in `Accept-Encoding` and `Content-Encoding`.
124 pub fn token(self) -> &'static str {
125 match self {
126 Encoding::Brotli => "br",
127 Encoding::Gzip => "gzip",
128 Encoding::Deflate => "deflate",
129 }
130 }
131
132 fn from_token(token: &str) -> Option<Self> {
133 match token {
134 "br" => Some(Encoding::Brotli),
135 "gzip" | "x-gzip" => Some(Encoding::Gzip),
136 "deflate" => Some(Encoding::Deflate),
137 _ => None,
138 }
139 }
140}
141
142/// How hard the encoder works.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum Level {
145 /// Least CPU, largest output.
146 Fastest,
147 /// The encoder's own default, which is what most deployments want.
148 #[default]
149 Default,
150 /// Most CPU, smallest output. Worth it for a cached or static payload,
151 /// rarely worth it per request.
152 Best,
153}
154
155impl From<Level> for async_compression::Level {
156 fn from(level: Level) -> Self {
157 match level {
158 Level::Fastest => async_compression::Level::Fastest,
159 Level::Default => async_compression::Level::Default,
160 Level::Best => async_compression::Level::Best,
161 }
162 }
163}
164
165/// Whether a media type is worth compressing.
166///
167/// True for text, JSON, XML, JavaScript, SVG and WebAssembly, including the
168/// structured suffixes (`application/vnd.api+json`). False for everything else,
169/// which is the conservative direction: a missed saving costs bandwidth once,
170/// while recompressing a JPEG costs CPU on every request and makes it larger.
171///
172/// ```
173/// use churust_compression::compressible_by_default;
174///
175/// assert!(compressible_by_default("text/html; charset=utf-8"));
176/// assert!(compressible_by_default("application/vnd.api+json"));
177/// assert!(!compressible_by_default("image/png"));
178/// ```
179pub fn compressible_by_default(content_type: &str) -> bool {
180 let essence = content_type
181 .split(';')
182 .next()
183 .unwrap_or("")
184 .trim()
185 .to_ascii_lowercase();
186
187 if essence.starts_with("text/") {
188 return true;
189 }
190 if essence.ends_with("+json") || essence.ends_with("+xml") || essence.ends_with("+text") {
191 return true;
192 }
193 matches!(
194 essence.as_str(),
195 "application/json"
196 | "application/javascript"
197 | "application/xml"
198 | "application/xhtml+xml"
199 | "application/rss+xml"
200 | "application/atom+xml"
201 | "application/wasm"
202 | "application/manifest+json"
203 | "application/x-ndjson"
204 | "image/svg+xml"
205 )
206}
207
208/// A predicate deciding whether a media type should be compressed.
209type CompressibleFn = Arc<dyn Fn(&str) -> bool + Send + Sync>;
210
211/// The response compression plugin.
212///
213/// Construct with [`Compression::new`] and refine with the builder methods.
214/// Also usable as scoped middleware through `RouteBuilder::intercept`.
215#[derive(Clone)]
216pub struct Compression {
217 min_size: usize,
218 level: Level,
219 /// Server preference, most preferred first. Only these are ever chosen.
220 preference: Vec<Encoding>,
221 compressible: CompressibleFn,
222}
223
224impl std::fmt::Debug for Compression {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 f.debug_struct("Compression")
227 .field("min_size", &self.min_size)
228 .field("level", &self.level)
229 .field("preference", &self.preference)
230 .finish_non_exhaustive()
231 }
232}
233
234impl Default for Compression {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240impl Compression {
241 /// Brotli, then gzip, then deflate, for text-like bodies of at least 1 KiB.
242 pub fn new() -> Self {
243 Self {
244 min_size: 1024,
245 level: Level::Default,
246 preference: vec![Encoding::Brotli, Encoding::Gzip, Encoding::Deflate],
247 compressible: Arc::new(compressible_by_default),
248 }
249 }
250
251 /// Only compress buffered bodies of at least `bytes`.
252 ///
253 /// A streamed body has no length until it ends, so it is always compressed
254 /// regardless of this setting.
255 pub fn min_size(mut self, bytes: usize) -> Self {
256 self.min_size = bytes;
257 self
258 }
259
260 /// Set how hard the encoder works.
261 pub fn level(mut self, level: Level) -> Self {
262 self.level = level;
263 self
264 }
265
266 /// Restrict and order the codings this server is willing to produce, most
267 /// preferred first.
268 ///
269 /// Client preference wins on `q` value; this order only breaks ties. Use it
270 /// to drop brotli on a CPU-bound service:
271 ///
272 /// ```
273 /// use churust_compression::{Compression, Encoding};
274 ///
275 /// let plugin = Compression::new().encodings([Encoding::Gzip]);
276 /// ```
277 ///
278 /// # Panics
279 ///
280 /// If the list is empty, which would install a plugin that can never do
281 /// anything.
282 pub fn encodings(mut self, encodings: impl IntoIterator<Item = Encoding>) -> Self {
283 let list: Vec<Encoding> = encodings.into_iter().collect();
284 assert!(
285 !list.is_empty(),
286 "Compression needs at least one encoding to offer"
287 );
288 self.preference = list;
289 self
290 }
291
292 /// Replace the media-type predicate.
293 ///
294 /// ```
295 /// use churust_compression::{compressible_by_default, Compression};
296 ///
297 /// // Also compress a bespoke binary format that happens to be repetitive.
298 /// let plugin = Compression::new().compressible(|ct| {
299 /// compressible_by_default(ct) || ct.starts_with("application/x-telemetry")
300 /// });
301 /// ```
302 pub fn compressible<F>(mut self, f: F) -> Self
303 where
304 F: Fn(&str) -> bool + Send + Sync + 'static,
305 {
306 self.compressible = Arc::new(f);
307 self
308 }
309
310 /// Pick the coding to use, or `None` to send the body as it is.
311 ///
312 /// Client `q` values decide first; the server's own order breaks ties. An
313 /// explicitly listed coding always beats a match through `*`, so
314 /// `gzip;q=0.5, *` sends gzip at 0.5 rather than promoting brotli to 1.0
315 /// through the wildcard.
316 fn negotiate(&self, header: &str) -> Option<Encoding> {
317 // (quality, wildcard) per coding this server offers.
318 let mut offers: Vec<(Encoding, f32, bool)> = Vec::new();
319 let mut wildcard: Option<f32> = None;
320 let mut explicit: Vec<(Encoding, f32)> = Vec::new();
321
322 for part in header.split(',') {
323 let mut bits = part.split(';');
324 let token = bits.next().unwrap_or("").trim().to_ascii_lowercase();
325 let mut q = 1.0f32;
326 for param in bits {
327 // RFC 9110 §5.6.6 makes a parameter name case-insensitive, so
328 // `gzip;Q=0` is exactly the refusal `gzip;q=0` is. Testing the
329 // literal prefix `q=` missed the uppercase spelling: the
330 // parameter fell through, `q` kept its 1.0 initialiser, and the
331 // coding the client had just refused was picked and sent — to a
332 // client that wrote `Q=0` precisely because it cannot decode it.
333 // Lowercasing the whole parameter is safe because the value is a
334 // qvalue, which has no letters in it.
335 let param = param.trim().to_ascii_lowercase();
336 if let Some(value) = param.strip_prefix("q=") {
337 q = value.trim().parse().unwrap_or(0.0);
338 }
339 }
340 if token == "*" {
341 wildcard = Some(wildcard.map_or(q, |prev: f32| prev.max(q)));
342 } else if let Some(enc) = Encoding::from_token(&token) {
343 explicit.push((enc, q));
344 }
345 }
346
347 for (index, enc) in self.preference.iter().enumerate() {
348 let quality = match explicit.iter().find(|(e, _)| e == enc) {
349 Some((_, q)) => Some((*q, false)),
350 None => wildcard.map(|q| (q, true)),
351 };
352 if let Some((q, is_wildcard)) = quality {
353 if q > 0.0 {
354 // Fold the server's order into the sort key so a tie on q
355 // resolves the same way every time.
356 let _ = index;
357 offers.push((*enc, q, is_wildcard));
358 }
359 }
360 }
361
362 offers
363 .into_iter()
364 .enumerate()
365 .max_by(|(ia, a), (ib, b)| {
366 // Higher q wins; then explicit over wildcard; then the server's
367 // own preference order, which `enumerate` preserves.
368 a.1.partial_cmp(&b.1)
369 .unwrap_or(std::cmp::Ordering::Equal)
370 .then_with(|| (!a.2).cmp(&(!b.2)))
371 .then_with(|| ib.cmp(ia))
372 })
373 .map(|(_, (enc, _, _))| enc)
374 }
375
376 /// Whether this response may be compressed at all.
377 ///
378 /// `is_head` says the request was a `HEAD`, in which case the body has
379 /// already been dropped and the size test has to be read off the headers
380 /// instead — see the call site in [`Middleware::handle`].
381 fn should_compress(&self, res: &Response, is_head: bool) -> bool {
382 // A body-less status has nothing to encode, and a `304` must keep the
383 // headers of the response it revalidates.
384 if res.status.is_informational()
385 || res.status == StatusCode::NO_CONTENT
386 || res.status == StatusCode::NOT_MODIFIED
387 {
388 return false;
389 }
390 // The range was selected from the identity representation.
391 if res.status == StatusCode::PARTIAL_CONTENT || res.headers.contains_key(CONTENT_RANGE) {
392 return false;
393 }
394 // Already encoded by the handler or an inner layer. Stacking a second
395 // coding is legal and always a mistake.
396 if res.headers.contains_key(CONTENT_ENCODING) {
397 return false;
398 }
399 let content_type = res
400 .headers
401 .get(CONTENT_TYPE)
402 .and_then(|v| v.to_str().ok())
403 .unwrap_or("");
404 if !(self.compressible)(content_type) {
405 return false;
406 }
407 match res.body.as_bytes() {
408 // A `HEAD` reply arrives here already emptied, so its own body
409 // says nothing about the size of the representation. The engine
410 // wrote that size into `Content-Length` before discarding the
411 // bytes, and that is the number the floor has to be compared
412 // against: the decision must come out the same as it does for the
413 // `GET` this `HEAD` is answering, or the two disagree about
414 // whether the resource is compressed at all. With no
415 // `Content-Length` there is nothing to compare — a streamed `GET`
416 // that never announced a length — and the response is left as it
417 // is rather than guessed at.
418 Some(bytes) if is_head && bytes.is_empty() => {
419 head_identity_len(res).is_some_and(|len| len >= self.min_size)
420 }
421 // A buffered body's length is known, so the floor applies.
422 Some(bytes) => bytes.len() >= self.min_size,
423 // A stream's is not, so it is compressed on the assumption that a
424 // handler streaming a response has more than a kilobyte to say.
425 None => true,
426 }
427 }
428}
429
430/// The identity length a `HEAD` reply is quoting, if it quotes one.
431///
432/// The endpoint records the `GET` body's length in `Content-Length` before it
433/// drops the bytes, so on a synthesized `HEAD` that header is the only
434/// surviving statement of how large the representation is.
435fn head_identity_len(res: &Response) -> Option<usize> {
436 res.headers
437 .get(CONTENT_LENGTH)?
438 .to_str()
439 .ok()?
440 .trim()
441 .parse()
442 .ok()
443}
444
445/// Wrap `body` in the encoder for `encoding`, keeping it a stream throughout.
446///
447/// There is a [`yield_now`](tokio::task::yield_now) on each side of the
448/// encoder, and both are needed, because the encoder absorbs one of them
449/// exactly when the other is unnecessary. async-compression's bufread encoder
450/// ends its poll with
451///
452/// ```text
453/// if is_pending {
454/// if output.written().is_empty() { return Poll::Pending } else { return Poll::Ready(Ok(())) }
455/// }
456/// ```
457///
458/// — the right thing for an `AsyncRead`, and it means a `Pending` raised while
459/// pulling the *next* input piece is swallowed whenever the encoder already has
460/// bytes to hand back. So:
461///
462/// - The yield between input pieces is what reaches the scheduler while the
463/// encoder is eating input without emitting anything, which is the case that
464/// actually pins a worker: brotli at quality 11 buffers a large window, so a
465/// compressible body can be consumed for a long time before a byte comes out.
466/// - The yield between output chunks is what reaches the scheduler in the
467/// ordinary case, where the encoder emits on every input piece and therefore
468/// swallows every input-side yield. Nothing above it can absorb it: the
469/// consumer is either the collect loop in [`Middleware::handle`] or hyper's
470/// body writer, and a `Pending` there goes straight to the runtime.
471///
472/// Neither yield fires before the first piece, so a small body — the common
473/// case, and the one where the encode is over in microseconds — costs no
474/// scheduler round trip at all.
475fn encode(body: Body, encoding: Encoding, level: Level) -> Body {
476 let source = match body {
477 // `unfold` rather than `iter`: `iter` is unconditionally ready, so the
478 // state machine has to be one that can await. See [`CHUNK`].
479 Body::Bytes(bytes) => {
480 futures_util::stream::unfold((bytes, false), |(mut rest, started)| async move {
481 if rest.is_empty() {
482 return None;
483 }
484 if started {
485 tokio::task::yield_now().await;
486 }
487 let take = CHUNK.min(rest.len());
488 let chunk = rest.split_to(take);
489 Some((Ok::<Bytes, std::io::Error>(chunk), (rest, true)))
490 })
491 .boxed()
492 }
493 // A streamed body already has real await points in it — the handler
494 // producing it — so it needs nothing added on the input side.
495 Body::Stream(stream) => stream
496 .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())))
497 .boxed(),
498 };
499
500 let reader = StreamReader::new(source);
501 let level = level.into();
502 let encoded: std::pin::Pin<Box<dyn AsyncRead + Send>> = match encoding {
503 Encoding::Brotli => {
504 Box::pin(async_compression::tokio::bufread::BrotliEncoder::with_quality(reader, level))
505 }
506 Encoding::Gzip => {
507 Box::pin(async_compression::tokio::bufread::GzipEncoder::with_quality(reader, level))
508 }
509 // Zlib, not raw deflate: see `Encoding::Deflate`.
510 Encoding::Deflate => {
511 Box::pin(async_compression::tokio::bufread::ZlibEncoder::with_quality(reader, level))
512 }
513 };
514
515 Body::from_stream(futures_util::stream::unfold(
516 (ReaderStream::new(encoded), false),
517 |(mut out, started)| async move {
518 if started {
519 tokio::task::yield_now().await;
520 }
521 let chunk = out.next().await?;
522 Some((chunk, (out, true)))
523 },
524 ))
525}
526
527/// Append `Accept-Encoding` to `Vary` without disturbing what is already there.
528fn vary_on_accept_encoding(res: &mut Response) {
529 let existing: Vec<String> = res
530 .headers
531 .get_all(VARY)
532 .iter()
533 .filter_map(|v| v.to_str().ok())
534 .flat_map(|v| v.split(','))
535 .map(|v| v.trim().to_ascii_lowercase())
536 .filter(|v| !v.is_empty())
537 .collect();
538
539 if existing.iter().any(|v| v == "*" || v == "accept-encoding") {
540 return;
541 }
542
543 let mut merged = existing;
544 merged.push("accept-encoding".to_string());
545 if let Ok(value) = HeaderValue::from_str(&merged.join(", ")) {
546 res.headers.insert(VARY, value);
547 }
548}
549
550/// Downgrade a strong `ETag` to a weak one.
551///
552/// A compressed body is a different sequence of bytes for the same resource,
553/// so a strong validator would claim byte-for-byte equality that no longer
554/// holds. RFC 9110 §8.8.1 makes the weak form exactly this statement:
555/// equivalent, not identical. A tag that is already weak is left alone.
556fn weaken_etag(res: &mut Response) {
557 let Some(tag) = res.headers.get(ETAG).and_then(|v| v.to_str().ok()) else {
558 return;
559 };
560 if tag.starts_with("W/") {
561 return;
562 }
563 if let Ok(value) = HeaderValue::from_str(&format!("W/{tag}")) {
564 res.headers.insert(ETAG, value);
565 }
566}
567
568#[async_trait]
569impl Middleware for Compression {
570 async fn handle(&self, call: Call, next: Next) -> Response {
571 let accept = call
572 .header(ACCEPT_ENCODING.as_str())
573 .unwrap_or_default()
574 .to_string();
575 // A `HEAD` with no handler of its own is answered by running the `GET`
576 // route and then discarding the body, and that discarding happens at
577 // the endpoint — inside this middleware, not outside it. So a `HEAD`
578 // comes back here already emptied. The method has to be remembered now,
579 // because the rest of the chain consumes `call`.
580 let is_head = call.method() == Method::HEAD;
581
582 let mut res = next.run(call).await;
583
584 // Every response the plugin sees is Vary-marked, compressed or not, so
585 // a shared cache keys on the request's Accept-Encoding either way.
586 vary_on_accept_encoding(&mut res);
587
588 if accept.is_empty() {
589 return res;
590 }
591 let Some(encoding) = self.negotiate(&accept) else {
592 return res;
593 };
594 if !self.should_compress(&res, is_head) {
595 return res;
596 }
597
598 // A `HEAD` reply has no bytes left to encode, but its headers must
599 // still describe the representation the matching `GET` would return.
600 // Skipping it entirely left the two contradicting each other for the
601 // same `Accept-Encoding`: `HEAD /file` answered the identity
602 // `Content-Length`, `Accept-Ranges: bytes` and a strong `ETag`, while
603 // `GET /file` answered `Content-Encoding: gzip` with the ranges
604 // withdrawn and the tag weakened. RFC 9110 §9.3.2 asks a `HEAD` for the
605 // header fields its `GET` would send, and RFC 9111 §4.3.5 has a cache
606 // use a `HEAD` to update the stored `GET` and invalidate it when the
607 // lengths disagree — so every `HEAD` evicted the compressed `GET` a
608 // shared cache was holding. So the metadata below is applied either
609 // way, and only the body work is skipped.
610 //
611 // Dropping `Content-Length` rather than correcting it is the whole
612 // answer here: the encoded length cannot be known without doing the
613 // work the client declined to ask for, and §9.3.2 permits omitting a
614 // field that is only determined while generating the content. hyper
615 // writes no implicit `content-length: 0` for a `HEAD`, so the field
616 // comes out absent rather than replaced by a different wrong number.
617 // This is what nginx's gzip filter does with a header-only request too.
618 if !is_head {
619 // Keep the buffered original: compressing it cannot normally fail,
620 // and if it somehow does, sending it uncompressed beats a 500.
621 let original = res.body.as_bytes().cloned();
622 let was_buffered = original.is_some();
623 let body = std::mem::take(&mut res.body);
624 let encoded = encode(body, encoding, self.level);
625
626 res.body = if was_buffered {
627 // Collect back so `Content-Length` stays exact for a body that
628 // had one before.
629 match encoded.into_bytes().await {
630 Ok(bytes) => Body::Bytes(bytes),
631 Err(_) => {
632 res.body = Body::Bytes(original.unwrap_or_default());
633 return res;
634 }
635 }
636 } else {
637 encoded
638 };
639 }
640
641 res.headers
642 .insert(CONTENT_ENCODING, HeaderValue::from_static(encoding.token()));
643 // Whatever length was set describes the identity body. The engine
644 // recomputes it for a buffered body and omits it for a stream.
645 res.headers.remove(CONTENT_LENGTH);
646 // Ranges described the identity body too, and they cannot be honoured
647 // for this one. `StaticFiles` sets `Accept-Ranges: bytes`; leaving it
648 // told the client it could resume, and its `Range` request then came
649 // back `206` with *identity* bytes — `should_compress` correctly skips
650 // `206` — against a total it never received. The client splices
651 // plaintext into a gzip stream and the resumed download is corrupt.
652 // nginx's gzip filter clears this header for the same reason.
653 res.headers.remove(http::header::ACCEPT_RANGES);
654 weaken_etag(&mut res);
655 res
656 }
657}
658
659impl Plugin for Compression {
660 /// Installed in [`Phase::Plugins`]. Anything that inspects a response body
661 /// must sit inside this layer, since outside it the body is compressed.
662 fn install(self: Box<Self>, app: &mut AppBuilder) {
663 app.add_middleware_in(Phase::Plugins, Arc::new(*self));
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 fn plugin() -> Compression {
672 Compression::new()
673 }
674
675 #[test]
676 fn negotiation_prefers_the_client_quality() {
677 let c = plugin();
678 assert_eq!(c.negotiate("gzip;q=1.0, br;q=0.1"), Some(Encoding::Gzip));
679 assert_eq!(c.negotiate("gzip;q=0.1, br;q=1.0"), Some(Encoding::Brotli));
680 }
681
682 #[test]
683 fn server_order_breaks_a_quality_tie() {
684 let c = plugin();
685 assert_eq!(c.negotiate("gzip, br, deflate"), Some(Encoding::Brotli));
686 let gzip_first = plugin().encodings([Encoding::Gzip, Encoding::Brotli]);
687 assert_eq!(gzip_first.negotiate("gzip, br"), Some(Encoding::Gzip));
688 }
689
690 #[test]
691 fn a_zero_quality_refuses_that_coding() {
692 let c = plugin();
693 assert_eq!(c.negotiate("br;q=0, gzip"), Some(Encoding::Gzip));
694 assert_eq!(c.negotiate("br;q=0, gzip;q=0, deflate;q=0"), None);
695 }
696
697 #[test]
698 fn a_quality_parameter_is_matched_case_insensitively() {
699 // RFC 9110 §5.6.6: a parameter name is case-insensitive, so `Q=0` is
700 // the same refusal as `q=0` and must lose the same way.
701 let c = plugin();
702 assert_eq!(c.negotiate("gzip;Q=0, deflate"), Some(Encoding::Deflate));
703 assert_eq!(c.negotiate("br;Q=0, gzip;Q=0, deflate;Q=0"), None);
704 assert_eq!(c.negotiate("gzip;Q=1.0, br;Q=0.1"), Some(Encoding::Gzip));
705 }
706
707 #[test]
708 fn an_explicit_coding_beats_the_wildcard() {
709 let c = plugin();
710 // `*` would give brotli 1.0; the explicit gzip entry is what the client
711 // actually named, so it wins despite the lower number.
712 assert_eq!(c.negotiate("gzip;q=0.5, *;q=0.4"), Some(Encoding::Gzip));
713 }
714
715 #[test]
716 fn the_wildcard_alone_selects_the_server_preference() {
717 assert_eq!(plugin().negotiate("*"), Some(Encoding::Brotli));
718 }
719
720 #[test]
721 fn unknown_codings_are_ignored() {
722 assert_eq!(plugin().negotiate("exi, sdch"), None);
723 }
724
725 #[test]
726 fn media_types_are_classified_conservatively() {
727 assert!(compressible_by_default("text/html"));
728 assert!(compressible_by_default("application/json"));
729 assert!(compressible_by_default("image/svg+xml"));
730 assert!(!compressible_by_default("image/jpeg"));
731 assert!(!compressible_by_default("application/zip"));
732 assert!(!compressible_by_default(""));
733 }
734
735 #[tokio::test]
736 async fn encoding_round_trips_through_gzip() {
737 use tokio::io::AsyncReadExt;
738
739 let input = Bytes::from("hello ".repeat(4000));
740 let body = encode(Body::Bytes(input.clone()), Encoding::Gzip, Level::Default);
741 let compressed = body.into_bytes().await.unwrap();
742 assert!(
743 compressed.len() < input.len(),
744 "repetitive text should shrink"
745 );
746
747 let mut decoded = Vec::new();
748 async_compression::tokio::bufread::GzipDecoder::new(std::io::Cursor::new(
749 compressed.to_vec(),
750 ))
751 .read_to_end(&mut decoded)
752 .await
753 .unwrap();
754 assert_eq!(Bytes::from(decoded), input);
755 }
756
757 #[tokio::test]
758 async fn a_buffered_body_yields_the_worker_between_chunks() {
759 use std::sync::atomic::{AtomicBool, Ordering};
760
761 // `#[tokio::test]` runs a current-thread runtime, where a spawned task
762 // gets a turn only once the task doing the encoding returns `Pending`.
763 // So the flag is a direct reading of whether the buffered path reaches
764 // the scheduler at all: with the encode running to completion inside
765 // one poll it is still false when the collect returns.
766 let ran = Arc::new(AtomicBool::new(false));
767 let flag = Arc::clone(&ran);
768 tokio::spawn(async move { flag.store(true, Ordering::SeqCst) });
769
770 let input = Bytes::from(vec![b'x'; CHUNK * 4]);
771 let encoded = encode(Body::Bytes(input), Encoding::Gzip, Level::Fastest)
772 .into_bytes()
773 .await
774 .unwrap();
775
776 assert!(
777 !encoded.is_empty(),
778 "the encode still has to produce output"
779 );
780 assert!(
781 ran.load(Ordering::SeqCst),
782 "a multi-chunk encode must let the runtime run something else"
783 );
784 }
785
786 #[test]
787 fn vary_is_appended_not_replaced() {
788 let mut res = Response::text("x");
789 res.headers.insert(VARY, HeaderValue::from_static("origin"));
790 vary_on_accept_encoding(&mut res);
791 assert_eq!(res.headers.get(VARY).unwrap(), "origin, accept-encoding");
792 }
793
794 #[test]
795 fn vary_is_not_duplicated() {
796 let mut res = Response::text("x");
797 vary_on_accept_encoding(&mut res);
798 vary_on_accept_encoding(&mut res);
799 assert_eq!(res.headers.get_all(VARY).iter().count(), 1);
800 assert_eq!(res.headers.get(VARY).unwrap(), "accept-encoding");
801 }
802
803 #[test]
804 fn a_strong_etag_becomes_weak() {
805 let mut res = Response::text("x");
806 res.headers
807 .insert(ETAG, HeaderValue::from_static("\"abc\""));
808 weaken_etag(&mut res);
809 assert_eq!(res.headers.get(ETAG).unwrap(), "W/\"abc\"");
810 }
811
812 #[test]
813 fn an_already_weak_etag_is_left_alone() {
814 let mut res = Response::text("x");
815 res.headers
816 .insert(ETAG, HeaderValue::from_static("W/\"abc\""));
817 weaken_etag(&mut res);
818 assert_eq!(res.headers.get(ETAG).unwrap(), "W/\"abc\"");
819 }
820
821 #[test]
822 fn partial_content_is_never_compressed() {
823 let mut res = Response::text("x".repeat(4096));
824 res.status = StatusCode::PARTIAL_CONTENT;
825 assert!(!plugin().should_compress(&res, false));
826 }
827
828 #[test]
829 fn an_already_encoded_response_is_left_alone() {
830 let mut res = Response::text("x".repeat(4096));
831 res.headers
832 .insert(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
833 assert!(!plugin().should_compress(&res, false));
834 }
835
836 #[test]
837 fn small_bodies_are_left_alone() {
838 let res = Response::text("small");
839 assert!(!plugin().should_compress(&res, false));
840 assert!(
841 plugin().min_size(1).should_compress(&res, false),
842 "lowering the floor should admit it"
843 );
844 }
845}