Skip to main content

rust_web_server/compression/
mod.rs

1#[cfg(test)]
2mod tests;
3
4use flate2::Compression;
5use flate2::write::GzEncoder;
6use std::io::Write;
7use crate::header::Header;
8use crate::request::Request;
9use crate::response::Response;
10
11/// MIME types whose responses are worth compressing.
12const COMPRESSIBLE: &[&str] = &[
13    "text/html",
14    "text/css",
15    "text/javascript",
16    "text/plain",
17    "text/xml",
18    "application/json",
19    "application/javascript",
20    "application/xml",
21    "application/xhtml+xml",
22    "image/svg+xml",
23];
24
25/// Bodies smaller than this are sent uncompressed: gzip's framing overhead
26/// and the CPU time outweigh what it saves (Caddy's `encode` uses the same
27/// minimum).
28pub const MIN_COMPRESS_SIZE: usize = 512;
29
30/// If the client accepts gzip and the response body is compressible text of
31/// at least [`MIN_COMPRESS_SIZE`] bytes, compress every content range
32/// in-place and add `Content-Encoding: gzip`; also appends `Accept-Encoding`
33/// to the `Vary` header. A body that gzip doesn't make smaller (already
34/// compressed or random data) is left as it was, and so is a response that
35/// already has a `Content-Encoding` (e.g. a backend that compressed it).
36pub fn apply_gzip(request: &Request, response: &mut Response) {
37    if response.content_range_list.is_empty() {
38        return;
39    }
40    // A partial body (206, one range or several) is a slice of the
41    // representation: gzipping the slice and rewriting Content-Range to the
42    // compressed size describes bytes that don't exist (#52, RFC 9110 ยง14.4).
43    if response.status_code == 206 || response.content_range_list.len() > 1 {
44        return;
45    }
46    if response.headers.iter().any(|h| h.name.eq_ignore_ascii_case(Header::_CONTENT_ENCODING)) {
47        return;
48    }
49    let original_size: usize = response.content_range_list.iter().map(|cr| cr.body.len()).sum();
50    if original_size < MIN_COMPRESS_SIZE {
51        return;
52    }
53    if !accepts_gzip(request) {
54        return;
55    }
56    let content_type = response
57        .content_range_list
58        .first()
59        .map(|cr| cr.content_type.as_str())
60        .unwrap_or_default();
61    if !is_compressible(content_type) {
62        return;
63    }
64
65    let mut compressed_bodies = Vec::with_capacity(response.content_range_list.len());
66    for cr in &response.content_range_list {
67        match gzip(&cr.body) {
68            Some(compressed) => compressed_bodies.push(compressed),
69            None => return,
70        }
71    }
72    let compressed_size: usize = compressed_bodies.iter().map(Vec::len).sum();
73    if compressed_size >= original_size {
74        return;
75    }
76    for (cr, compressed) in response.content_range_list.iter_mut().zip(compressed_bodies) {
77        let new_len = compressed.len() as u64;
78        cr.body = compressed;
79        cr.range.end = new_len;
80        cr.size = new_len.to_string();
81    }
82    mark_gzipped(response);
83}
84
85/// Whether the request's `Accept-Encoding` allows gzip: `gzip`, optionally
86/// with parameters, and not `q=0` ("not acceptable").
87pub(crate) fn accepts_gzip(request: &Request) -> bool {
88    request
89        .header(Header::_ACCEPT_ENCODING)
90        .map(|h| h.value.split(',').any(|coding| {
91            let mut parts = coding.split(';');
92            let name_ok = parts.next().unwrap_or("").trim().eq_ignore_ascii_case("gzip");
93            let refused = parts.any(|p| {
94                let p = p.trim();
95                p.len() >= 2 && p[..2].eq_ignore_ascii_case("q=") && p[2..].trim().parse::<f32>().is_ok_and(|q| q == 0.0)
96            });
97            name_ok && !refused
98        }))
99        .unwrap_or(false)
100}
101
102/// Whether a body of this `Content-Type` is worth compressing ([`COMPRESSIBLE`]
103/// prefixes, case-insensitive).
104pub(crate) fn is_compressible(content_type: &str) -> bool {
105    COMPRESSIBLE.iter().any(|mime| {
106        content_type.len() >= mime.len() && content_type.as_bytes()[..mime.len()].eq_ignore_ascii_case(mime.as_bytes())
107    })
108}
109
110/// `body` gzip-compressed at the default level, as [`apply_gzip`] sends it.
111pub(crate) fn gzip(body: &[u8]) -> Option<Vec<u8>> {
112    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
113    encoder.write_all(body).ok()?;
114    encoder.finish().ok()
115}
116
117/// The headers of a gzip-encoded body: `Content-Encoding: gzip`, and
118/// `Accept-Encoding` appended to `Vary` (or a new `Vary`).
119pub(crate) fn mark_gzipped(response: &mut Response) {
120    response.headers.push(Header {
121        name: Header::_CONTENT_ENCODING.into(),
122        value: "gzip".into(),
123    });
124
125    let vary_pos = response.headers.iter().position(|h| h.name == Header::_VARY);
126    if let Some(i) = vary_pos {
127        let current = response.headers[i].value.clone();
128        if !current.to_lowercase().contains("accept-encoding") {
129            response.headers[i].value = format!("{}, Accept-Encoding", current).into();
130        }
131    } else {
132        response.headers.push(Header {
133            name: Header::_VARY.into(),
134            value: "Accept-Encoding".into(),
135        });
136    }
137}