Skip to main content

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}