phantom_protocol/transport/
compression.rs1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[repr(u8)]
15pub enum CompressionAlgo {
16 None = 0,
18 Lz4 = 1,
20 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
39const MIN_COMPRESS_SIZE: usize = 64;
41
42pub const MAX_DECOMPRESSED_LEN: usize = 16 * 1024 * 1024;
50
51#[derive(Debug, Clone)]
53pub struct CompressionStats {
54 pub total_input: u64,
56 pub total_output: u64,
58 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 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
87pub struct AdaptiveCompressor {
92 algo: CompressionAlgo,
93 stats: CompressionStats,
94 probe_threshold: f64,
96 probe_samples: u32,
97 disabled_by_probe: bool,
99 zstd_level: i32,
101}
102
103impl AdaptiveCompressor {
104 pub fn new(algo: CompressionAlgo) -> Self {
106 Self {
107 algo,
108 stats: CompressionStats::new(),
109 probe_threshold: 1.05, probe_samples: 32,
111 disabled_by_probe: false,
112 zstd_level: 1,
113 }
114 }
115
116 pub fn none() -> Self {
118 Self::new(CompressionAlgo::None)
119 }
120
121 pub fn lz4() -> Self {
123 Self::new(CompressionAlgo::Lz4)
124 }
125
126 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 pub fn algorithm(&self) -> CompressionAlgo {
135 if self.disabled_by_probe {
136 CompressionAlgo::None
137 } else {
138 self.algo
139 }
140 }
141
142 pub fn is_active(&self) -> bool {
144 self.algorithm() != CompressionAlgo::None
145 }
146
147 pub fn compress(&mut self, data: &[u8]) -> (u8, Vec<u8>) {
152 let active_algo = self.algorithm();
153
154 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 #[cfg(not(feature = "compression-zstd"))]
165 CompressionAlgo::Zstd1 => Self::compress_lz4(data),
166 CompressionAlgo::None => return (CompressionAlgo::None.to_byte(), data.to_vec()),
171 };
172
173 self.stats.total_input += data.len() as u64;
175 self.stats.total_output += compressed.len() as u64;
176 self.stats.samples += 1;
177
178 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 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 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 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 #[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 pub fn stats(&self) -> &CompressionStats {
231 &self.stats
232 }
233
234 pub fn reset_probe(&mut self) {
236 self.stats = CompressionStats::new();
237 self.disabled_by_probe = false;
238 }
239
240 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 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 #[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 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#[derive(Debug)]
295pub enum CompressionError {
296 UnknownAlgorithm(u8),
297 DecompressFailed(String),
298 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 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 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"; 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; 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 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 let mut bomb = Vec::new();
404 bomb.extend_from_slice(&u32::to_le_bytes(0xC000_0000)); bomb.extend_from_slice(&[0u8; 16]); 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 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 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]; let iters = 10_000;
455
456 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 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]; 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}