Skip to main content

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}