ws_kit/compression.rs
1//! Application-layer payload compression.
2//!
3//! ## Honest layer assessment: why this is NOT `permessage-deflate`
4//!
5//! The right layer for WebSocket compression is the **handshake extension**
6//! (`permessage-deflate`, RFC 7692): it compresses at the frame boundary,
7//! shares context across frames, and is negotiated by the server echoing
8//! `Sec-WebSocket-Extensions` during the upgrade.
9//!
10//! **The underlying stack cannot do that today.** As of the versions this
11//! crate builds against — `tokio-tungstenite` 0.29 (what `axum` 0.8 pulls)
12//! and `tungstenite` 0.29 — `permessage-deflate` is *not implemented*;
13//! tungstenite merely *parses* the `Sec-WebSocket-Extensions` header for
14//! handshake validation and offers no extension hook a library could wire
15//! a negotiation into. Shipping a hand-rolled RFC 7692 extension on top of
16//! a transport that owns the framing would mean re-implementing the
17//! context-takeover state machine against frame internals we do not
18//! control — the wrong layer and a correctness hazard.
19//!
20//! So the `compression` feature provides an **application-layer** codec
21//! instead, and says so plainly:
22//!
23//! - Payloads are DEFLATE-compressed (RFC 1951, raw) via `flate2` with the
24//! pure-Rust `miniz_oxide` backend — the crate's `#![forbid(unsafe_code)]`
25//! posture is preserved end to end.
26//! - Compressed payloads travel inside ordinary **binary frames** with a
27//! 1-byte envelope (see [`FrameCompressor`]). Both endpoints must run
28//! ws-kit and opt in — **browsers cannot use this**: they only speak
29//! `permessage-deflate`. This is for Rust-to-Rust links (ws-kit server ↔
30//! ws-kit client/proxy/worker).
31//! - "Negotiation" is out-of-band configuration (both sides build with the
32//! feature and construct a compressor). For integrators who signal the
33//! capability in a custom handshake header, [`extension_accepted`]
34//! matches the [`EXTENSION_NAME`] token case-insensitively in a
35//! comma-separated header value. When a tungstenite/axum handshake-level
36//! implementation lands upstream, [`CompressionConfig`] is what gets
37//! wired through — the config struct is deliberately transport-shaped.
38//!
39//! ## Security posture
40//!
41//! - **Decompression-bomb guard**: [`FrameCompressor::decompress`] refuses
42//! output larger than [`CompressionConfig::max_size`] (checked *during*
43//! inflation via `Read::take`, so a 10 GiB bomb never materializes in
44//! memory) and refuses identity payloads above the same cap.
45//! - **Fail-closed envelopes**: unknown flag bits are rejected, corrupt
46//! deflate streams error ([`WsError::Compression`]), and a text frame
47//! restored from non-UTF-8 bytes errors rather than lossy-coercing.
48//! - Compressed payloads ride `Frame::Binary` — the crate-wide rule that
49//! binary is never parsed as text/JSON applies to them too.
50//!
51//! # Example
52//!
53//! ```
54//! use ws_kit::codec::Frame;
55//! use ws_kit::compression::{CompressionConfig, FrameCompressor};
56//!
57//! let c = FrameCompressor::new(CompressionConfig::default());
58//! let frame = Frame::from("a very repetitive payload ".repeat(50));
59//! let wire = c.compress_frame(frame.clone()).unwrap();
60//! // Wire is always a Binary frame with the envelope prefix:
61//! assert!(wire.is_binary());
62//! let restored = c.decompress_frame(wire).unwrap();
63//! assert_eq!(restored, frame);
64//! ```
65
66use std::io::{Read, Write};
67
68use flate2::read::DeflateDecoder;
69use flate2::write::DeflateEncoder;
70use flate2::Compression;
71
72use crate::codec::Frame;
73use crate::error::WsError;
74
75/// Capability token for out-of-band negotiation in a custom handshake
76/// header. **Not** `permessage-deflate` — see the module docs for why this
77/// is an application-layer codec.
78pub const EXTENSION_NAME: &str = "x-ws-kit-deflate";
79
80/// Envelope flag: payload is raw-DEFLATE (RFC 1951); unset = identity.
81const FLAG_COMPRESSED: u8 = 0x01;
82/// Envelope flag: original frame was [`Frame::Text`] (restores the kind).
83const FLAG_TEXT: u8 = 0x02;
84/// All defined flag bits; anything else fails closed.
85const FLAG_KNOWN: u8 = FLAG_COMPRESSED | FLAG_TEXT;
86
87/// Compression settings.
88///
89/// Transport-shaped so it can be wired through to a future handshake-level
90/// implementation; today it configures [`FrameCompressor`].
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct CompressionConfig {
93 /// DEFLATE level, 0 (store) – 9 (best). Default 6.
94 pub level: u8,
95 /// Payloads shorter than this are sent as identity inside the envelope
96 /// (deflate overhead beats the savings on tiny payloads). Default 64.
97 pub min_size: usize,
98 /// Decompression-bomb guard: decompressed (or identity) payloads larger
99 /// than this are rejected with [`WsError::PayloadTooLarge`]. Default
100 /// 1 MiB.
101 pub max_size: usize,
102}
103
104impl Default for CompressionConfig {
105 fn default() -> Self {
106 Self {
107 level: 6,
108 min_size: 64,
109 max_size: 1024 * 1024,
110 }
111 }
112}
113
114impl CompressionConfig {
115 /// Default settings.
116 pub fn new() -> Self {
117 Self::default()
118 }
119}
120
121/// Encodes/decodes [`Frame`]s into compressed wire form.
122///
123/// Wire format (inside a `Frame::Binary`, big-endian irrelevant — single
124/// byte header):
125///
126/// ```text
127/// +-------+---------------------------------+
128/// | flags | payload |
129/// +-------+---------------------------------+
130/// bit 0: payload is raw-DEFLATE compressed (else identity)
131/// bit 1: original frame was Text (else Binary)
132/// ```
133///
134/// Cloning is cheap; share one compressor across tasks (`&self` API).
135#[derive(Debug, Clone)]
136pub struct FrameCompressor {
137 config: CompressionConfig,
138}
139
140impl FrameCompressor {
141 /// A compressor with the given settings.
142 pub fn new(config: CompressionConfig) -> Self {
143 Self { config }
144 }
145
146 /// A compressor with [`CompressionConfig::default`] settings.
147 pub fn with_defaults() -> Self {
148 Self::new(CompressionConfig::default())
149 }
150
151 /// Active settings.
152 pub fn config(&self) -> &CompressionConfig {
153 &self.config
154 }
155
156 /// Compress a payload into the envelope form.
157 ///
158 /// Payloads below [`CompressionConfig::min_size`] — or payloads deflate
159 /// cannot shrink (already-compressed data) — are stored as identity;
160 /// the envelope still marks them so the peer decodes either way.
161 pub fn compress(&self, data: &[u8]) -> Result<Vec<u8>, WsError> {
162 let mut flags = 0u8;
163 let body: Vec<u8> = if data.len() >= self.config.min_size {
164 let mut encoder = DeflateEncoder::new(
165 Vec::with_capacity(data.len() / 2),
166 Compression::new(u32::from(self.config.level)),
167 );
168 encoder.write_all(data).map_err(|_| WsError::Compression)?;
169 let compressed = encoder.finish().map_err(|_| WsError::Compression)?;
170 // Only "compress" when it actually helps; incompressible input
171 // would otherwise grow on the wire.
172 if compressed.len() < data.len() {
173 flags |= FLAG_COMPRESSED;
174 compressed
175 } else {
176 data.to_vec()
177 }
178 } else {
179 data.to_vec()
180 };
181
182 let mut out = Vec::with_capacity(body.len() + 1);
183 out.push(flags);
184 out.extend_from_slice(&body);
185 Ok(out)
186 }
187
188 /// Decode an envelope produced by [`FrameCompressor::compress`],
189 /// returning the original payload bytes.
190 ///
191 /// Enforces the decompression-bomb guard during inflation.
192 pub fn decompress(&self, enveloped: &[u8]) -> Result<Vec<u8>, WsError> {
193 let (&flags, body) = enveloped.split_first().ok_or(WsError::Compression)?;
194 if flags & !FLAG_KNOWN != 0 {
195 // Unknown bits = newer/speaking-another-protocol peer: fail
196 // closed instead of misinterpreting bytes.
197 return Err(WsError::Compression);
198 }
199 if flags & FLAG_COMPRESSED == 0 {
200 if body.len() > self.config.max_size {
201 return Err(WsError::PayloadTooLarge);
202 }
203 return Ok(body.to_vec());
204 }
205 let mut decoder = DeflateDecoder::new(body).take(self.config.max_size as u64 + 1);
206 let mut out = Vec::new();
207 decoder
208 .read_to_end(&mut out)
209 .map_err(|_| WsError::Compression)?;
210 if out.len() > self.config.max_size {
211 return Err(WsError::PayloadTooLarge);
212 }
213 Ok(out)
214 }
215
216 /// Compress a [`Frame`] into a wire-ready [`Frame::Binary`] envelope.
217 ///
218 /// `Frame::Binary` payloads pass through as-is bytes; `Frame::Text`
219 /// payloads are compressed from and restored to their exact UTF-8
220 /// bytes — never re-validated through lossy conversion.
221 pub fn compress_frame(&self, frame: Frame) -> Result<Frame, WsError> {
222 let mut flags = 0u8;
223 if frame.is_text() {
224 flags |= FLAG_TEXT;
225 }
226 let payload = self.compress(frame.as_bytes())?;
227 let mut out = Vec::with_capacity(payload.len() + 1);
228 out.push(flags);
229 out.extend_from_slice(&payload);
230 Ok(Frame::Binary(out.into()))
231 }
232
233 /// Restore a [`Frame`] from a [`FrameCompressor::compress_frame`]
234 /// envelope. Text frames come back as [`Frame::Text`] (invalid UTF-8 is
235 /// a hard error — no lossy coercion), binary as [`Frame::Binary`].
236 pub fn decompress_frame(&self, frame: Frame) -> Result<Frame, WsError> {
237 let bytes = frame.into_bytes();
238 let (&flags, body) = bytes.split_first().ok_or(WsError::Compression)?;
239 if flags & !FLAG_KNOWN != 0 {
240 return Err(WsError::Compression);
241 }
242 let payload = self.decompress(body)?;
243 if flags & FLAG_TEXT != 0 {
244 let s = String::from_utf8(payload).map_err(|_| WsError::Compression)?;
245 Ok(Frame::Text(s))
246 } else {
247 Ok(Frame::Binary(payload.into()))
248 }
249 }
250}
251
252/// True when a handshake header value (e.g. the integrator's custom
253/// capability header) offers [`EXTENSION_NAME`].
254///
255/// Matching is case-insensitive, token-exact, comma-separated — the same
256/// shape as `Sec-WebSocket-Extensions`. Absent header → `false`.
257///
258/// ```
259/// use ws_kit::compression::{extension_accepted, EXTENSION_NAME};
260///
261/// assert!(extension_accepted(Some("x-ws-kit-deflate")));
262/// assert!(extension_accepted(Some("permessage-deflate, X-WS-KIT-DEFLATE")));
263/// assert!(!extension_accepted(Some("permessage-deflate")));
264/// assert!(!extension_accepted(None));
265/// ```
266pub fn extension_accepted(header: Option<&str>) -> bool {
267 header.is_some_and(|value| {
268 value
269 .split(',')
270 .any(|token| token.trim().eq_ignore_ascii_case(EXTENSION_NAME))
271 })
272}
273
274/// The value a client/server puts in its capability header to offer
275/// compression. Alias of [`EXTENSION_NAME`] for call-site readability.
276pub fn offer_extension() -> &'static str {
277 EXTENSION_NAME
278}
279
280// Tests exercise failure paths and invariants directly; unwrap/expect,
281// slicing, and panicking asserts are acceptable here — violations
282// surface as test failures, not production panics.
283#[allow(
284 clippy::unwrap_used,
285 clippy::indexing_slicing,
286 clippy::expect_used,
287 clippy::panic
288)]
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use bytes::Bytes;
293
294 fn repetitive(n: usize) -> Vec<u8> {
295 // Highly compressible: 64-byte ramp repeated.
296 let pattern: Vec<u8> = (0..=63u8).collect();
297 pattern.repeat(n / 64 + 1).into_iter().take(n).collect()
298 }
299
300 #[test]
301 fn roundtrip_text_and_binary() {
302 let c = FrameCompressor::with_defaults();
303
304 let text = Frame::from("chat line ".repeat(40));
305 let wire = c.compress_frame(text.clone()).unwrap();
306 assert!(wire.is_binary());
307 let restored = c.decompress_frame(wire).unwrap();
308 assert_eq!(restored, text);
309 assert!(restored.is_text());
310
311 let bin = Frame::from(repetitive(4096));
312 let wire = c.compress_frame(bin.clone()).unwrap();
313 let restored = c.decompress_frame(wire).unwrap();
314 assert_eq!(restored, bin);
315 assert!(restored.is_binary());
316 }
317
318 #[test]
319 fn small_payloads_stay_identity() {
320 let c = FrameCompressor::with_defaults(); // min_size 64
321 let small = b"tiny".to_vec();
322 let env = c.compress(&small).unwrap();
323 assert_eq!(env[0] & FLAG_COMPRESSED, 0, "below min_size: identity");
324 assert_eq!(&env[1..], &small[..]);
325 assert_eq!(c.decompress(&env).unwrap(), small);
326 }
327
328 #[test]
329 fn repetitive_payload_compresses_smaller() {
330 let c = FrameCompressor::with_defaults();
331 let data = repetitive(4096);
332 let env = c.compress(&data).unwrap();
333 assert_eq!(env[0] & FLAG_COMPRESSED, FLAG_COMPRESSED);
334 assert!(
335 env.len() < data.len() / 4,
336 "repetitive data should crush: env={} raw={}",
337 env.len(),
338 data.len()
339 );
340 assert_eq!(c.decompress(&env).unwrap(), data);
341 }
342
343 #[test]
344 fn incompressible_payload_falls_back_to_identity() {
345 let c = FrameCompressor::with_defaults();
346 // 4 KiB of well-shuffled pseudo-random bytes: deflate cannot shrink.
347 let mut data = vec![0u8; 4096];
348 let mut x: u64 = 0x9E3779B97F4A7C15;
349 for slot in data.iter_mut() {
350 x ^= x << 13;
351 x ^= x >> 7;
352 x ^= x << 17;
353 *slot = (x >> 32) as u8;
354 }
355 let env = c.compress(&data).unwrap();
356 assert_eq!(env[0] & FLAG_COMPRESSED, 0, "must not grow on the wire");
357 assert_eq!(c.decompress(&env).unwrap(), data);
358 }
359
360 #[test]
361 fn empty_payload_roundtrips() {
362 let c = FrameCompressor::with_defaults();
363 let env = c.compress(&[]).unwrap();
364 assert!(c.decompress(&env).unwrap().is_empty());
365 let wire = c.compress_frame(Frame::from("")).unwrap();
366 assert_eq!(c.decompress_frame(wire).unwrap(), Frame::from(""));
367 }
368
369 #[test]
370 fn bomb_guard_caps_decompression() {
371 let small = FrameCompressor::new(CompressionConfig {
372 level: 6,
373 min_size: 64,
374 max_size: 1024,
375 });
376 let bomb_input = vec![0u8; 1 << 20]; // 1 MiB of zeros
377 let env = small.compress(&bomb_input).unwrap();
378 // Compresses fine; decompressing against the 1 KiB cap is refused
379 // *after* taking at most cap+1 bytes from the stream.
380 assert_eq!(
381 small.decompress(&env).unwrap_err(),
382 WsError::PayloadTooLarge
383 );
384
385 // Identity payloads over the cap are refused too.
386 let big_identity = small.compress(&vec![7u8; 2048]).unwrap();
387 assert_eq!(
388 small.decompress(&big_identity).unwrap_err(),
389 WsError::PayloadTooLarge
390 );
391 }
392
393 #[test]
394 fn unknown_flags_fail_closed() {
395 let c = FrameCompressor::with_defaults();
396 let err = c.decompress(&[0b1000_0000, 1, 2, 3]).unwrap_err();
397 assert_eq!(err, WsError::Compression);
398 let err = c
399 .decompress_frame(Frame::from(vec![0b0000_0100u8, 1]))
400 .unwrap_err();
401 assert_eq!(err, WsError::Compression);
402 // Empty envelope: no flags byte at all.
403 assert_eq!(c.decompress(&[]).unwrap_err(), WsError::Compression);
404 assert_eq!(
405 c.decompress_frame(Frame::from(Bytes::new())).unwrap_err(),
406 WsError::Compression
407 );
408 }
409
410 #[test]
411 fn identity_envelope_roundtrips_through_decompress() {
412 let c = FrameCompressor::with_defaults();
413 // Flag byte without the COMPRESSED bit: body is passed through
414 // as-is (subject to max_size).
415 let env = [0b0000_0000, b'o', b'k'];
416 assert_eq!(c.decompress(&env).unwrap(), b"ok".to_vec());
417 // Same path, but oversized: rejected before any allocation.
418 let big = vec![0u8; c.config.max_size + 1];
419 let mut oversized = vec![0b0000_0000];
420 oversized.extend_from_slice(&big);
421 assert_eq!(
422 c.decompress(&oversized).unwrap_err(),
423 WsError::PayloadTooLarge
424 );
425 }
426
427 #[test]
428 fn corrupt_deflate_stream_is_an_error() {
429 let c = FrameCompressor::with_defaults();
430 let garbage_env = [FLAG_COMPRESSED, 0xDE, 0xAD, 0xBE, 0xEF];
431 assert_eq!(
432 c.decompress(&garbage_env).unwrap_err(),
433 WsError::Compression
434 );
435 }
436
437 #[test]
438 fn text_frame_with_invalid_utf8_flag_rejected() {
439 let c = FrameCompressor::with_defaults();
440 // Hand-crafted envelope claiming Text with non-UTF-8 identity bytes.
441 let evil = [FLAG_TEXT, 0xFF, 0xFE, 0xFF];
442 assert_eq!(
443 c.decompress_frame(Frame::from(evil.to_vec())).unwrap_err(),
444 WsError::Compression
445 );
446 }
447
448 #[test]
449 fn multibyte_utf8_text_survives_exactly() {
450 let c = FrameCompressor::with_defaults();
451 let text = Frame::from("héllo wörld — ünïcode 🌐 ".repeat(20));
452 let restored = c
453 .decompress_frame(c.compress_frame(text.clone()).unwrap())
454 .unwrap();
455 assert_eq!(restored, text);
456 }
457
458 #[test]
459 fn negotiation_tokens() {
460 assert!(extension_accepted(Some(EXTENSION_NAME)));
461 assert!(extension_accepted(Some("X-WS-KIT-DEFLATE")));
462 assert!(extension_accepted(Some(
463 " permessage-deflate, x-ws-kit-deflate "
464 )));
465 assert!(!extension_accepted(Some("permessage-deflate")));
466 assert!(!extension_accepted(Some("x-ws-kit-deflate2")));
467 assert!(!extension_accepted(Some("")));
468 assert!(!extension_accepted(None));
469 assert_eq!(offer_extension(), "x-ws-kit-deflate");
470 }
471
472 #[test]
473 fn config_level_bounds_and_accessors() {
474 let cfg = CompressionConfig {
475 level: 9,
476 ..CompressionConfig::default()
477 };
478 let c = FrameCompressor::new(cfg.clone());
479 assert_eq!(c.config(), &cfg);
480 assert_eq!(CompressionConfig::new(), CompressionConfig::default());
481 }
482}