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,
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.
528///
529/// The merge is `Response::vary_on`, shared with the CORS plugin: a response can
530/// pass through both, and it only reads as one consistent list if they agree on
531/// how to merge.
532fn vary_on_accept_encoding(res: &mut Response) {
533 res.vary_on("accept-encoding");
534}
535
536/// Downgrade a strong `ETag` to a weak one.
537///
538/// A compressed body is a different sequence of bytes for the same resource,
539/// so a strong validator would claim byte-for-byte equality that no longer
540/// holds. RFC 9110 §8.8.1 makes the weak form exactly this statement:
541/// equivalent, not identical. A tag that is already weak is left alone.
542fn weaken_etag(res: &mut Response) {
543 let Some(tag) = res.headers.get(ETAG).and_then(|v| v.to_str().ok()) else {
544 return;
545 };
546 if tag.starts_with("W/") {
547 return;
548 }
549 if let Ok(value) = HeaderValue::from_str(&format!("W/{tag}")) {
550 res.headers.insert(ETAG, value);
551 }
552}
553
554#[async_trait]
555impl Middleware for Compression {
556 async fn handle(&self, call: Call, next: Next) -> Response {
557 let accept = call
558 .header(ACCEPT_ENCODING.as_str())
559 .unwrap_or_default()
560 .to_string();
561 // A `HEAD` with no handler of its own is answered by running the `GET`
562 // route and then discarding the body, and that discarding happens at
563 // the endpoint — inside this middleware, not outside it. So a `HEAD`
564 // comes back here already emptied. The method has to be remembered now,
565 // because the rest of the chain consumes `call`.
566 let is_head = call.method() == Method::HEAD;
567
568 let mut res = next.run(call).await;
569
570 // Every response the plugin sees is Vary-marked, compressed or not, so
571 // a shared cache keys on the request's Accept-Encoding either way.
572 vary_on_accept_encoding(&mut res);
573
574 if accept.is_empty() {
575 return res;
576 }
577 let Some(encoding) = self.negotiate(&accept) else {
578 return res;
579 };
580 if !self.should_compress(&res, is_head) {
581 return res;
582 }
583
584 // A `HEAD` reply has no bytes left to encode, but its headers must
585 // still describe the representation the matching `GET` would return.
586 // Skipping it entirely left the two contradicting each other for the
587 // same `Accept-Encoding`: `HEAD /file` answered the identity
588 // `Content-Length`, `Accept-Ranges: bytes` and a strong `ETag`, while
589 // `GET /file` answered `Content-Encoding: gzip` with the ranges
590 // withdrawn and the tag weakened. RFC 9110 §9.3.2 asks a `HEAD` for the
591 // header fields its `GET` would send, and RFC 9111 §4.3.5 has a cache
592 // use a `HEAD` to update the stored `GET` and invalidate it when the
593 // lengths disagree — so every `HEAD` evicted the compressed `GET` a
594 // shared cache was holding. So the metadata below is applied either
595 // way, and only the body work is skipped.
596 //
597 // Dropping `Content-Length` rather than correcting it is the whole
598 // answer here: the encoded length cannot be known without doing the
599 // work the client declined to ask for, and §9.3.2 permits omitting a
600 // field that is only determined while generating the content. hyper
601 // writes no implicit `content-length: 0` for a `HEAD`, so the field
602 // comes out absent rather than replaced by a different wrong number.
603 // This is what nginx's gzip filter does with a header-only request too.
604 if !is_head {
605 // Keep the buffered original: compressing it cannot normally fail,
606 // and if it somehow does, sending it uncompressed beats a 500.
607 let original = res.body.as_bytes().cloned();
608 let was_buffered = original.is_some();
609 let body = std::mem::take(&mut res.body);
610 let encoded = encode(body, encoding, self.level);
611
612 res.body = if was_buffered {
613 // Collect back so `Content-Length` stays exact for a body that
614 // had one before.
615 match encoded.into_bytes().await {
616 Ok(bytes) => Body::Bytes(bytes),
617 Err(_) => {
618 res.body = Body::Bytes(original.unwrap_or_default());
619 return res;
620 }
621 }
622 } else {
623 encoded
624 };
625 }
626
627 res.headers
628 .insert(CONTENT_ENCODING, HeaderValue::from_static(encoding.token()));
629 // Whatever length was set describes the identity body. The engine
630 // recomputes it for a buffered body and omits it for a stream.
631 res.headers.remove(CONTENT_LENGTH);
632 // Ranges described the identity body too, and they cannot be honoured
633 // for this one. `StaticFiles` sets `Accept-Ranges: bytes`; leaving it
634 // told the client it could resume, and its `Range` request then came
635 // back `206` with *identity* bytes — `should_compress` correctly skips
636 // `206` — against a total it never received. The client splices
637 // plaintext into a gzip stream and the resumed download is corrupt.
638 // nginx's gzip filter clears this header for the same reason.
639 res.headers.remove(http::header::ACCEPT_RANGES);
640 weaken_etag(&mut res);
641 res
642 }
643}
644
645impl Plugin for Compression {
646 /// Installed in [`Phase::Plugins`]. Anything that inspects a response body
647 /// must sit inside this layer, since outside it the body is compressed.
648 fn install(self: Box<Self>, app: &mut AppBuilder) {
649 app.add_middleware_in(Phase::Plugins, Arc::new(*self));
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 // Only the tests name it now that merging lives in churust-core.
657 use http::header::VARY;
658
659 fn plugin() -> Compression {
660 Compression::new()
661 }
662
663 #[test]
664 fn negotiation_prefers_the_client_quality() {
665 let c = plugin();
666 assert_eq!(c.negotiate("gzip;q=1.0, br;q=0.1"), Some(Encoding::Gzip));
667 assert_eq!(c.negotiate("gzip;q=0.1, br;q=1.0"), Some(Encoding::Brotli));
668 }
669
670 #[test]
671 fn server_order_breaks_a_quality_tie() {
672 let c = plugin();
673 assert_eq!(c.negotiate("gzip, br, deflate"), Some(Encoding::Brotli));
674 let gzip_first = plugin().encodings([Encoding::Gzip, Encoding::Brotli]);
675 assert_eq!(gzip_first.negotiate("gzip, br"), Some(Encoding::Gzip));
676 }
677
678 #[test]
679 fn a_zero_quality_refuses_that_coding() {
680 let c = plugin();
681 assert_eq!(c.negotiate("br;q=0, gzip"), Some(Encoding::Gzip));
682 assert_eq!(c.negotiate("br;q=0, gzip;q=0, deflate;q=0"), None);
683 }
684
685 #[test]
686 fn a_quality_parameter_is_matched_case_insensitively() {
687 // RFC 9110 §5.6.6: a parameter name is case-insensitive, so `Q=0` is
688 // the same refusal as `q=0` and must lose the same way.
689 let c = plugin();
690 assert_eq!(c.negotiate("gzip;Q=0, deflate"), Some(Encoding::Deflate));
691 assert_eq!(c.negotiate("br;Q=0, gzip;Q=0, deflate;Q=0"), None);
692 assert_eq!(c.negotiate("gzip;Q=1.0, br;Q=0.1"), Some(Encoding::Gzip));
693 }
694
695 #[test]
696 fn an_explicit_coding_beats_the_wildcard() {
697 let c = plugin();
698 // `*` would give brotli 1.0; the explicit gzip entry is what the client
699 // actually named, so it wins despite the lower number.
700 assert_eq!(c.negotiate("gzip;q=0.5, *;q=0.4"), Some(Encoding::Gzip));
701 }
702
703 #[test]
704 fn the_wildcard_alone_selects_the_server_preference() {
705 assert_eq!(plugin().negotiate("*"), Some(Encoding::Brotli));
706 }
707
708 #[test]
709 fn unknown_codings_are_ignored() {
710 assert_eq!(plugin().negotiate("exi, sdch"), None);
711 }
712
713 #[test]
714 fn media_types_are_classified_conservatively() {
715 assert!(compressible_by_default("text/html"));
716 assert!(compressible_by_default("application/json"));
717 assert!(compressible_by_default("image/svg+xml"));
718 assert!(!compressible_by_default("image/jpeg"));
719 assert!(!compressible_by_default("application/zip"));
720 assert!(!compressible_by_default(""));
721 }
722
723 #[tokio::test]
724 async fn encoding_round_trips_through_gzip() {
725 use tokio::io::AsyncReadExt;
726
727 let input = Bytes::from("hello ".repeat(4000));
728 let body = encode(Body::Bytes(input.clone()), Encoding::Gzip, Level::Default);
729 let compressed = body.into_bytes().await.unwrap();
730 assert!(
731 compressed.len() < input.len(),
732 "repetitive text should shrink"
733 );
734
735 let mut decoded = Vec::new();
736 async_compression::tokio::bufread::GzipDecoder::new(std::io::Cursor::new(
737 compressed.to_vec(),
738 ))
739 .read_to_end(&mut decoded)
740 .await
741 .unwrap();
742 assert_eq!(Bytes::from(decoded), input);
743 }
744
745 #[tokio::test]
746 async fn a_buffered_body_yields_the_worker_between_chunks() {
747 use std::sync::atomic::{AtomicBool, Ordering};
748
749 // `#[tokio::test]` runs a current-thread runtime, where a spawned task
750 // gets a turn only once the task doing the encoding returns `Pending`.
751 // So the flag is a direct reading of whether the buffered path reaches
752 // the scheduler at all: with the encode running to completion inside
753 // one poll it is still false when the collect returns.
754 let ran = Arc::new(AtomicBool::new(false));
755 let flag = Arc::clone(&ran);
756 tokio::spawn(async move { flag.store(true, Ordering::SeqCst) });
757
758 let input = Bytes::from(vec![b'x'; CHUNK * 4]);
759 let encoded = encode(Body::Bytes(input), Encoding::Gzip, Level::Fastest)
760 .into_bytes()
761 .await
762 .unwrap();
763
764 assert!(
765 !encoded.is_empty(),
766 "the encode still has to produce output"
767 );
768 assert!(
769 ran.load(Ordering::SeqCst),
770 "a multi-chunk encode must let the runtime run something else"
771 );
772 }
773
774 #[test]
775 fn vary_is_appended_not_replaced() {
776 let mut res = Response::text("x");
777 res.headers.insert(VARY, HeaderValue::from_static("origin"));
778 vary_on_accept_encoding(&mut res);
779 assert_eq!(res.headers.get(VARY).unwrap(), "origin, accept-encoding");
780 }
781
782 #[test]
783 fn vary_is_not_duplicated() {
784 let mut res = Response::text("x");
785 vary_on_accept_encoding(&mut res);
786 vary_on_accept_encoding(&mut res);
787 assert_eq!(res.headers.get_all(VARY).iter().count(), 1);
788 assert_eq!(res.headers.get(VARY).unwrap(), "accept-encoding");
789 }
790
791 #[test]
792 fn a_strong_etag_becomes_weak() {
793 let mut res = Response::text("x");
794 res.headers
795 .insert(ETAG, HeaderValue::from_static("\"abc\""));
796 weaken_etag(&mut res);
797 assert_eq!(res.headers.get(ETAG).unwrap(), "W/\"abc\"");
798 }
799
800 #[test]
801 fn an_already_weak_etag_is_left_alone() {
802 let mut res = Response::text("x");
803 res.headers
804 .insert(ETAG, HeaderValue::from_static("W/\"abc\""));
805 weaken_etag(&mut res);
806 assert_eq!(res.headers.get(ETAG).unwrap(), "W/\"abc\"");
807 }
808
809 #[test]
810 fn partial_content_is_never_compressed() {
811 let mut res = Response::text("x".repeat(4096));
812 res.status = StatusCode::PARTIAL_CONTENT;
813 assert!(!plugin().should_compress(&res, false));
814 }
815
816 #[test]
817 fn an_already_encoded_response_is_left_alone() {
818 let mut res = Response::text("x".repeat(4096));
819 res.headers
820 .insert(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
821 assert!(!plugin().should_compress(&res, false));
822 }
823
824 #[test]
825 fn small_bodies_are_left_alone() {
826 let res = Response::text("small");
827 assert!(!plugin().should_compress(&res, false));
828 assert!(
829 plugin().min_size(1).should_compress(&res, false),
830 "lowering the floor should admit it"
831 );
832 }
833}