Skip to main content

phantom_protocol/transport/
compression.rs

1//! Production Adaptive Compression
2//!
3//! Real compression using LZ4 (lz4_flex) and Zstd:
4//! - Performance tier: LZ4 frame compression (~4 GB/s decompress, ~2 GB/s compress)
5//! - Standard tier: Zstd level 1 (~500 MB/s compress, ratio ~2.5x)
6//! - Constrained tier: off (CPU > bandwidth)
7//!
8//! Auto-probe: if the compression ratio stays below `probe_threshold` after the
9//! first `probe_samples` packets, compression self-disables (CPU not worth it).
10//! Minimum payload worth compressing = 64 bytes (below that the overhead dominates).
11
12/// Compression algorithm
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[repr(u8)]
15pub enum CompressionAlgo {
16    /// No compression (Constrained tier, or incompressible data)
17    None = 0,
18    /// LZ4 block compression (Performance tier) — lz4_flex
19    Lz4 = 1,
20    /// Zstd level 1 (Standard tier)
21    Zstd1 = 2,
22}
23
24impl CompressionAlgo {
25    pub fn from_byte(b: u8) -> Option<Self> {
26        match b {
27            0 => Some(Self::None),
28            1 => Some(Self::Lz4),
29            2 => Some(Self::Zstd1),
30            _ => None,
31        }
32    }
33
34    pub fn to_byte(self) -> u8 {
35        self as u8
36    }
37}
38
39/// Minimum payload size worth compressing
40const MIN_COMPRESS_SIZE: usize = 64;
41
42/// Default upper bound on decompressed output (16 MiB). A conservative ceiling on
43/// the largest plaintext a single frame may expand to; the established-session
44/// `TcpSessionTransport` frame cap is tighter still (4 MiB), so this never gates
45/// legitimate traffic. Decompression is an asymmetric operation: a few KiB of
46/// crafted LZ4/Zstd can expand to gigabytes (a "decompression bomb").
47/// [`AdaptiveCompressor::decompress`] enforces this cap unconditionally; callers
48/// that need a different bound use [`AdaptiveCompressor::decompress_with_limit`].
49pub const MAX_DECOMPRESSED_LEN: usize = 16 * 1024 * 1024;
50
51/// Compression statistics for auto-probe
52#[derive(Debug, Clone)]
53pub struct CompressionStats {
54    /// Total uncompressed bytes fed in
55    pub total_input: u64,
56    /// Total compressed bytes produced
57    pub total_output: u64,
58    /// Number of compression samples
59    pub samples: u32,
60}
61
62impl Default for CompressionStats {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl CompressionStats {
69    pub fn new() -> Self {
70        Self {
71            total_input: 0,
72            total_output: 0,
73            samples: 0,
74        }
75    }
76
77    /// Current compression ratio (input / output, higher = better, 1.0 = no benefit)
78    pub fn ratio(&self) -> f64 {
79        if self.total_output == 0 {
80            1.0
81        } else {
82            self.total_input as f64 / self.total_output as f64
83        }
84    }
85}
86
87/// Adaptive compressor with auto-probe.
88///
89/// Frame format for compressed data: `[algo:1][data:N]`
90/// If compression is skipped, raw data is returned with algo=0.
91pub struct AdaptiveCompressor {
92    algo: CompressionAlgo,
93    stats: CompressionStats,
94    /// Auto-disable if ratio < threshold after probe_samples
95    probe_threshold: f64,
96    probe_samples: u32,
97    /// Whether auto-probe disabled compression
98    disabled_by_probe: bool,
99    /// Zstd compression level (1-22, default 1 for speed)
100    zstd_level: i32,
101}
102
103impl AdaptiveCompressor {
104    /// Create with specified algorithm
105    pub fn new(algo: CompressionAlgo) -> Self {
106        Self {
107            algo,
108            stats: CompressionStats::new(),
109            probe_threshold: 1.05, // 5% savings minimum to keep compressing
110            probe_samples: 32,
111            disabled_by_probe: false,
112            zstd_level: 1,
113        }
114    }
115
116    /// No compression
117    pub fn none() -> Self {
118        Self::new(CompressionAlgo::None)
119    }
120
121    /// LZ4 for Performance tier
122    pub fn lz4() -> Self {
123        Self::new(CompressionAlgo::Lz4)
124    }
125
126    /// Zstd level 1 for Standard tier
127    pub fn zstd(level: i32) -> Self {
128        let mut c = Self::new(CompressionAlgo::Zstd1);
129        c.zstd_level = level.clamp(1, 22);
130        c
131    }
132
133    /// Currently active algorithm (may be None if auto-probe disabled it)
134    pub fn algorithm(&self) -> CompressionAlgo {
135        if self.disabled_by_probe {
136            CompressionAlgo::None
137        } else {
138            self.algo
139        }
140    }
141
142    /// Whether compression is effectively active
143    pub fn is_active(&self) -> bool {
144        self.algorithm() != CompressionAlgo::None
145    }
146
147    /// Compress data. Returns (algo_byte, compressed_data).
148    ///
149    /// If compression is off, skipped for small data, or output >= input:
150    /// returns (0, original_data_clone).
151    pub fn compress(&mut self, data: &[u8]) -> (u8, Vec<u8>) {
152        let active_algo = self.algorithm();
153
154        // Skip for None or small payloads
155        if active_algo == CompressionAlgo::None || data.len() < MIN_COMPRESS_SIZE {
156            return (CompressionAlgo::None.to_byte(), data.to_vec());
157        }
158
159        let compressed = match active_algo {
160            CompressionAlgo::Lz4 => Self::compress_lz4(data),
161            #[cfg(feature = "compression-zstd")]
162            CompressionAlgo::Zstd1 => Self::compress_zstd(data, self.zstd_level),
163            // Without `compression-zstd`, fall back to LZ4 transparently.
164            #[cfg(not(feature = "compression-zstd"))]
165            CompressionAlgo::Zstd1 => Self::compress_lz4(data),
166            // The `None` arm is already eliminated by the early-return at the
167            // top of this function, but matching defensively (rather than
168            // calling `unreachable!()`) means malformed enum extensions
169            // cannot become a panic source.
170            CompressionAlgo::None => return (CompressionAlgo::None.to_byte(), data.to_vec()),
171        };
172
173        // Update stats
174        self.stats.total_input += data.len() as u64;
175        self.stats.total_output += compressed.len() as u64;
176        self.stats.samples += 1;
177
178        // Auto-probe check
179        if self.stats.samples == self.probe_samples && self.stats.ratio() < self.probe_threshold {
180            self.disabled_by_probe = true;
181            return (CompressionAlgo::None.to_byte(), data.to_vec());
182        }
183
184        // Only return compressed if it actually saves space
185        if compressed.len() < data.len() {
186            (active_algo.to_byte(), compressed)
187        } else {
188            (CompressionAlgo::None.to_byte(), data.to_vec())
189        }
190    }
191
192    /// Decompress data given the algorithm byte, bounding the output to
193    /// [`MAX_DECOMPRESSED_LEN`]. An input that would expand beyond the cap is
194    /// rejected with [`CompressionError::OutputTooLarge`] rather than allocated.
195    pub fn decompress(algo_byte: u8, data: &[u8]) -> Result<Vec<u8>, CompressionError> {
196        Self::decompress_with_limit(algo_byte, data, MAX_DECOMPRESSED_LEN)
197    }
198
199    /// Decompress with a caller-chosen output cap. `max_output` is the largest
200    /// plaintext the caller is willing to materialise; the decoders refuse to
201    /// allocate or emit beyond it, so a decompression bomb cannot exhaust memory.
202    pub fn decompress_with_limit(
203        algo_byte: u8,
204        data: &[u8],
205        max_output: usize,
206    ) -> Result<Vec<u8>, CompressionError> {
207        let algo = CompressionAlgo::from_byte(algo_byte)
208            .ok_or(CompressionError::UnknownAlgorithm(algo_byte))?;
209        match algo {
210            CompressionAlgo::None => {
211                if data.len() > max_output {
212                    return Err(CompressionError::OutputTooLarge { limit: max_output });
213                }
214                Ok(data.to_vec())
215            }
216            CompressionAlgo::Lz4 => Self::decompress_lz4(data, max_output),
217            #[cfg(feature = "compression-zstd")]
218            CompressionAlgo::Zstd1 => Self::decompress_zstd(data, max_output),
219            // Without `compression-zstd`, we cannot decode Zstd payloads.
220            // Surface the failure as a typed error rather than fabricating
221            // garbage plaintext.
222            #[cfg(not(feature = "compression-zstd"))]
223            CompressionAlgo::Zstd1 => Err(CompressionError::DecompressFailed(
224                "Zstd disabled in this build (compression-zstd feature off)".into(),
225            )),
226        }
227    }
228
229    /// Compression stats
230    pub fn stats(&self) -> &CompressionStats {
231        &self.stats
232    }
233
234    /// Reset auto-probe state
235    pub fn reset_probe(&mut self) {
236        self.stats = CompressionStats::new();
237        self.disabled_by_probe = false;
238    }
239
240    // ── LZ4 (lz4_flex block mode) ────────────────────────────────────────
241
242    fn compress_lz4(data: &[u8]) -> Vec<u8> {
243        lz4_flex::compress_prepend_size(data)
244    }
245
246    fn decompress_lz4(data: &[u8], max_output: usize) -> Result<Vec<u8>, CompressionError> {
247        // `compress_prepend_size` writes the uncompressed length as a
248        // little-endian u32 prefix and `decompress_size_prepended` trusts it to
249        // pre-allocate the whole output. Reject an oversized declared length
250        // BEFORE that allocation happens — otherwise 8 bytes on the wire could
251        // force a multi-GiB `Vec` reservation (decompression-bomb guard).
252        if data.len() >= 4 {
253            let declared = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
254            if declared > max_output {
255                return Err(CompressionError::OutputTooLarge { limit: max_output });
256            }
257        }
258        lz4_flex::decompress_size_prepended(data)
259            .map_err(|e| CompressionError::DecompressFailed(format!("LZ4: {}", e)))
260    }
261
262    // ── Zstd ─────────────────────────────────────────────────────────────
263
264    #[cfg(feature = "compression-zstd")]
265    fn compress_zstd(data: &[u8], level: i32) -> Vec<u8> {
266        zstd::encode_all(data, level).unwrap_or_else(|_| data.to_vec())
267    }
268
269    #[cfg(feature = "compression-zstd")]
270    fn decompress_zstd(data: &[u8], max_output: usize) -> Result<Vec<u8>, CompressionError> {
271        use std::io::Read;
272        // Zstd frames may declare no content size, so we cannot pre-check a
273        // header the way LZ4 lets us. Instead, stream-decode through a reader
274        // capped at `max_output + 1`: if the decoder produces that many bytes
275        // the frame exceeds the cap and we fail closed before the `Vec` can
276        // grow without bound.
277        let mut decoder = zstd::stream::read::Decoder::new(data)
278            .map_err(|e| CompressionError::DecompressFailed(format!("Zstd: {}", e)))?;
279        let mut out = Vec::new();
280        let cap_plus_one = (max_output as u64).saturating_add(1);
281        decoder
282            .by_ref()
283            .take(cap_plus_one)
284            .read_to_end(&mut out)
285            .map_err(|e| CompressionError::DecompressFailed(format!("Zstd: {}", e)))?;
286        if out.len() > max_output {
287            return Err(CompressionError::OutputTooLarge { limit: max_output });
288        }
289        Ok(out)
290    }
291}
292
293/// Compression errors
294#[derive(Debug)]
295pub enum CompressionError {
296    UnknownAlgorithm(u8),
297    DecompressFailed(String),
298    /// The decompressed output reached the configured cap. Guards against a
299    /// decompression bomb (a tiny ciphertext that expands to gigabytes).
300    OutputTooLarge {
301        limit: usize,
302    },
303}
304
305impl std::fmt::Display for CompressionError {
306    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        match self {
308            Self::UnknownAlgorithm(b) => write!(f, "Unknown compression algorithm: 0x{:02x}", b),
309            Self::DecompressFailed(msg) => write!(f, "Decompression failed: {}", msg),
310            Self::OutputTooLarge { limit } => {
311                write!(f, "Decompressed output exceeds the {}-byte cap", limit)
312            }
313        }
314    }
315}
316
317impl std::error::Error for CompressionError {}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn no_compression_passthrough() {
325        let mut c = AdaptiveCompressor::none();
326        let data = b"Hello, world!";
327        let (algo, result) = c.compress(data);
328        assert_eq!(algo, 0);
329        assert_eq!(result, data);
330    }
331
332    #[test]
333    fn lz4_round_trip() {
334        let mut c = AdaptiveCompressor::lz4();
335        // Highly compressible data (repeating pattern)
336        let data = vec![0u8; 4096];
337        let (algo, compressed) = c.compress(&data);
338        assert_eq!(algo, CompressionAlgo::Lz4.to_byte());
339        assert!(compressed.len() < data.len(), "LZ4 should compress zeros");
340        let decompressed = AdaptiveCompressor::decompress(algo, &compressed).unwrap();
341        assert_eq!(decompressed, data);
342        eprintln!(
343            "LZ4: {} → {} bytes (ratio {:.2}x)",
344            data.len(),
345            compressed.len(),
346            data.len() as f64 / compressed.len() as f64
347        );
348    }
349
350    #[cfg(feature = "compression-zstd")]
351    #[test]
352    fn zstd_round_trip() {
353        let mut c = AdaptiveCompressor::zstd(1);
354        // Compressible text-like data
355        let data: Vec<u8> = (0..2048)
356            .map(|i| b"The quick brown fox jumps over the lazy dog. "[i % 45])
357            .collect();
358        let (algo, compressed) = c.compress(&data);
359        assert_eq!(algo, CompressionAlgo::Zstd1.to_byte());
360        assert!(compressed.len() < data.len(), "Zstd should compress text");
361        let decompressed = AdaptiveCompressor::decompress(algo, &compressed).unwrap();
362        assert_eq!(decompressed, data);
363        eprintln!(
364            "Zstd: {} → {} bytes (ratio {:.2}x)",
365            data.len(),
366            compressed.len(),
367            data.len() as f64 / compressed.len() as f64
368        );
369    }
370
371    #[test]
372    fn skip_tiny_data() {
373        let mut c = AdaptiveCompressor::lz4();
374        let data = b"tiny"; // < 64 bytes → skip
375        let (algo, result) = c.compress(data);
376        assert_eq!(algo, 0);
377        assert_eq!(result, data);
378    }
379
380    #[test]
381    fn auto_probe_disable_on_random() {
382        let mut c = AdaptiveCompressor::lz4();
383        c.probe_samples = 8;
384        c.probe_threshold = 1.5; // Very high threshold — random data won't hit this
385
386        // Compress pseudo-random (incompressible) data
387        for i in 0u32..10 {
388            let data: Vec<u8> = (0..256)
389                .map(|j| ((i.wrapping_mul(2654435761).wrapping_add(j)) & 0xFF) as u8)
390                .collect();
391            let _ = c.compress(&data);
392        }
393        // After probe_samples, should be disabled
394        assert!(c.disabled_by_probe);
395        assert_eq!(c.algorithm(), CompressionAlgo::None);
396    }
397
398    #[test]
399    fn lz4_decompress_rejects_oversized_declared_size() {
400        // Craft an LZ4 size-prepended frame whose declared output length is
401        // absurd (~3 GiB). The guard must reject it from the LE size prefix
402        // alone, before `decompress_size_prepended` tries to allocate it.
403        let mut bomb = Vec::new();
404        bomb.extend_from_slice(&u32::to_le_bytes(0xC000_0000)); // declared ≈ 3 GiB
405        bomb.extend_from_slice(&[0u8; 16]); // arbitrary (never reached)
406        let err = AdaptiveCompressor::decompress(CompressionAlgo::Lz4.to_byte(), &bomb)
407            .expect_err("oversized declared size must be rejected");
408        assert!(
409            matches!(err, CompressionError::OutputTooLarge { .. }),
410            "expected OutputTooLarge, got {err:?}"
411        );
412    }
413
414    #[test]
415    fn lz4_decompress_with_limit_rejects_overlimit_output() {
416        // A genuinely compressible 4 KiB payload decodes fine under a generous
417        // cap, but a tight 100-byte cap rejects it via the declared-size guard.
418        let mut c = AdaptiveCompressor::lz4();
419        let data = vec![7u8; 4096];
420        let (algo, compressed) = c.compress(&data);
421        assert!(AdaptiveCompressor::decompress(algo, &compressed).is_ok());
422        let err = AdaptiveCompressor::decompress_with_limit(algo, &compressed, 100)
423            .expect_err("4 KiB output must exceed a 100-byte cap");
424        assert!(matches!(
425            err,
426            CompressionError::OutputTooLarge { limit: 100 }
427        ));
428    }
429
430    #[cfg(feature = "compression-zstd")]
431    #[test]
432    fn zstd_decompress_with_limit_rejects_overlimit_output() {
433        // Zstd carries no peekable content-size guarantee, so the cap is
434        // enforced by the bounded streaming read. A 4 KiB payload under a
435        // 100-byte cap must fail closed mid-stream.
436        let mut c = AdaptiveCompressor::zstd(1);
437        let data = vec![9u8; 4096];
438        let (algo, compressed) = c.compress(&data);
439        assert_eq!(algo, CompressionAlgo::Zstd1.to_byte());
440        assert!(AdaptiveCompressor::decompress(algo, &compressed).is_ok());
441        let err = AdaptiveCompressor::decompress_with_limit(algo, &compressed, 100)
442            .expect_err("4 KiB output must exceed a 100-byte cap");
443        assert!(matches!(
444            err,
445            CompressionError::OutputTooLarge { limit: 100 }
446        ));
447    }
448
449    #[test]
450    fn lz4_throughput() {
451        use std::time::Instant;
452
453        let data = vec![42u8; 64 * 1024]; // 64KB of compressible data
454        let iters = 10_000;
455
456        // Compress throughput
457        let start = Instant::now();
458        for _ in 0..iters {
459            let c = lz4_flex::compress_prepend_size(&data);
460            std::hint::black_box(c);
461        }
462        let elapsed = start.elapsed();
463        let tput = (data.len() * iters) as f64 / 1_048_576.0 / elapsed.as_secs_f64();
464        eprintln!("LZ4 compress:   {:.0} MiB/s (64KB payload)", tput);
465
466        // Decompress throughput
467        let compressed = lz4_flex::compress_prepend_size(&data);
468        let start = Instant::now();
469        for _ in 0..iters {
470            let d = lz4_flex::decompress_size_prepended(&compressed).unwrap();
471            std::hint::black_box(d);
472        }
473        let elapsed = start.elapsed();
474        let tput = (data.len() * iters) as f64 / 1_048_576.0 / elapsed.as_secs_f64();
475        eprintln!("LZ4 decompress: {:.0} MiB/s (64KB payload)", tput);
476    }
477
478    #[cfg(feature = "compression-zstd")]
479    #[test]
480    fn zstd_throughput() {
481        use std::time::Instant;
482
483        let data = vec![42u8; 64 * 1024]; // 64KB
484        let iters = 5_000;
485
486        let start = Instant::now();
487        for _ in 0..iters {
488            let c = zstd::encode_all(&data[..], 1).unwrap();
489            std::hint::black_box(c);
490        }
491        let elapsed = start.elapsed();
492        let tput = (data.len() * iters) as f64 / 1_048_576.0 / elapsed.as_secs_f64();
493        eprintln!("Zstd-1 compress:   {:.0} MiB/s (64KB payload)", tput);
494
495        let compressed = zstd::encode_all(&data[..], 1).unwrap();
496        let start = Instant::now();
497        for _ in 0..iters {
498            let d = zstd::decode_all(&compressed[..]).unwrap();
499            std::hint::black_box(d);
500        }
501        let elapsed = start.elapsed();
502        let tput = (data.len() * iters) as f64 / 1_048_576.0 / elapsed.as_secs_f64();
503        eprintln!("Zstd-1 decompress: {:.0} MiB/s (64KB payload)", tput);
504    }
505
506    #[test]
507    fn decompress_unknown_algo_fails() {
508        let result = AdaptiveCompressor::decompress(0xFF, &[1, 2, 3]);
509        assert!(result.is_err());
510    }
511}