Skip to main content

ocserve_http/
compress.rs

1//! Content negotiation for the web surfaces (WEBUI-PLAN.md W1/W2).
2//!
3//! Design law: the wire contract is the freeze's — a client that sends
4//! neither `Accept-Encoding` nor `If-None-Match` must receive **byte-identical**
5//! responses to what ocserve served before this module existed (WEBUI-PLAN
6//! target 3, asserted by replay/pair/k6 and by tests here). Compression and
7//! validators are strictly additive, gated on the client asking.
8//!
9//! - `negotiate` picks an encoding from `Accept-Encoding` by q-value.
10//! - `compress` produces gzip (flate2, fast level) or brotli (q5). Brotli
11//!   wins on bytes for JSON/JS/CSS and is precomputed off the request path
12//!   for the F5 wire routes.
13//! - `etag_for` is a strong validator over the *identity* body, so the same
14//!   ETag tags both the identity and compressed representations (RFC 9110
15//!   §8.8.3 — a strong ETag must not change with encoding; the encoding is
16//!   conveyed by Content-Encoding + Vary, so the representation is still
17//!   distinct). 304 is answered before any body work.
18
19use axum::http::header;
20use axum::response::Response;
21
22/// Encodings we can produce, best-bytes first.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Encoding {
25    Brotli,
26    Gzip,
27}
28
29impl Encoding {
30    fn token(self) -> &'static str {
31        match self {
32            Encoding::Brotli => "br",
33            Encoding::Gzip => "gzip",
34        }
35    }
36}
37
38/// Pick the client's preferred supported encoding from `Accept-Encoding`.
39/// Honours q-values; `identity`/`*;q=0` suppress compression. Returns None
40/// when the client did not offer anything (→ identity, the pre-existing
41/// behavior).
42pub fn negotiate(accept_encoding: Option<&str>) -> Option<Encoding> {
43    let ae = accept_encoding?;
44    // q-value parser: "gzip;q=0.5, br;q=1.0, *;q=0"
45    let mut best: Option<(Encoding, f32)> = None;
46    let mut identity_q: f32 = 1.0;
47    let mut star_q: Option<f32> = None;
48    for part in ae.split(',') {
49        let mut it = part.trim().split(';');
50        let token = it.next().unwrap_or("").trim().to_ascii_lowercase();
51        let mut q = 1.0f32;
52        for p in it {
53            let p = p.trim();
54            if let Some(v) = p.strip_prefix("q=") {
55                q = v.trim().parse().unwrap_or(1.0);
56            }
57        }
58        match token.as_str() {
59            "gzip" => consider(&mut best, Encoding::Gzip, q),
60            "br" => consider(&mut best, Encoding::Brotli, q),
61            "identity" => identity_q = q,
62            "*" => star_q = Some(q),
63            _ => {}
64        }
65    }
66    // If a wildcard allows an encoding we support and none was explicit,
67    // prefer brotli (best bytes) when the wildcard is at least as preferred
68    // as identity (RFC 9110 §12.5.3: `*` opts into any coding).
69    if best.is_none()
70        && let Some(sq) = star_q
71        && sq >= identity_q
72    {
73        best = Some((Encoding::Brotli, sq));
74    }
75    best.filter(|(_, q)| *q > 0.0).map(|(e, _)| e)
76}
77
78fn consider(best: &mut Option<(Encoding, f32)>, enc: Encoding, q: f32) {
79    if q <= 0.0 {
80        return;
81    }
82    // Tie-break by encoding quality, not header order: browsers send
83    // `gzip, deflate, br, zstd` (gzip first), but brotli is universally
84    // smaller — RFC 9110 §12.5.3 lets the server choose among equal q. A
85    // strictly higher q still wins.
86    let better = match best {
87        None => true,
88        Some((cur, bq)) => {
89            q > *bq || (q == *bq && *cur == Encoding::Gzip && enc == Encoding::Brotli)
90        }
91    };
92    if better {
93        *best = Some((enc, q));
94    }
95}
96
97/// Compress `data`. gzip uses the fast level (request-path friendly);
98/// brotli uses quality 5 (best bytes/second tradeoff — WEBUI-PLAN measured
99/// br-q11 at 20 s for 6.2 MB, rejected; q5 at 0.18 s). Precomputed callers
100/// use this on the write-epoch path, not per request.
101pub fn compress(data: &[u8], enc: Encoding) -> Vec<u8> {
102    match enc {
103        Encoding::Gzip => {
104            use flate2::write::GzEncoder;
105            use std::io::Write as _;
106            let mut e = GzEncoder::new(Vec::new(), flate2::Compression::default());
107            // write_all to a Vec cannot fail.
108            if e.write_all(data).is_err() {
109                return data.to_vec();
110            }
111            e.finish().unwrap_or_else(|_| data.to_vec())
112        }
113        Encoding::Brotli => {
114            use std::io::Write as _;
115            let mut out = Vec::with_capacity(data.len() / 4 + 64);
116            {
117                let mut w = brotli::CompressorWriter::new(&mut out, 4096, 5, 22);
118                if w.write_all(data).is_err() {
119                    return data.to_vec();
120                }
121            }
122            out
123        }
124    }
125}
126
127/// Strong ETag over the identity body: `"<hex-sha256[..16]>"` quoted per
128/// RFC 9110. Stable across encodings (see module docs).
129pub fn etag_for(body: &[u8]) -> String {
130    use sha2::{Digest as _, Sha256};
131    let d = Sha256::digest(body);
132    format!("\"{}\"", hex::encode(&d[..16]))
133}
134
135/// Whether an `If-None-Match` header matches this ETag (weak/strong
136/// comparison per RFC 9110 §13.1.2 — compare opaque tags, `W/` ignored on
137/// the request side is not needed because we never emit weak tags).
138pub fn if_none_match_matches(header_value: Option<&str>, etag: &str) -> bool {
139    let Some(hv) = header_value else { return false };
140    let hv = hv.trim();
141    if hv == "*" {
142        return true;
143    }
144    hv.split(',').any(|t| t.trim() == etag)
145}
146
147/// Whether a route's content type is compressible (SSE is NOT: byte-golden
148/// stream, and compressing it would break framing/latency). Images/fonts
149/// already-compressed are skipped.
150pub fn is_compressible(content_type: &str) -> bool {
151    let ct = content_type.to_ascii_lowercase();
152    if ct.starts_with("text/event-stream") {
153        return false; // never touch SSE
154    }
155    (ct.starts_with("application/json")
156        || ct.starts_with("text/")
157        || ct.contains("javascript")
158        || ct.contains("svg"))
159        && !ct.starts_with("image/")
160}
161
162/// Apply negotiation + validators to an identity body, returning a full
163/// response. `identity_etag` is the caller-computed ETag (over identity
164/// bytes) or None to skip validator handling.
165pub fn encode_response(
166    status: axum::http::StatusCode,
167    content_type: &str,
168    identity: bytes::Bytes,
169    identity_etag: Option<&str>,
170    accept_encoding: Option<&str>,
171    if_none_match: Option<&str>,
172    extra: &[(&str, &str)],
173) -> Response {
174    // 304 short-circuit: only when we have a validator and it matches.
175    if let (Some(etag), Some(inm)) = (identity_etag, if_none_match)
176        && if_none_match_matches(Some(inm), etag)
177    {
178        let mut b = Response::builder()
179            .status(axum::http::StatusCode::NOT_MODIFIED)
180            .header(header::ETAG, etag);
181        for (k, v) in extra {
182            b = b.header(*k, *v);
183        }
184        return b.body(axum::body::Body::empty()).expect("static 304");
185    }
186
187    let compressible = is_compressible(content_type);
188    let chosen = if compressible {
189        negotiate(accept_encoding)
190    } else {
191        None
192    };
193
194    let mut builder = Response::builder().status(status);
195    for (k, v) in extra {
196        builder = builder.header(*k, *v);
197    }
198    builder = builder.header(header::CONTENT_TYPE, content_type);
199    if let Some(etag) = identity_etag {
200        builder = builder.header(header::ETAG, etag);
201    }
202    // Vary is required whenever the response *could* differ by encoding, so
203    // caches key correctly (RFC 9110 §12.5.3).
204    if compressible {
205        builder = builder.header(header::VARY, "Accept-Encoding");
206    }
207
208    match chosen {
209        Some(enc) => {
210            let compressed = compress(&identity, enc);
211            builder
212                .header(header::CONTENT_ENCODING, enc.token())
213                .body(axum::body::Body::from(compressed))
214                .expect("static response")
215        }
216        None => builder
217            .body(axum::body::Body::from(identity))
218            .expect("static response"),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn negotiate_q_values_and_identity() {
228        assert_eq!(negotiate(None), None);
229        assert_eq!(negotiate(Some("")), None);
230        assert_eq!(negotiate(Some("gzip")), Some(Encoding::Gzip));
231        assert_eq!(negotiate(Some("br")), Some(Encoding::Brotli));
232        // br preferred on tie regardless of header order (quality tie-break)
233        assert_eq!(negotiate(Some("br, gzip")), Some(Encoding::Brotli));
234        assert_eq!(
235            negotiate(Some("gzip, deflate, br, zstd")),
236            Some(Encoding::Brotli)
237        );
238        // explicit q wins
239        assert_eq!(
240            negotiate(Some("br;q=0.5, gzip;q=1.0")),
241            Some(Encoding::Gzip)
242        );
243        // identity;q=1 with br;q=0.5 → still br (client accepted it)
244        assert_eq!(
245            negotiate(Some("br;q=0.5, identity;q=1.0")),
246            Some(Encoding::Brotli)
247        );
248        // all zero → no compression
249        assert_eq!(negotiate(Some("gzip;q=0, br;q=0")), None);
250        // wildcard
251        assert_eq!(negotiate(Some("*")), Some(Encoding::Brotli));
252        // unknown only
253        assert_eq!(negotiate(Some("deflate, zstd")), None);
254    }
255
256    #[test]
257    fn compress_roundtrips_shapes() {
258        let data = b"hello hello hello world world world".repeat(50);
259        let gz = compress(&data, Encoding::Gzip);
260        let br = compress(&data, Encoding::Brotli);
261        assert!(gz.len() < data.len());
262        assert!(br.len() < data.len());
263        // gzip magic + brotli decodes to identity
264        assert_eq!(&gz[..2], &[0x1f, 0x8b]);
265        // decode gzip
266        use std::io::Read as _;
267        let mut d = flate2::read::GzDecoder::new(&gz[..]);
268        let mut out = Vec::new();
269        d.read_to_end(&mut out).unwrap();
270        assert_eq!(out, data);
271    }
272
273    #[test]
274    fn etag_stable_and_inm() {
275        let a = etag_for(b"abc");
276        assert_eq!(a, etag_for(b"abc"));
277        assert_ne!(a, etag_for(b"abd"));
278        assert!(a.starts_with('"') && a.ends_with('"'));
279        assert!(if_none_match_matches(Some(&a), &a));
280        assert!(if_none_match_matches(Some("*"), &a));
281        assert!(if_none_match_matches(Some(&format!("junk, {a}")), &a));
282        assert!(!if_none_match_matches(Some("\"other\""), &a));
283        assert!(!if_none_match_matches(None, &a));
284    }
285
286    #[test]
287    fn sse_never_compressible() {
288        assert!(!is_compressible("text/event-stream"));
289        assert!(is_compressible("application/json"));
290        assert!(is_compressible("text/html;charset=UTF-8"));
291        assert!(is_compressible("text/javascript"));
292        assert!(!is_compressible("image/png"));
293    }
294
295    #[test]
296    fn encode_response_identity_is_byte_identical() {
297        // The load-bearing invariant: no Accept-Encoding, no If-None-Match →
298        // identity bytes, no Content-Encoding, no Vary on non-compressible.
299        let body = bytes::Bytes::from_static(b"{\"a\":1}");
300        let resp = encode_response(
301            axum::http::StatusCode::OK,
302            "application/json",
303            body.clone(),
304            None,
305            None,
306            None,
307            &[],
308        );
309        assert!(resp.headers().get(header::CONTENT_ENCODING).is_none());
310        // Vary present (compressible) even without negotiation — correct.
311        assert_eq!(resp.headers().get(header::VARY).unwrap(), "Accept-Encoding");
312    }
313
314    #[test]
315    fn encode_response_compresses_when_asked() {
316        let body = bytes::Bytes::from_static(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
317        let resp = encode_response(
318            axum::http::StatusCode::OK,
319            "application/json",
320            body,
321            Some("\"etag\""),
322            Some("br"),
323            None,
324            &[],
325        );
326        assert_eq!(resp.headers().get(header::CONTENT_ENCODING).unwrap(), "br");
327        assert_eq!(resp.headers().get(header::ETAG).unwrap(), "\"etag\"");
328    }
329
330    #[test]
331    fn encode_response_304_short_circuit() {
332        let body = bytes::Bytes::from_static(b"{\"a\":1}");
333        let resp = encode_response(
334            axum::http::StatusCode::OK,
335            "application/json",
336            body,
337            Some("\"abc\""),
338            None,
339            Some("\"abc\""),
340            &[],
341        );
342        assert_eq!(resp.status(), axum::http::StatusCode::NOT_MODIFIED);
343    }
344}