Skip to main content

voided_core/compression/
mod.rs

1//! Compression module providing Brotli and Gzip compression.
2
3use crate::{Error, Result, MAGIC_COMPRESSED};
4use alloc::vec::Vec;
5use serde::{Deserialize, Serialize};
6
7/// Maximum output produced by one in-memory decompression operation.
8pub const MAX_DECOMPRESSED_SIZE: usize = 512 * 1024 * 1024;
9
10/// Maximum expansion accepted by the default in-memory decompressor.
11pub const MAX_COMPRESSION_RATIO: usize = 256;
12
13/// Supported compression algorithms
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[repr(u8)]
16pub enum CompressionAlgorithm {
17    /// No compression
18    None = 0x00,
19    /// Gzip compression
20    Gzip = 0x01,
21    /// Brotli compression
22    Brotli = 0x02,
23}
24
25impl CompressionAlgorithm {
26    /// Get algorithm from byte identifier
27    pub fn from_byte(byte: u8) -> Result<Self> {
28        match byte {
29            0x00 => Ok(CompressionAlgorithm::None),
30            0x01 => Ok(CompressionAlgorithm::Gzip),
31            0x02 => Ok(CompressionAlgorithm::Brotli),
32            _ => Err(Error::UnsupportedAlgorithm(byte)),
33        }
34    }
35
36    /// Get algorithm name as string
37    pub fn name(&self) -> &'static str {
38        match self {
39            CompressionAlgorithm::None => "none",
40            CompressionAlgorithm::Gzip => "gzip",
41            CompressionAlgorithm::Brotli => "brotli",
42        }
43    }
44
45    /// Parse from string name
46    pub fn from_name(name: &str) -> Result<Self> {
47        match name.to_lowercase().as_str() {
48            "none" => Ok(CompressionAlgorithm::None),
49            "gzip" => Ok(CompressionAlgorithm::Gzip),
50            "brotli" => Ok(CompressionAlgorithm::Brotli),
51            _ => Err(Error::UnsupportedAlgorithm(0)),
52        }
53    }
54}
55
56/// Result of a compression operation
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CompressionResult {
59    /// Compressed data
60    pub compressed: Vec<u8>,
61    /// Algorithm used
62    pub algorithm: CompressionAlgorithm,
63    /// Original size in bytes
64    pub original_size: usize,
65    /// Compressed size in bytes
66    pub compressed_size: usize,
67    /// Compression ratio (compressed / original)
68    pub compression_ratio: f64,
69}
70
71/// Compression options
72#[derive(Debug, Clone)]
73pub struct CompressionOptions {
74    /// Preferred algorithm (auto selects best)
75    pub algorithm: CompressionAlgorithm,
76    /// Minimum size threshold for compression (skip for smaller data)
77    pub min_size_threshold: usize,
78    /// Compression level (1-9 for gzip, 1-11 for brotli)
79    pub level: u32,
80}
81
82impl Default for CompressionOptions {
83    fn default() -> Self {
84        Self {
85            algorithm: CompressionAlgorithm::Brotli,
86            min_size_threshold: 100,
87            level: 6,
88        }
89    }
90}
91
92/// Compress data using the specified algorithm
93pub fn compress(data: &[u8], options: Option<CompressionOptions>) -> Result<CompressionResult> {
94    let opts = options.unwrap_or_default();
95    validate_compression_level(opts.algorithm, opts.level)?;
96    let original_size = data.len();
97
98    // Skip compression for small data
99    if original_size < opts.min_size_threshold {
100        return Ok(CompressionResult {
101            compressed: data.to_vec(),
102            algorithm: CompressionAlgorithm::None,
103            original_size,
104            compressed_size: original_size,
105            compression_ratio: 1.0,
106        });
107    }
108
109    // Skip if explicitly set to none
110    if opts.algorithm == CompressionAlgorithm::None {
111        return Ok(CompressionResult {
112            compressed: data.to_vec(),
113            algorithm: CompressionAlgorithm::None,
114            original_size,
115            compressed_size: original_size,
116            compression_ratio: 1.0,
117        });
118    }
119
120    let (compressed, algorithm) = match opts.algorithm {
121        CompressionAlgorithm::Brotli => compress_brotli(data, opts.level)?,
122        CompressionAlgorithm::Gzip => compress_gzip(data, opts.level)?,
123        CompressionAlgorithm::None => (data.to_vec(), CompressionAlgorithm::None),
124    };
125
126    let compressed_size = compressed.len();
127    let compression_ratio = compressed_size as f64 / original_size as f64;
128
129    // Only use compression if it saves at least 10% and remains within the
130    // expansion policy enforced by the matching decompressor.
131    let within_expansion_policy = compressed_size > 0
132        && original_size <= compressed_size.saturating_mul(MAX_COMPRESSION_RATIO);
133    if compression_ratio < 0.9 && within_expansion_policy {
134        Ok(CompressionResult {
135            compressed,
136            algorithm,
137            original_size,
138            compressed_size,
139            compression_ratio,
140        })
141    } else {
142        Ok(CompressionResult {
143            compressed: data.to_vec(),
144            algorithm: CompressionAlgorithm::None,
145            original_size,
146            compressed_size: original_size,
147            compression_ratio: 1.0,
148        })
149    }
150}
151
152fn validate_compression_level(algorithm: CompressionAlgorithm, level: u32) -> Result<()> {
153    let valid = match algorithm {
154        CompressionAlgorithm::None => level == 0 || level == CompressionOptions::default().level,
155        CompressionAlgorithm::Gzip => level <= 9,
156        CompressionAlgorithm::Brotli => level <= 11,
157    };
158    if valid {
159        Ok(())
160    } else {
161        Err(Error::InvalidConfiguration(format!(
162            "invalid compression level {level} for {}",
163            algorithm.name()
164        )))
165    }
166}
167
168/// Decompress data using the specified algorithm
169pub fn decompress(data: &[u8], algorithm: CompressionAlgorithm) -> Result<Vec<u8>> {
170    decompress_with_limits(
171        data,
172        algorithm,
173        MAX_DECOMPRESSED_SIZE,
174        MAX_COMPRESSION_RATIO,
175    )
176}
177
178/// Decompress with explicit output and expansion bounds.
179pub fn decompress_with_limits(
180    data: &[u8],
181    algorithm: CompressionAlgorithm,
182    max_output_size: usize,
183    max_ratio: usize,
184) -> Result<Vec<u8>> {
185    if max_ratio == 0 {
186        return Err(Error::InvalidConfiguration(
187            "decompression ratio limit must be greater than zero".to_string(),
188        ));
189    }
190
191    let ratio_limit = data.len().saturating_mul(max_ratio);
192    let effective_limit = max_output_size.min(ratio_limit);
193    match algorithm {
194        CompressionAlgorithm::None => {
195            if data.len() > max_output_size {
196                return Err(Error::PayloadTooLarge {
197                    size: data.len(),
198                    limit: max_output_size,
199                });
200            }
201            Ok(data.to_vec())
202        }
203        CompressionAlgorithm::Gzip => decompress_gzip(data, effective_limit),
204        CompressionAlgorithm::Brotli => decompress_brotli(data, effective_limit),
205    }
206}
207
208/// Decompress into an authenticated expected size while retaining global and
209/// ratio bounds. The expected size is checked before any output allocation.
210pub fn decompress_exact(
211    data: &[u8],
212    algorithm: CompressionAlgorithm,
213    expected_size: usize,
214) -> Result<Vec<u8>> {
215    if expected_size > MAX_DECOMPRESSED_SIZE {
216        return Err(Error::PayloadTooLarge {
217            size: expected_size,
218            limit: MAX_DECOMPRESSED_SIZE,
219        });
220    }
221    let output = decompress_with_limits(data, algorithm, expected_size, MAX_COMPRESSION_RATIO)?;
222    if output.len() != expected_size {
223        return Err(Error::SizeMismatch {
224            expected: expected_size,
225            actual: output.len(),
226        });
227    }
228    Ok(output)
229}
230
231fn read_decompressed_with_limit<R: std::io::Read>(
232    reader: R,
233    max_output_size: usize,
234) -> Result<Vec<u8>> {
235    use std::io::Read;
236
237    let read_limit = max_output_size.saturating_add(1) as u64;
238    let mut limited = reader.take(read_limit);
239    let mut output = Vec::with_capacity(max_output_size.min(64 * 1024));
240    limited
241        .read_to_end(&mut output)
242        .map_err(|e| Error::DecompressionFailed(e.to_string()))?;
243    if output.len() > max_output_size {
244        return Err(Error::PayloadTooLarge {
245            size: output.len(),
246            limit: max_output_size,
247        });
248    }
249    Ok(output)
250}
251
252/// Compress data with Brotli
253fn compress_brotli(data: &[u8], level: u32) -> Result<(Vec<u8>, CompressionAlgorithm)> {
254    use brotli::enc::BrotliEncoderParams;
255
256    let mut output = Vec::new();
257    let mut params = BrotliEncoderParams::default();
258    params.quality = level as i32;
259
260    brotli::BrotliCompress(&mut std::io::Cursor::new(data), &mut output, &params)
261        .map_err(|e| Error::CompressionFailed(e.to_string()))?;
262
263    Ok((output, CompressionAlgorithm::Brotli))
264}
265
266/// Decompress Brotli data
267fn decompress_brotli(data: &[u8], max_output_size: usize) -> Result<Vec<u8>> {
268    let decoder = brotli::Decompressor::new(std::io::Cursor::new(data), 4096);
269    read_decompressed_with_limit(decoder, max_output_size)
270}
271
272/// Compress data with Gzip
273fn compress_gzip(data: &[u8], level: u32) -> Result<(Vec<u8>, CompressionAlgorithm)> {
274    use flate2::write::GzEncoder;
275    use flate2::Compression;
276    use std::io::Write;
277
278    let mut encoder = GzEncoder::new(Vec::new(), Compression::new(level));
279    encoder
280        .write_all(data)
281        .map_err(|e| Error::CompressionFailed(e.to_string()))?;
282
283    let output = encoder
284        .finish()
285        .map_err(|e| Error::CompressionFailed(e.to_string()))?;
286
287    Ok((output, CompressionAlgorithm::Gzip))
288}
289
290/// Decompress Gzip data
291fn decompress_gzip(data: &[u8], max_output_size: usize) -> Result<Vec<u8>> {
292    use flate2::read::GzDecoder;
293
294    read_decompressed_with_limit(GzDecoder::new(data), max_output_size)
295}
296
297/// Serialize compression result with header
298pub fn serialize_with_header(result: &CompressionResult) -> Result<Vec<u8>> {
299    let original_size =
300        u32::try_from(result.original_size).map_err(|_| Error::PayloadTooLarge {
301            size: result.original_size,
302            limit: u32::MAX as usize,
303        })?;
304    let mut output = Vec::with_capacity(7 + result.compressed.len());
305
306    // Magic bytes "VC"
307    output.extend_from_slice(MAGIC_COMPRESSED);
308    // Algorithm
309    output.push(result.algorithm as u8);
310    // Original size (big-endian)
311    output.extend_from_slice(&original_size.to_be_bytes());
312    // Compressed data
313    output.extend_from_slice(&result.compressed);
314
315    Ok(output)
316}
317
318/// Deserialize compression result with header
319pub fn deserialize_with_header(data: &[u8]) -> Result<(Vec<u8>, CompressionAlgorithm, usize)> {
320    if data.len() < 7 {
321        return Err(Error::TruncatedPayload {
322            expected: 7,
323            actual: data.len(),
324        });
325    }
326
327    // Check magic
328    if &data[0..2] != MAGIC_COMPRESSED {
329        return Err(Error::InvalidFormat);
330    }
331
332    // Parse algorithm
333    let algorithm = CompressionAlgorithm::from_byte(data[2])?;
334
335    // Parse original size
336    let original_size = u32::from_be_bytes([data[3], data[4], data[5], data[6]]) as usize;
337
338    // Extract compressed data
339    let compressed = data[7..].to_vec();
340
341    Ok((compressed, algorithm, original_size))
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_gzip_roundtrip() {
350        let data = b"Hello, World! This is a test message that should be compressed.";
351
352        let result = compress(
353            data,
354            Some(CompressionOptions {
355                algorithm: CompressionAlgorithm::Gzip,
356                min_size_threshold: 10,
357                level: 6,
358            }),
359        )
360        .unwrap();
361
362        let decompressed = decompress(&result.compressed, result.algorithm).unwrap();
363        assert_eq!(data, &decompressed[..]);
364    }
365
366    #[test]
367    fn test_brotli_roundtrip() {
368        let data = b"Hello, World! This is a test message that should be compressed with Brotli.";
369
370        let result = compress(
371            data,
372            Some(CompressionOptions {
373                algorithm: CompressionAlgorithm::Brotli,
374                min_size_threshold: 10,
375                level: 6,
376            }),
377        )
378        .unwrap();
379
380        let decompressed = decompress(&result.compressed, result.algorithm).unwrap();
381        assert_eq!(data, &decompressed[..]);
382    }
383
384    #[test]
385    fn test_skip_small_data() {
386        let data = b"tiny";
387
388        let result = compress(
389            data,
390            Some(CompressionOptions {
391                algorithm: CompressionAlgorithm::Brotli,
392                min_size_threshold: 100, // Data is smaller than threshold
393                level: 6,
394            }),
395        )
396        .unwrap();
397
398        assert_eq!(result.algorithm, CompressionAlgorithm::None);
399        assert_eq!(result.compressed, data);
400    }
401
402    #[test]
403    fn test_header_serialization() {
404        let data = b"Test data for header serialization test with enough content.";
405
406        let result = compress(
407            data,
408            Some(CompressionOptions {
409                algorithm: CompressionAlgorithm::Gzip,
410                min_size_threshold: 10,
411                level: 6,
412            }),
413        )
414        .unwrap();
415
416        let serialized = serialize_with_header(&result).unwrap();
417        let (compressed, algorithm, original_size) = deserialize_with_header(&serialized).unwrap();
418
419        assert_eq!(algorithm, result.algorithm);
420        assert_eq!(original_size, result.original_size);
421        assert_eq!(compressed, result.compressed);
422    }
423
424    #[test]
425    fn test_decompression_rejects_bomb_before_full_expansion() {
426        let plaintext = vec![0u8; 2 * 1024 * 1024];
427        let (compressed, _) = compress_gzip(&plaintext, 6).unwrap();
428        assert!(compressed.len().saturating_mul(MAX_COMPRESSION_RATIO) < plaintext.len());
429        assert!(matches!(
430            decompress(&compressed, CompressionAlgorithm::Gzip),
431            Err(Error::PayloadTooLarge { .. })
432        ));
433    }
434
435    #[test]
436    fn test_decompression_exact_enforces_expected_size() {
437        let plaintext = b"bounded decompression".repeat(64);
438        let (compressed, _) = compress_gzip(&plaintext, 6).unwrap();
439        assert_eq!(
440            decompress_exact(&compressed, CompressionAlgorithm::Gzip, plaintext.len()).unwrap(),
441            plaintext
442        );
443        assert!(matches!(
444            decompress_exact(&compressed, CompressionAlgorithm::Gzip, plaintext.len() - 1),
445            Err(Error::PayloadTooLarge { .. }) | Err(Error::SizeMismatch { .. })
446        ));
447    }
448
449    #[test]
450    fn test_none_decompression_obeys_output_bound() {
451        assert!(matches!(
452            decompress_with_limits(&[0u8; 9], CompressionAlgorithm::None, 8, 256),
453            Err(Error::PayloadTooLarge { size: 9, limit: 8 })
454        ));
455    }
456
457    #[test]
458    fn test_compression_rejects_invalid_levels_before_processing() {
459        assert!(matches!(
460            compress(
461                b"small",
462                Some(CompressionOptions {
463                    algorithm: CompressionAlgorithm::Gzip,
464                    min_size_threshold: usize::MAX,
465                    level: 10,
466                })
467            ),
468            Err(Error::InvalidConfiguration(_))
469        ));
470        assert!(matches!(
471            compress(
472                b"small",
473                Some(CompressionOptions {
474                    algorithm: CompressionAlgorithm::Brotli,
475                    min_size_threshold: usize::MAX,
476                    level: 12,
477                })
478            ),
479            Err(Error::InvalidConfiguration(_))
480        ));
481    }
482}