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}