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 an explicit absolute output bound.
179///
180/// Unlike [`decompress_with_limits`], this function deliberately does not
181/// apply a compression-ratio heuristic. Callers that authenticate or otherwise
182/// trust an expected output size can therefore accept highly compressible data
183/// without giving the decoder an unbounded allocation path.
184pub fn decompress_bounded(
185    data: &[u8],
186    algorithm: CompressionAlgorithm,
187    max_output_size: usize,
188) -> Result<Vec<u8>> {
189    if max_output_size > MAX_DECOMPRESSED_SIZE {
190        return Err(Error::PayloadTooLarge {
191            size: max_output_size,
192            limit: MAX_DECOMPRESSED_SIZE,
193        });
194    }
195
196    decompress_with_absolute_limit(data, algorithm, max_output_size)
197}
198
199fn decompress_with_absolute_limit(
200    data: &[u8],
201    algorithm: CompressionAlgorithm,
202    max_output_size: usize,
203) -> Result<Vec<u8>> {
204    match algorithm {
205        CompressionAlgorithm::None => {
206            if data.len() > max_output_size {
207                return Err(Error::PayloadTooLarge {
208                    size: data.len(),
209                    limit: max_output_size,
210                });
211            }
212            Ok(data.to_vec())
213        }
214        CompressionAlgorithm::Gzip => decompress_gzip(data, max_output_size),
215        CompressionAlgorithm::Brotli => decompress_brotli(data, max_output_size),
216    }
217}
218
219/// Decompress with explicit output and expansion bounds.
220pub fn decompress_with_limits(
221    data: &[u8],
222    algorithm: CompressionAlgorithm,
223    max_output_size: usize,
224    max_ratio: usize,
225) -> Result<Vec<u8>> {
226    if max_ratio == 0 {
227        return Err(Error::InvalidConfiguration(
228            "decompression ratio limit must be greater than zero".to_string(),
229        ));
230    }
231
232    let ratio_limit = data.len().saturating_mul(max_ratio);
233    let effective_limit = max_output_size.min(ratio_limit);
234    decompress_with_absolute_limit(data, algorithm, effective_limit)
235}
236
237/// Decompress into an authenticated expected size while retaining global and
238/// ratio bounds. The expected size is checked before any output allocation.
239pub fn decompress_exact(
240    data: &[u8],
241    algorithm: CompressionAlgorithm,
242    expected_size: usize,
243) -> Result<Vec<u8>> {
244    if expected_size > MAX_DECOMPRESSED_SIZE {
245        return Err(Error::PayloadTooLarge {
246            size: expected_size,
247            limit: MAX_DECOMPRESSED_SIZE,
248        });
249    }
250    let output = decompress_with_limits(data, algorithm, expected_size, MAX_COMPRESSION_RATIO)?;
251    if output.len() != expected_size {
252        return Err(Error::SizeMismatch {
253            expected: expected_size,
254            actual: output.len(),
255        });
256    }
257    Ok(output)
258}
259
260fn read_decompressed_with_limit<R: std::io::Read>(
261    mut reader: R,
262    max_output_size: usize,
263) -> Result<Vec<u8>> {
264    let mut output = Vec::with_capacity(max_output_size.min(64 * 1024));
265    let mut buffer = vec![0u8; max_output_size.min(64 * 1024).max(1)];
266
267    loop {
268        let remaining = max_output_size.saturating_sub(output.len());
269        if remaining == 0 {
270            let mut probe = [0u8; 1];
271            let read = reader
272                .read(&mut probe)
273                .map_err(|e| Error::DecompressionFailed(e.to_string()))?;
274            if read == 0 {
275                return Ok(output);
276            }
277            return Err(Error::PayloadTooLarge {
278                size: max_output_size.saturating_add(1),
279                limit: max_output_size,
280            });
281        }
282
283        let read_capacity = remaining.min(buffer.len());
284        let read = reader
285            .read(&mut buffer[..read_capacity])
286            .map_err(|e| Error::DecompressionFailed(e.to_string()))?;
287        if read == 0 {
288            return Ok(output);
289        }
290
291        let required_capacity = output.len().saturating_add(read);
292        if required_capacity > output.capacity() {
293            let target_capacity = output
294                .capacity()
295                .saturating_mul(2)
296                .max(required_capacity)
297                .min(max_output_size);
298            output.reserve_exact(target_capacity.saturating_sub(output.len()));
299        }
300        output.extend_from_slice(&buffer[..read]);
301    }
302}
303
304/// Compress data with Brotli
305fn compress_brotli(data: &[u8], level: u32) -> Result<(Vec<u8>, CompressionAlgorithm)> {
306    use brotli::enc::BrotliEncoderParams;
307
308    let mut output = Vec::new();
309    let mut params = BrotliEncoderParams::default();
310    params.quality = level as i32;
311
312    brotli::BrotliCompress(&mut std::io::Cursor::new(data), &mut output, &params)
313        .map_err(|e| Error::CompressionFailed(e.to_string()))?;
314
315    Ok((output, CompressionAlgorithm::Brotli))
316}
317
318/// Decompress Brotli data
319fn decompress_brotli(data: &[u8], max_output_size: usize) -> Result<Vec<u8>> {
320    let decoder = brotli::Decompressor::new(std::io::Cursor::new(data), 4096);
321    read_decompressed_with_limit(decoder, max_output_size)
322}
323
324/// Compress data with Gzip
325fn compress_gzip(data: &[u8], level: u32) -> Result<(Vec<u8>, CompressionAlgorithm)> {
326    use flate2::write::GzEncoder;
327    use flate2::Compression;
328    use std::io::Write;
329
330    let mut encoder = GzEncoder::new(Vec::new(), Compression::new(level));
331    encoder
332        .write_all(data)
333        .map_err(|e| Error::CompressionFailed(e.to_string()))?;
334
335    let output = encoder
336        .finish()
337        .map_err(|e| Error::CompressionFailed(e.to_string()))?;
338
339    Ok((output, CompressionAlgorithm::Gzip))
340}
341
342/// Decompress Gzip data
343fn decompress_gzip(data: &[u8], max_output_size: usize) -> Result<Vec<u8>> {
344    use flate2::read::GzDecoder;
345
346    read_decompressed_with_limit(GzDecoder::new(data), max_output_size)
347}
348
349/// Serialize compression result with header
350pub fn serialize_with_header(result: &CompressionResult) -> Result<Vec<u8>> {
351    let original_size =
352        u32::try_from(result.original_size).map_err(|_| Error::PayloadTooLarge {
353            size: result.original_size,
354            limit: u32::MAX as usize,
355        })?;
356    let mut output = Vec::with_capacity(7 + result.compressed.len());
357
358    // Magic bytes "VC"
359    output.extend_from_slice(MAGIC_COMPRESSED);
360    // Algorithm
361    output.push(result.algorithm as u8);
362    // Original size (big-endian)
363    output.extend_from_slice(&original_size.to_be_bytes());
364    // Compressed data
365    output.extend_from_slice(&result.compressed);
366
367    Ok(output)
368}
369
370/// Deserialize compression result with header
371pub fn deserialize_with_header(data: &[u8]) -> Result<(Vec<u8>, CompressionAlgorithm, usize)> {
372    if data.len() < 7 {
373        return Err(Error::TruncatedPayload {
374            expected: 7,
375            actual: data.len(),
376        });
377    }
378
379    // Check magic
380    if &data[0..2] != MAGIC_COMPRESSED {
381        return Err(Error::InvalidFormat);
382    }
383
384    // Parse algorithm
385    let algorithm = CompressionAlgorithm::from_byte(data[2])?;
386
387    // Parse original size
388    let original_size = u32::from_be_bytes([data[3], data[4], data[5], data[6]]) as usize;
389
390    // Extract compressed data
391    let compressed = data[7..].to_vec();
392
393    Ok((compressed, algorithm, original_size))
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_gzip_roundtrip() {
402        let data = b"Hello, World! This is a test message that should be compressed.";
403
404        let result = compress(
405            data,
406            Some(CompressionOptions {
407                algorithm: CompressionAlgorithm::Gzip,
408                min_size_threshold: 10,
409                level: 6,
410            }),
411        )
412        .unwrap();
413
414        let decompressed = decompress(&result.compressed, result.algorithm).unwrap();
415        assert_eq!(data, &decompressed[..]);
416    }
417
418    #[test]
419    fn test_brotli_roundtrip() {
420        let data = b"Hello, World! This is a test message that should be compressed with Brotli.";
421
422        let result = compress(
423            data,
424            Some(CompressionOptions {
425                algorithm: CompressionAlgorithm::Brotli,
426                min_size_threshold: 10,
427                level: 6,
428            }),
429        )
430        .unwrap();
431
432        let decompressed = decompress(&result.compressed, result.algorithm).unwrap();
433        assert_eq!(data, &decompressed[..]);
434    }
435
436    #[test]
437    fn test_skip_small_data() {
438        let data = b"tiny";
439
440        let result = compress(
441            data,
442            Some(CompressionOptions {
443                algorithm: CompressionAlgorithm::Brotli,
444                min_size_threshold: 100, // Data is smaller than threshold
445                level: 6,
446            }),
447        )
448        .unwrap();
449
450        assert_eq!(result.algorithm, CompressionAlgorithm::None);
451        assert_eq!(result.compressed, data);
452    }
453
454    #[test]
455    fn test_header_serialization() {
456        let data = b"Test data for header serialization test with enough content.";
457
458        let result = compress(
459            data,
460            Some(CompressionOptions {
461                algorithm: CompressionAlgorithm::Gzip,
462                min_size_threshold: 10,
463                level: 6,
464            }),
465        )
466        .unwrap();
467
468        let serialized = serialize_with_header(&result).unwrap();
469        let (compressed, algorithm, original_size) = deserialize_with_header(&serialized).unwrap();
470
471        assert_eq!(algorithm, result.algorithm);
472        assert_eq!(original_size, result.original_size);
473        assert_eq!(compressed, result.compressed);
474    }
475
476    #[test]
477    fn test_decompression_rejects_bomb_before_full_expansion() {
478        let plaintext = vec![0u8; 2 * 1024 * 1024];
479        let (compressed, _) = compress_gzip(&plaintext, 6).unwrap();
480        assert!(compressed.len().saturating_mul(MAX_COMPRESSION_RATIO) < plaintext.len());
481        assert!(matches!(
482            decompress(&compressed, CompressionAlgorithm::Gzip),
483            Err(Error::PayloadTooLarge { .. })
484        ));
485    }
486
487    #[test]
488    fn test_decompression_exact_enforces_expected_size() {
489        let plaintext = b"bounded decompression".repeat(64);
490        let (compressed, _) = compress_gzip(&plaintext, 6).unwrap();
491        assert_eq!(
492            decompress_exact(&compressed, CompressionAlgorithm::Gzip, plaintext.len()).unwrap(),
493            plaintext
494        );
495        assert!(matches!(
496            decompress_exact(&compressed, CompressionAlgorithm::Gzip, plaintext.len() - 1),
497            Err(Error::PayloadTooLarge { .. }) | Err(Error::SizeMismatch { .. })
498        ));
499    }
500
501    #[test]
502    fn test_bounded_decompression_accepts_high_ratio_data_and_enforces_absolute_cap() {
503        let plaintext =
504            b"<section class=\"oathdoc-clause\">auditable terms</section>\n".repeat(40_000);
505        let streams = [
506            (
507                compress_brotli(&plaintext, 6).unwrap().0,
508                CompressionAlgorithm::Brotli,
509            ),
510            (
511                compress_gzip(&plaintext, 6).unwrap().0,
512                CompressionAlgorithm::Gzip,
513            ),
514        ];
515
516        for (compressed, algorithm) in streams {
517            assert!(compressed.len().saturating_mul(MAX_COMPRESSION_RATIO) < plaintext.len());
518            assert_eq!(
519                decompress_bounded(&compressed, algorithm, plaintext.len()).unwrap(),
520                plaintext
521            );
522            assert!(matches!(
523                decompress_bounded(&compressed, algorithm, plaintext.len() - 1,),
524                Err(Error::PayloadTooLarge { .. })
525            ));
526        }
527    }
528
529    #[test]
530    fn test_bounded_decompression_rejects_a_cap_above_the_global_ceiling() {
531        assert!(matches!(
532            decompress_bounded(
533                &[],
534                CompressionAlgorithm::None,
535                MAX_DECOMPRESSED_SIZE + 1,
536            ),
537            Err(Error::PayloadTooLarge {
538                size,
539                limit: MAX_DECOMPRESSED_SIZE,
540            }) if size == MAX_DECOMPRESSED_SIZE + 1
541        ));
542    }
543
544    #[test]
545    fn test_none_decompression_obeys_output_bound() {
546        assert!(matches!(
547            decompress_with_limits(&[0u8; 9], CompressionAlgorithm::None, 8, 256),
548            Err(Error::PayloadTooLarge { size: 9, limit: 8 })
549        ));
550    }
551
552    #[test]
553    fn test_compression_rejects_invalid_levels_before_processing() {
554        assert!(matches!(
555            compress(
556                b"small",
557                Some(CompressionOptions {
558                    algorithm: CompressionAlgorithm::Gzip,
559                    min_size_threshold: usize::MAX,
560                    level: 10,
561                })
562            ),
563            Err(Error::InvalidConfiguration(_))
564        ));
565        assert!(matches!(
566            compress(
567                b"small",
568                Some(CompressionOptions {
569                    algorithm: CompressionAlgorithm::Brotli,
570                    min_size_threshold: usize::MAX,
571                    level: 12,
572                })
573            ),
574            Err(Error::InvalidConfiguration(_))
575        ));
576    }
577}