Skip to main content

rws_http/compression/
mod.rs

1#[cfg(test)]
2mod tests;
3
4pub mod dictionary;
5
6use flate2::Compression;
7use flate2::write::GzEncoder;
8use std::io::Write;
9use crate::header::Header;
10use crate::request::Request;
11use crate::response::Response;
12
13/// MIME types whose responses are worth compressing.
14const COMPRESSIBLE: &[&str] = &[
15    "text/html",
16    "text/css",
17    "text/javascript",
18    "text/plain",
19    "text/markdown",
20    "text/xml",
21    "application/json",
22    "application/javascript",
23    "application/xml",
24    "application/xhtml+xml",
25    "image/svg+xml",
26];
27
28/// Bodies smaller than this are sent uncompressed: gzip's framing overhead
29/// and the CPU time outweigh what it saves (Caddy's `encode` uses the same
30/// minimum).
31pub const MIN_COMPRESS_SIZE: usize = 512;
32
33/// If the client accepts gzip and the response body is compressible text of
34/// at least [`MIN_COMPRESS_SIZE`] bytes, compress every content range
35/// in-place and add `Content-Encoding: gzip`; also appends `Accept-Encoding`
36/// to the `Vary` header. A body that gzip doesn't make smaller (already
37/// compressed or random data) is left as it was, and so is a response that
38/// already has a `Content-Encoding` (e.g. a backend that compressed it).
39pub fn apply_gzip(request: &Request, response: &mut Response) {
40    compress_with(request, response, &[Encoding::Gzip]);
41}
42
43/// Like [`apply_gzip`], with the content coding negotiated from the request's
44/// `Accept-Encoding` among those compiled in ([`DYNAMIC_PREFERENCE`]): `zstd`,
45/// `br` or `gzip`. What the HTTP/1.1, HTTP/2 and HTTP/3 writers call (18.1.0).
46pub fn apply_compression(request: &Request, response: &mut Response) {
47    compress_with(request, response, DYNAMIC_PREFERENCE);
48}
49
50fn compress_with(request: &Request, response: &mut Response, preference: &[Encoding]) {
51    if response.content_range_list.is_empty() {
52        return;
53    }
54    // A partial body (206, one range or several) is a slice of the
55    // representation: gzipping the slice and rewriting Content-Range to the
56    // compressed size describes bytes that don't exist (#52, RFC 9110 §14.4).
57    if response.status_code == 206 || response.content_range_list.len() > 1 {
58        return;
59    }
60    if response.headers.iter().any(|h| h.name.eq_ignore_ascii_case(Header::_CONTENT_ENCODING)) {
61        return;
62    }
63    let original_size: usize = response.content_range_list.iter().map(|cr| cr.body.len()).sum();
64    if original_size < MIN_COMPRESS_SIZE {
65        return;
66    }
67    let Some(encoding) = negotiate(request, preference) else { return };
68    let content_type = response
69        .content_range_list
70        .first()
71        .map(|cr| cr.content_type.as_str())
72        .unwrap_or_default();
73    if !is_compressible(content_type) {
74        return;
75    }
76
77    let mut compressed_bodies = Vec::with_capacity(response.content_range_list.len());
78    for cr in &response.content_range_list {
79        match encode(encoding, &cr.body, Level::Dynamic) {
80            Some(compressed) => compressed_bodies.push(compressed),
81            None => return,
82        }
83    }
84    let compressed_size: usize = compressed_bodies.iter().map(Vec::len).sum();
85    if compressed_size >= original_size {
86        return;
87    }
88    for (cr, compressed) in response.content_range_list.iter_mut().zip(compressed_bodies) {
89        let new_len = compressed.len() as u64;
90        cr.body = compressed;
91        cr.range.end = new_len;
92        cr.size = new_len.to_string();
93    }
94    mark_encoded(response, encoding);
95    tag_etag(response, encoding);
96}
97
98/// A compressed body is a different representation, so it needs its own
99/// ETag: `"x"` becomes `"x-br"` (weak tags too), as nginx and Caddy do.
100fn tag_etag(response: &mut Response, encoding: Encoding) {
101    for header in response.headers.iter_mut().filter(|h| h.name.eq_ignore_ascii_case(Header::_ETAG)) {
102        let value = header.value.trim();
103        if let Some(quoted) = value.strip_suffix('"') {
104            let mut tagged = String::with_capacity(value.len() + 6);
105            tagged.push_str(quoted);
106            tagged.push('-');
107            tagged.push_str(encoding.token());
108            tagged.push('"');
109            header.value = tagged.into();
110        }
111    }
112}
113
114/// A content coding rws can produce. `Brotli` and `Zstd` need the `brotli`
115/// and `zstd` features (both default); without them they are never chosen.
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117pub enum Encoding {
118    Gzip,
119    Brotli,
120    Zstd,
121}
122
123impl Encoding {
124    /// The `Content-Encoding` / `Accept-Encoding` token.
125    pub fn token(self) -> &'static str {
126        match self {
127            Encoding::Gzip => "gzip",
128            Encoding::Brotli => "br",
129            Encoding::Zstd => "zstd",
130        }
131    }
132
133    /// Whether this build can produce it.
134    pub fn available(self) -> bool {
135        match self {
136            Encoding::Gzip => true,
137            Encoding::Brotli => cfg!(feature = "brotli"),
138            Encoding::Zstd => cfg!(feature = "zstd"),
139        }
140    }
141}
142
143/// Order of preference for responses compressed per request, when the client
144/// accepts several equally: zstd costs the least CPU at a ratio close to
145/// brotli's at the levels used here.
146pub const DYNAMIC_PREFERENCE: &[Encoding] = &[Encoding::Zstd, Encoding::Brotli, Encoding::Gzip];
147
148/// Order of preference for static files, compressed once per file version:
149/// brotli's smaller output wins.
150pub const STATIC_PREFERENCE: &[Encoding] = &[Encoding::Brotli, Encoding::Zstd, Encoding::Gzip];
151
152/// How hard to compress.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum Level {
155    /// For a body compressed on every request: fast levels (brotli 4, zstd 3,
156    /// gzip 6 — what Caddy and nginx use). Brotli's default quality, 11, is
157    /// about a hundred times slower than gzip.
158    Dynamic,
159    /// For a static file compressed once per version and cached: a notch
160    /// higher (brotli 6, zstd 6, gzip 6).
161    Static,
162}
163
164/// Brotli quality per [`Level`].
165pub const BROTLI_QUALITY: [u32; 2] = [4, 6];
166/// zstd level per [`Level`].
167pub const ZSTD_LEVEL: [i32; 2] = [3, 6];
168
169impl Level {
170    fn index(self) -> usize {
171        match self {
172            Level::Dynamic => 0,
173            Level::Static => 1,
174        }
175    }
176}
177
178/// The coding to use for this request among `preference` (in that order of
179/// preference on equal weight): the one with the highest `q` in its
180/// `Accept-Encoding`, counting `*` for codings not listed; `None` without the
181/// header, or when nothing in `preference` is acceptable or compiled in.
182pub fn negotiate(request: &Request, preference: &[Encoding]) -> Option<Encoding> {
183    let header = request.header(Header::_ACCEPT_ENCODING)?;
184    let mut best: Option<(Encoding, f32)> = None;
185    for &encoding in preference.iter().filter(|e| e.available()) {
186        let q = accepted_weight(&header.value, encoding.token());
187        if q > 0.0 && best.is_none_or(|(_, best_q)| q > best_q) {
188            best = Some((encoding, q));
189        }
190    }
191    best.map(|(encoding, _)| encoding)
192}
193
194/// `token`'s weight in an `Accept-Encoding` value: its own entry's `q`
195/// (1 when absent or unparsable), else the `*` entry's, else 0.
196fn accepted_weight(value: &str, token: &str) -> f32 {
197    let mut wildcard = None;
198    for coding in value.split(',') {
199        let mut parts = coding.split(';');
200        let name = parts.next().unwrap_or("").trim();
201        let q = parts
202            .find_map(|p| {
203                let p = p.trim();
204                // Bytes, not a str slice: `p` may start with a multi-byte
205                // character, and `p[..2]` would panic (found by fuzzing).
206                p.as_bytes().get(..2).is_some_and(|b| b.eq_ignore_ascii_case(b"q=")).then(|| p[2..].trim().parse::<f32>().ok())
207            })
208            .flatten()
209            .filter(|q| (0.0..=1.0).contains(q))
210            .unwrap_or(1.0);
211        if name.eq_ignore_ascii_case(token) {
212            return q;
213        }
214        if name == "*" {
215            wildcard = Some(q);
216        }
217    }
218    wildcard.unwrap_or(0.0)
219}
220
221/// `body` in `encoding` at `level`; `None` if this build can't produce it or
222/// the encoder fails.
223pub fn encode(encoding: Encoding, body: &[u8], level: Level) -> Option<Vec<u8>> {
224    match encoding {
225        Encoding::Gzip => gzip(body),
226        #[cfg(feature = "brotli")]
227        Encoding::Brotli => {
228            let mut out = Vec::with_capacity(body.len() / 3 + 64);
229            {
230                let mut writer = brotli::CompressorWriter::new(&mut out, 4096, BROTLI_QUALITY[level.index()], 22);
231                writer.write_all(body).ok()?;
232                writer.flush().ok()?;
233            }
234            Some(out)
235        }
236        #[cfg(feature = "zstd")]
237        Encoding::Zstd => zstd::bulk::compress(body, ZSTD_LEVEL[level.index()]).ok(),
238        #[allow(unreachable_patterns)]
239        _ => {
240            let _ = level;
241            None
242        }
243    }
244}
245
246/// The headers of a body in `encoding`: `Content-Encoding`, and
247/// `Accept-Encoding` appended to `Vary` (or a new `Vary`).
248pub fn mark_encoded(response: &mut Response, encoding: Encoding) {
249    response.headers.push(Header {
250        name: Header::_CONTENT_ENCODING.into(),
251        value: encoding.token().into(),
252    });
253    add_vary_accept_encoding(response);
254}
255
256/// Whether a body of this `Content-Type` is worth compressing ([`COMPRESSIBLE`]
257/// prefixes, case-insensitive).
258#[doc(hidden)]
259pub fn is_compressible(content_type: &str) -> bool {
260    COMPRESSIBLE.iter().any(|mime| {
261        content_type.len() >= mime.len() && content_type.as_bytes()[..mime.len()].eq_ignore_ascii_case(mime.as_bytes())
262    })
263}
264
265/// `body` gzip-compressed at the default level, as [`apply_gzip`] sends it.
266#[doc(hidden)]
267pub fn gzip(body: &[u8]) -> Option<Vec<u8>> {
268    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
269    encoder.write_all(body).ok()?;
270    encoder.finish().ok()
271}
272
273fn add_vary_accept_encoding(response: &mut Response) {
274    let vary_pos = response.headers.iter().position(|h| h.name == Header::_VARY);
275    if let Some(i) = vary_pos {
276        let current = response.headers[i].value.clone();
277        if !current.to_lowercase().contains("accept-encoding") {
278            response.headers[i].value = format!("{}, Accept-Encoding", current).into();
279        }
280    } else {
281        response.headers.push(Header {
282            name: Header::_VARY.into(),
283            value: "Accept-Encoding".into(),
284        });
285    }
286}