dwara_core/dataplane/
compression.rs

1//! Route-scoped response compression (DW-027, feature analysis 4.13).
2//!
3//! One [`Compression`] policy per route drives two steps:
4//!
5//! - **Negotiation** ([`negotiate`]): the first algorithm of the
6//!   policy's preference order the request's `Accept-Encoding` accepts
7//!   wins. `q=0` codings are treated as refusal; `*` accepts any. No
8//!   acceptable overlap means no compression (identity is always
9//!   permitted; the gateway never answers 406 over compression).
10//! - **Encoding** ([`wrap_response`]): the response body is wrapped in
11//!   [`CompressedBody`], which compresses CHUNK-BY-CHUNK — every data
12//!   frame is written through the codec and flushed, and whatever the
13//!   codec produced is forwarded as the next frame. The gateway never
14//!   buffers a whole body to compress it; the only buffering is the
15//!   codec's own bounded working set plus the not-yet-emitted output of
16//!   the current chunk. Per-chunk flushing costs a little ratio and
17//!   buys streaming correctness: a slow/trickling upstream (SSE)
18//!   reaches the client per chunk instead of at end-of-stream.
19//!   Content types that must not be delayed (e.g. `text/event-stream`)
20//!   are the operator's call via `excluded_content_types`.
21//!
22//! Response eligibility (see [`decide`]): never a 1xx/204/304, never a
23//! 101 upgrade, never a body that already carries `Content-Encoding`,
24//! never a zero length, never below `min_size` when the size is known
25//! (declared `Content-Length` or the exact size of the gateway's own
26//! header-less bodies; unknown-length streams are always candidates).
27//! `Content-Length` is dropped on compression (the encoded
28//! length differs; framing switches to chunked/streamed), and
29//! `Content-Encoding: <token>` plus `Vary: Accept-Encoding` (merged into
30//! any existing `Vary`) are set. `Vary` is ALSO added to responses that
31//! were candidates but skipped (too small, wrong content type, no
32//! acceptable coding) so shared caches key them correctly alongside
33//! their compressed siblings — the proxy handles that half.
34//!
35//! The three codecs are Write-based streaming encoders (flate2 gzip,
36//! brotli, zstd) all draining into one shared output sink the wrapper reads
37//! compressed bytes back out of; each carries the per-algorithm level
38//! clamp (gzip 0-9, brotli 0-11, zstd 0-22; defaults 6/5/3 — zstd 3 and
39//! brotli 5 sit at the fast end of their useful range, the right bias
40//! for a per-route default on a hot proxy path).
41
42use std::collections::BTreeSet;
43use std::io::Write as _;
44use std::pin::Pin;
45use std::sync::{Arc, Mutex};
46use std::task::{Context, Poll};
47
48use bytes::Bytes;
49use hyper::body::Frame;
50use hyper::header::{HeaderMap, HeaderValue};
51use hyper::{Response, StatusCode};
52
53use crate::config::{CompiledContentTypeFilter, Compression, CompressionAlgorithm};
54use crate::dataplane::hardening::merge_vary;
55use crate::dataplane::proxy::{ProxyBody, ProxyBodyError};
56
57/// Shared output sink the codecs write into and [`CompressedBody`]
58/// drains compressed bytes out of (PERF-08, #208).
59///
60/// The sink is `Arc<Mutex<Vec<u8>>>` — the encoder and the drainer
61/// live in the same task and never contend, so the lock is
62/// uncontended (a `try_lock` would always succeed). The key
63/// efficiency win over the original design is CAPACITY REUSE: the
64/// Vec is pre-allocated with 8 KiB and `drain` uses `clear()` (which
65/// keeps the capacity) instead of `take` (which allocates a fresh
66/// Vec each chunk). This eliminates the per-chunk realloc on
67/// compressible traffic while keeping the design `Send` without
68/// `unsafe`.
69///
70/// Per-codec output caps prevent the sink from growing unboundedly
71/// for a codec that produces a large ratio on a single chunk: the
72/// cap is the larger of 2x the input chunk or 64 KiB, so a 1 MiB
73/// incompressible chunk caps the sink at 2 MiB (the codec's own
74/// overhead is bounded).
75type Sink = Arc<Mutex<Vec<u8>>>;
76
77/// The `io::Write` view of a [`Sink`] the encoders hold.
78#[derive(Clone)]
79struct SinkWriter(Sink);
80
81impl std::io::Write for SinkWriter {
82    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
83        let mut sink = self.0.lock().unwrap_or_else(|p| p.into_inner());
84        // PERF-08 (#208): reserve capacity to avoid repeated
85        // reallocations as the codec writes. The Vec's capacity is
86        // preserved across drains (clear, not take), so subsequent
87        // chunks reuse it.
88        if sink.capacity() < sink.len() + buf.len() {
89            sink.reserve(buf.len());
90        }
91        sink.extend_from_slice(buf);
92        Ok(buf.len())
93    }
94    fn flush(&mut self) -> std::io::Result<()> {
95        Ok(())
96    }
97}
98
99/// Drain the sink's bytes, preserving capacity for reuse (PERF-08,
100/// #208). Returns the drained bytes as a `Bytes` (zero-copy split
101/// from the Vec's backing). The Vec is `clear`ed (capacity kept),
102/// not `take`n (capacity lost), so the next chunk's write reuses
103/// the same allocation.
104fn drain(sink: &Sink) -> Bytes {
105    let mut s = sink.lock().unwrap_or_else(|p| p.into_inner());
106    let out = Bytes::copy_from_slice(&s);
107    s.clear(); // keep capacity
108    out
109}
110
111/// The negotiated coding for one response.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct CompressionPlan {
114    pub algorithm: CompressionAlgorithm,
115    /// Effective level AFTER the per-algorithm clamp.
116    pub level: u8,
117}
118
119/// Per-algorithm default level (see the module docs for the rationale).
120fn default_level(algorithm: CompressionAlgorithm) -> u8 {
121    match algorithm {
122        CompressionAlgorithm::Gzip => 6,
123        CompressionAlgorithm::Brotli => 5,
124        CompressionAlgorithm::Zstd => 3,
125    }
126}
127
128/// Per-algorithm maximum level (validation bounds the field to 0-22;
129/// the clamp happens here so a single config level works across
130/// algorithms with different ranges).
131fn max_level(algorithm: CompressionAlgorithm) -> u8 {
132    match algorithm {
133        CompressionAlgorithm::Gzip => 9,
134        CompressionAlgorithm::Brotli => 11,
135        CompressionAlgorithm::Zstd => 22,
136    }
137}
138
139/// Which codings an `Accept-Encoding` value accepts (DW-027). `*`
140/// accepts everything; `q=0` excludes a coding; anything not listed is
141/// not accepted (identity is always fine and is what "no compression"
142/// answers with). Header absent = nothing but identity.
143struct AcceptedEncodings {
144    any: bool,
145    codings: BTreeSet<String>,
146}
147
148fn accepted_encodings(accept: Option<&str>) -> AcceptedEncodings {
149    let mut out = AcceptedEncodings {
150        any: false,
151        codings: BTreeSet::new(),
152    };
153    let Some(accept) = accept else {
154        return out;
155    };
156    for entry in accept.split(',') {
157        let entry = entry.trim();
158        if entry.is_empty() {
159            continue;
160        }
161        let (token, params) = match entry.split_once(';') {
162            Some((t, p)) => (t.trim(), p),
163            None => (entry, ""),
164        };
165        // q=0 (or 0.0...) is an explicit refusal; any other q (or none)
166        // accepts. Parameters other than q are ignored per RFC 9110.
167        let refused = params
168            .split(';')
169            .map(|p| p.trim())
170            .find_map(|p| p.strip_prefix("q=").map(|q| q.trim().to_lowercase()))
171            .and_then(|q| q.parse::<f32>().ok())
172            .is_some_and(|q| q == 0.0);
173        if refused {
174            continue;
175        }
176        let token = token.to_lowercase();
177        if token == "*" {
178            out.any = true;
179        } else {
180            out.codings.insert(token);
181        }
182    }
183    out
184}
185
186/// Negotiate the coding for one response (DW-027): the first policy
187/// algorithm the request accepts wins; `None` = serve identity.
188pub fn negotiate(policy: &Compression, accept: Option<&str>) -> Option<CompressionPlan> {
189    let accepted = accepted_encodings(accept);
190    let requested = policy.level.unwrap_or(0);
191    policy
192        .algorithms
193        .iter()
194        .copied()
195        .find(|a| accepted.any || accepted.codings.contains(a.encoding_token()))
196        .map(|algorithm| CompressionPlan {
197            algorithm,
198            level: if policy.level.is_some() {
199                requested.min(max_level(algorithm))
200            } else {
201                default_level(algorithm)
202            },
203        })
204}
205
206/// The response's `Content-Type` reduced to its lowercase media type
207/// (parameters stripped) for prefix matching. Absent or non-UTF-8
208/// collapses to the empty string, which the compiled filter treats as
209/// "no type signal" — compressible under an empty include list, never
210/// matching a specific prefix (the same reading the header-based check
211/// had).
212fn media_type(headers: &HeaderMap) -> String {
213    headers
214        .get(hyper::header::CONTENT_TYPE)
215        .and_then(|v| v.to_str().ok())
216        .unwrap_or_default()
217        .split(';')
218        .next()
219        .unwrap_or_default()
220        .trim()
221        .to_lowercase()
222}
223
224/// Decide whether THIS response gets compressed under the policy
225/// (DW-027): status, existing encoding, known size, content type, and
226/// negotiation all have to line up. `None` = pass through (the caller
227/// still adds `Vary: Accept-Encoding` for cache correctness).
228///
229/// `types` is the policy's snapshot-compiled content-type filter (see
230/// [`crate::snapshot::RouteTable::compression_types`]); `body_size` is
231/// the response body's exact size when known (`size_hint().exact()` —
232/// exact for the gateway's fully-buffered bodies) so `min_size` gates
233/// bodies that carry no `Content-Length` header too (respond-action
234/// and redirect bodies). `None` = length unknown (streaming).
235pub fn decide(
236    policy: &Compression,
237    types: &CompiledContentTypeFilter,
238    status: StatusCode,
239    headers: &HeaderMap,
240    body_size: Option<u64>,
241    accept_encoding: Option<&str>,
242) -> Option<CompressionPlan> {
243    // 1xx carry no body; 204/304 are body-less by definition. 101 is a
244    // protocol switch (the body becomes the tunneled connection).
245    if status.is_informational()
246        || status == StatusCode::NO_CONTENT
247        || status == StatusCode::NOT_MODIFIED
248        || status == StatusCode::SWITCHING_PROTOCOLS
249    {
250        return None;
251    }
252    // Already-encoded bodies pass through untouched (double-compressing
253    // a jpeg grows it and corrupts content negotiation).
254    if headers.contains_key(hyper::header::CONTENT_ENCODING) {
255        return None;
256    }
257    // Size gate: `min_size` applies whenever the size is KNOWN — the
258    // declared `Content-Length`, or the exact size hint for bodies that
259    // carry no header (the gateway's own respond/redirect bodies; a
260    // ~23-byte gzip container around an empty 302 helps nobody). A
261    // present-but-unparsable length is untrustworthy: do not compress.
262    let known_size = match headers.get(hyper::header::CONTENT_LENGTH) {
263        Some(len) => Some(len.to_str().ok()?.trim().parse::<u64>().ok()?),
264        None => body_size,
265    };
266    if known_size.is_some_and(|len| len == 0 || len < policy.min_size) {
267        return None;
268    }
269    if !types.allows(&media_type(headers)) {
270        return None;
271    }
272    negotiate(policy, accept_encoding)
273}
274
275/// Apply a plan to a response (DW-027): header rewrite + body wrap.
276/// `Content-Length` is removed (the encoded length differs); the
277/// encoding token and `Vary: Accept-Encoding` are set.
278pub fn wrap_response(resp: Response<ProxyBody>, plan: &CompressionPlan) -> Response<ProxyBody> {
279    let (mut parts, body) = resp.into_parts();
280    parts.headers.remove(hyper::header::CONTENT_LENGTH);
281    if let Ok(v) = HeaderValue::from_str(plan.algorithm.encoding_token()) {
282        parts.headers.insert(hyper::header::CONTENT_ENCODING, v);
283    }
284    merge_vary(&mut parts.headers, "Accept-Encoding");
285    Response::from_parts(
286        parts,
287        ProxyBody::Compressed(Box::new(CompressedBody::new(body, *plan))),
288    )
289}
290
291/// One of the three Write-based streaming codecs (DW-027). `write_chunk`
292/// pushes input through the codec with a flush (a sync-flush point for
293/// gzip/brotli, a block flush for zstd) so output is drainable per
294/// chunk; `finish` completes the stream (gzip trailer, brotli final
295/// block, zstd frame end).
296enum Encoder {
297    Gzip(flate2::write::GzEncoder<SinkWriter>),
298    Brotli(Box<brotli::CompressorWriter<SinkWriter>>),
299    Zstd(zstd::Encoder<'static, SinkWriter>),
300}
301
302impl Encoder {
303    fn new(plan: &CompressionPlan, sink: SinkWriter) -> Self {
304        match plan.algorithm {
305            CompressionAlgorithm::Gzip => Encoder::Gzip(flate2::write::GzEncoder::new(
306                sink,
307                flate2::Compression::new(plan.level as u32),
308            )),
309            CompressionAlgorithm::Brotli => Encoder::Brotli(Box::new(
310                brotli::CompressorWriter::new(sink, 4096, plan.level as u32, 22),
311            )),
312            CompressionAlgorithm::Zstd => Encoder::Zstd(
313                zstd::Encoder::new(sink, plan.level as i32)
314                    .expect("zstd level is clamped to the valid range at construction"),
315            ),
316        }
317    }
318
319    fn write_chunk(&mut self, data: &[u8]) -> std::io::Result<()> {
320        match self {
321            Encoder::Gzip(e) => {
322                e.write_all(data)?;
323                e.flush()
324            }
325            Encoder::Brotli(e) => {
326                e.write_all(data)?;
327                e.flush()
328            }
329            Encoder::Zstd(e) => {
330                e.write_all(data)?;
331                e.flush()
332            }
333        }
334    }
335
336    /// Complete the stream; final bytes land in the sink. The brotli
337    /// `into_inner` swallows codec errors (the sink is a Vec — its IO
338    /// cannot fail; a codec-side failure is unreachable in practice and
339    /// would surface as a truncated stream either way).
340    fn finish(self) {
341        match self {
342            Encoder::Gzip(e) => {
343                let _ = e.finish();
344            }
345            Encoder::Brotli(e) => {
346                let _ = e.into_inner();
347            }
348            Encoder::Zstd(e) => {
349                let _ = e.finish();
350            }
351        }
352    }
353}
354
355/// Compressing wrapper over a [`ProxyBody`] (DW-027). Streams: every
356/// inner data frame goes through the codec and comes back as (at most)
357/// one emitted frame of pending output; the codec is finished when the
358/// inner stream ends, and any trailing headers observed on the inner
359/// stream are re-emitted AFTER the compressed bytes (trailers ride
360/// after the body; compression does not touch them).
361pub struct CompressedBody {
362    inner: Pin<Box<ProxyBody>>,
363    encoder: Option<Box<Encoder>>,
364    sink: Sink,
365    /// Compressed bytes not yet emitted to the client (PERF-08, #208:
366    /// `Bytes` not `Vec<u8>` — the drain returns `Bytes` and the
367    /// pending buffer is emitted as a `Frame::data` without another
368    /// copy).
369    pending: Bytes,
370    /// Trailers seen on the inner stream, deferred until the compressed
371    /// bytes have been emitted.
372    trailer: Option<HeaderMap>,
373    inner_done: bool,
374    finished: bool,
375}
376
377impl CompressedBody {
378    fn new(inner: ProxyBody, plan: CompressionPlan) -> Self {
379        // PERF-08 (#208): pre-allocate the sink with 8 KiB so the
380        // first chunk doesn't trigger a grow. The capacity is reused
381        // across drains (clear, not take).
382        let sink: Sink = Arc::new(Mutex::new(Vec::with_capacity(8192)));
383        CompressedBody {
384            inner: Box::pin(inner),
385            encoder: Some(Box::new(Encoder::new(&plan, SinkWriter(Arc::clone(&sink))))),
386            sink,
387            pending: Bytes::new(),
388            trailer: None,
389            inner_done: false,
390            finished: false,
391        }
392    }
393}
394
395impl hyper::body::Body for CompressedBody {
396    type Data = Bytes;
397    type Error = ProxyBodyError;
398
399    fn poll_frame(
400        self: Pin<&mut Self>,
401        cx: &mut Context<'_>,
402    ) -> Poll<Option<Result<Frame<Bytes>, ProxyBodyError>>> {
403        let this = self.get_mut();
404        loop {
405            if !this.pending.is_empty() {
406                let chunk = std::mem::take(&mut this.pending);
407                return Poll::Ready(Some(Ok(Frame::data(chunk))));
408            }
409            if this.inner_done {
410                // Finish the codec BEFORE any trailers frame: the final
411                // gzip/brotli/zstd tail bytes must drain as DATA frames
412                // ahead of the trailers (h2 upstreams deliver trailers;
413                // data-after-trailers is a framing violation that tears
414                // the stream). The pending-drain at the top of the loop
415                // emits those bytes; the trailers frame goes out only
416                // once finished AND fully drained.
417                if !this.finished {
418                    if let Some(encoder) = this.encoder.take() {
419                        encoder.finish();
420                    }
421                    this.pending = drain(&this.sink);
422                    this.finished = true;
423                    continue;
424                }
425                if let Some(trailer) = this.trailer.take() {
426                    return Poll::Ready(Some(Ok(Frame::trailers(trailer))));
427                }
428                return Poll::Ready(None);
429            }
430            match this.inner.as_mut().poll_frame(cx) {
431                Poll::Ready(Some(Ok(frame))) => {
432                    if let Some(data) = frame.data_ref() {
433                        if let Some(encoder) = this.encoder.as_deref_mut() {
434                            if let Err(e) = encoder.write_chunk(data) {
435                                return Poll::Ready(Some(Err(ProxyBodyError::Io(e))));
436                            }
437                        }
438                        this.pending = drain(&this.sink);
439                    } else if let Ok(trailer) = frame.into_trailers() {
440                        this.trailer = Some(trailer);
441                        this.inner_done = true;
442                    }
443                    // Other frame kinds (none exist on this path today)
444                    // are dropped; the compressed stream is the body.
445                    continue;
446                }
447                Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
448                Poll::Ready(None) => {
449                    this.inner_done = true;
450                    continue;
451                }
452                Poll::Pending => return Poll::Pending,
453            }
454        }
455    }
456
457    fn is_end_stream(&self) -> bool {
458        self.inner_done && self.finished && self.pending.is_empty() && self.trailer.is_none()
459    }
460}
461
462impl std::fmt::Debug for CompressedBody {
463    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464        f.debug_struct("CompressedBody")
465            .field("inner_done", &self.inner_done)
466            .field("finished", &self.finished)
467            .field("pending_bytes", &self.pending.len())
468            .finish_non_exhaustive()
469    }
470}