Skip to main content

pjson_rs/compression/
secure.rs

1//! Secure compression with bomb protection and real byte-level codecs.
2//!
3//! This module provides [`SecureCompressor`], which applies byte-level compression (Layer B)
4//! to arbitrary `&[u8]` payloads. It is distinct from `SchemaCompressor` in `compression/mod.rs`,
5//! which operates on `serde_json::Value` (Layer A / structural compression).
6//!
7//! # Security
8//!
9//! [`SecureCompressor::decompress_protected`] and [`SecureCompressor::decompress_nested`] route
10//! decompression through `CompressionBombProtector`, which streams the decoder output and aborts
11//! if decompressed size or ratio exceeds configured limits. These methods are fully implemented
12//! and tested, but currently have no production callers in this codebase. The only live usage
13//! of [`SecureCompressor`] today is outbound compression via [`SecureCompressor::compress`],
14//! which compresses trusted server output without decompression.
15//!
16//! # In-process only
17//!
18//! [`SecureCompressedData`] carries the codec tag and is intended for in-process use only.
19//! It is not a wire format. If cross-process transport is needed in a future PR, a versioned
20//! framing header must be designed separately.
21
22use crate::{
23    Error, Result,
24    security::{CompressionBombDetector, CompressionStats},
25};
26#[cfg(feature = "compression")]
27use std::io::Write;
28use std::io::{Cursor, Read};
29use tracing::{debug, info, warn};
30#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
31use zstd;
32
33/// Byte-level compression algorithms used by [`SecureCompressor`].
34///
35/// This is distinct from [`CompressionStrategy`](super::CompressionStrategy), which operates on
36/// `serde_json::Value` (Layer A). `ByteCodec` operates on raw bytes after JSON serialization
37/// (Layer B).
38///
39/// Codecs other than `None` require the `compression` feature.
40///
41/// # Breaking change (pre-1.0)
42///
43/// `Copy` was removed from this enum when `ZstdDict` was added (it carries an
44/// `Arc<ZstdDictionary>`).  Code that relied on implicit copy can use `.clone()`
45/// (one atomic refcount bump for `ZstdDict`; a no-op for the other variants).
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47pub enum ByteCodec {
48    /// No compression — bytes stored verbatim. Always available.
49    #[default]
50    None,
51    /// Raw deflate (RFC 1951). Low framing overhead.
52    ///
53    /// Note: raw deflate has no magic header, so codec mismatch during decompression will
54    /// produce a decoder error rather than a guaranteed clean failure. The codec tag embedded
55    /// in [`SecureCompressedData`] prevents this for in-process round-trips.
56    ///
57    /// Requires `feature = "compression"`.
58    Deflate,
59    /// Gzip (RFC 1952). Self-identifying via `1f 8b` magic header.
60    ///
61    /// Requires `feature = "compression"`.
62    Gzip,
63    /// Brotli. Best ratio for repetitive JSON.
64    ///
65    /// Requires `feature = "compression"`.
66    Brotli,
67    /// Trained zstd dictionary compression.
68    ///
69    /// A single `Arc<ZstdDictionary>` is the canonical sharing primitive. The inner
70    /// `Vec<u8>` inside [`crate::compression::zstd::ZstdDictionary`] is **not**
71    /// `Arc`-wrapped — sharing happens exactly once at this enum level (avoids
72    /// double indirection). Cloning this variant performs one atomic refcount
73    /// increment and no allocation.
74    ///
75    /// Equality compares the underlying bytes via `Arc<T>: PartialEq where T: PartialEq`.
76    /// When both sides share the same `Arc` allocation, `Arc::ptr_eq` provides a fast path.
77    ///
78    /// Requires `feature = "compression"` on a non-`wasm32` target.
79    #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
80    ZstdDict(std::sync::Arc<crate::compression::zstd::ZstdDictionary>),
81}
82
83/// Quality knob for byte-level codecs.
84///
85/// Maps to codec-specific levels: deflate 1/6/9 and brotli quality 1/5/11.
86#[derive(Debug, Clone, Copy, Default)]
87pub enum CompressionQuality {
88    /// Speed-optimised: deflate level 1, brotli quality 1.
89    Fast,
90    /// Balanced speed/ratio (default): deflate level 6, brotli quality 5.
91    #[default]
92    Balanced,
93    /// Maximum ratio: deflate level 9, brotli quality 11.
94    Best,
95}
96
97impl CompressionQuality {
98    #[cfg(feature = "compression")]
99    fn flate2_level(self) -> flate2::Compression {
100        match self {
101            Self::Fast => flate2::Compression::fast(),
102            Self::Balanced => flate2::Compression::default(),
103            Self::Best => flate2::Compression::best(),
104        }
105    }
106
107    #[cfg(feature = "compression")]
108    fn brotli_quality(self) -> i32 {
109        match self {
110            Self::Fast => 1,
111            Self::Balanced => 5,
112            Self::Best => 11,
113        }
114    }
115}
116
117/// Compressed bytes with security metadata and codec identification.
118///
119/// # In-process only
120///
121/// This struct is intended for in-process use only and is not a wire format.
122/// The `codec` field is carried alongside `data` so that [`SecureCompressor::decompress_protected`]
123/// always uses the correct decoder. If this type must cross process boundaries in the future,
124/// design a versioned framing header as a separate concern.
125#[derive(Debug, Clone)]
126pub struct SecureCompressedData {
127    /// The compressed (or verbatim) payload.
128    pub data: Vec<u8>,
129    /// Original uncompressed size in bytes.
130    pub original_size: usize,
131    /// Compression ratio: `original_size / compressed_size`.
132    ///
133    /// A value of `2.0` means the compressed payload is half the original size (50% size reduction).
134    /// For `ByteCodec::None` this is always `1.0`; for incompressible data it can be `< 1.0`
135    /// because most codecs add a small framing header.
136    pub compression_ratio: f64,
137    /// Codec used to produce `data`. Must be passed back to [`SecureCompressor::decompress_protected`].
138    pub codec: ByteCodec,
139}
140
141/// Secure byte-level compressor with integrated bomb protection.
142///
143/// Wraps a [`CompressionBombDetector`] to ensure decompressed output never exceeds configured
144/// size and ratio limits, regardless of which codec is active.
145///
146/// # Examples
147///
148/// ```rust
149/// use pjson_rs::compression::secure::{SecureCompressor, ByteCodec};
150///
151/// let compressor = SecureCompressor::with_default_security(ByteCodec::None);
152/// let compressed = compressor.compress(b"hello world").unwrap();
153/// let decompressed = compressor.decompress_protected(&compressed).unwrap();
154/// assert_eq!(decompressed, b"hello world");
155/// ```
156pub struct SecureCompressor {
157    detector: CompressionBombDetector,
158    codec: ByteCodec,
159    #[cfg_attr(not(feature = "compression"), allow(dead_code))]
160    quality: CompressionQuality,
161}
162
163impl SecureCompressor {
164    /// Create a new secure compressor with the given detector and codec.
165    pub fn new(detector: CompressionBombDetector, codec: ByteCodec) -> Self {
166        Self {
167            detector,
168            codec,
169            quality: CompressionQuality::default(),
170        }
171    }
172
173    /// Create with default security settings and the given codec.
174    pub fn with_default_security(codec: ByteCodec) -> Self {
175        Self::new(CompressionBombDetector::default(), codec)
176    }
177
178    /// Create with explicit quality setting.
179    pub fn with_quality(
180        detector: CompressionBombDetector,
181        codec: ByteCodec,
182        quality: CompressionQuality,
183    ) -> Self {
184        Self {
185            detector,
186            codec,
187            quality,
188        }
189    }
190
191    /// Compress `data` using the configured codec.
192    ///
193    /// Validates the input size against `max_compressed_size` before encoding.
194    pub fn compress(&self, data: &[u8]) -> Result<SecureCompressedData> {
195        self.detector.validate_pre_decompression(data.len())?;
196
197        let compressed_bytes = self.encode(data)?;
198
199        let compression_ratio = data.len() as f64 / compressed_bytes.len().max(1) as f64;
200        info!("Compression completed: {:.2}x ratio", compression_ratio);
201
202        Ok(SecureCompressedData {
203            original_size: data.len(),
204            compression_ratio,
205            codec: self.codec.clone(),
206            data: compressed_bytes,
207        })
208    }
209
210    /// Decompress `compressed` using the codec recorded in `compressed.codec`.
211    ///
212    /// Decoder output is streamed through `CompressionBombProtector` — decompression aborts
213    /// early if size or ratio limits are exceeded.
214    pub fn decompress_protected(&self, compressed: &SecureCompressedData) -> Result<Vec<u8>> {
215        self.detector
216            .validate_pre_decompression(compressed.data.len())?;
217        self.decode_with_protection(&compressed.data, compressed.codec.clone(), None)
218    }
219
220    /// Decompress nested/chained compression with depth tracking.
221    ///
222    /// Equivalent to [`decompress_protected`](Self::decompress_protected) but additionally enforces
223    /// `max_compression_depth` via [`CompressionBombDetector::protect_nested_reader`].
224    pub fn decompress_nested(
225        &self,
226        compressed: &SecureCompressedData,
227        depth: usize,
228    ) -> Result<Vec<u8>> {
229        self.detector
230            .validate_pre_decompression(compressed.data.len())?;
231        self.decode_with_protection(&compressed.data, compressed.codec.clone(), Some(depth))
232    }
233
234    /// Encode `data` with the configured codec. Returns compressed bytes only.
235    fn encode(&self, data: &[u8]) -> Result<Vec<u8>> {
236        match &self.codec {
237            ByteCodec::None => {
238                debug!("No compression applied");
239                Ok(data.to_vec())
240            }
241
242            #[cfg(feature = "compression")]
243            ByteCodec::Deflate => {
244                use flate2::write::DeflateEncoder;
245                let mut enc = DeflateEncoder::new(Vec::new(), self.quality.flate2_level());
246                enc.write_all(data)
247                    .map_err(|e| Error::CompressionError(format!("deflate encode: {e}")))?;
248                enc.finish()
249                    .map_err(|e| Error::CompressionError(format!("deflate finish: {e}")))
250            }
251
252            #[cfg(feature = "compression")]
253            ByteCodec::Gzip => {
254                use flate2::write::GzEncoder;
255                let mut enc = GzEncoder::new(Vec::new(), self.quality.flate2_level());
256                enc.write_all(data)
257                    .map_err(|e| Error::CompressionError(format!("gzip encode: {e}")))?;
258                enc.finish()
259                    .map_err(|e| Error::CompressionError(format!("gzip finish: {e}")))
260            }
261
262            #[cfg(feature = "compression")]
263            ByteCodec::Brotli => {
264                let params = brotli::enc::BrotliEncoderParams {
265                    quality: self.quality.brotli_quality(),
266                    ..Default::default()
267                };
268                let mut out = Vec::new();
269                brotli::BrotliCompress(&mut Cursor::new(data), &mut out, &params)
270                    .map_err(|e| Error::CompressionError(format!("brotli encode: {e}")))?;
271                Ok(out)
272            }
273
274            #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
275            ByteCodec::ZstdDict(dict) => {
276                crate::compression::zstd::ZstdDictCompressor::compress(data, dict.as_ref())
277            }
278
279            #[cfg(not(feature = "compression"))]
280            ByteCodec::Deflate | ByteCodec::Gzip | ByteCodec::Brotli => Err(
281                Error::CompressionError("feature `compression` is not enabled".into()),
282            ),
283        }
284    }
285
286    /// Decode `data` through a bomb-protected reader.
287    ///
288    /// `depth` is `Some(n)` for nested decompression (depth-limited) or `None` for a flat call.
289    fn decode_with_protection(
290        &self,
291        data: &[u8],
292        codec: ByteCodec,
293        depth: Option<usize>,
294    ) -> Result<Vec<u8>> {
295        // Macro-free helper: executes the read loop with any `impl Read` decoder.
296        // Avoids boxing across a lifetime boundary by keeping decoder + protector in one scope.
297        macro_rules! run {
298            ($decoder:expr) => {{
299                let compressed_size = data.len();
300                let mut out = Vec::new();
301                let result = if let Some(d) = depth {
302                    let mut protector =
303                        self.detector
304                            .protect_nested_reader($decoder, compressed_size, d)?;
305                    let r = protector.read_to_end(&mut out);
306                    let stats = protector.stats();
307                    self.log_decompression_stats(&stats);
308                    if stats.compression_depth > 0 {
309                        warn!(
310                            "Nested decompression detected at depth {}",
311                            stats.compression_depth
312                        );
313                    }
314                    r
315                } else {
316                    let mut protector = self.detector.protect_reader($decoder, compressed_size);
317                    let r = protector.read_to_end(&mut out);
318                    let stats = protector.stats();
319                    self.log_decompression_stats(&stats);
320                    r
321                };
322                match result {
323                    Ok(_) => {
324                        self.detector.validate_result(compressed_size, out.len())?;
325                        Ok(out)
326                    }
327                    Err(e) => {
328                        warn!("Decompression failed: {}", e);
329                        Err(Error::SecurityError(format!(
330                            "Protected decompression failed: {}",
331                            e
332                        )))
333                    }
334                }
335            }};
336        }
337
338        match codec {
339            ByteCodec::None => run!(Cursor::new(data)),
340
341            #[cfg(feature = "compression")]
342            ByteCodec::Deflate => run!(flate2::read::DeflateDecoder::new(Cursor::new(data))),
343
344            #[cfg(feature = "compression")]
345            ByteCodec::Gzip => run!(flate2::read::GzDecoder::new(Cursor::new(data))),
346
347            #[cfg(feature = "compression")]
348            ByteCodec::Brotli => run!(brotli::Decompressor::new(Cursor::new(data), 4096)),
349
350            // ZstdDict uses the streaming decoder so every decompressed byte
351            // passes through the CompressionBombProtector's read loop (run!).
352            // Bulk `zstd::bulk::Decompressor::decompress` is intentionally
353            // avoided here — it would bypass the byte-level output cap.
354            #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
355            ByteCodec::ZstdDict(dict) => {
356                let decoder = zstd::stream::read::Decoder::with_dictionary(
357                    Cursor::new(data),
358                    dict.as_bytes(),
359                )
360                .map_err(|e| Error::CompressionError(format!("zstd decoder init: {e}")))?;
361                run!(decoder)
362            }
363
364            #[cfg(not(feature = "compression"))]
365            ByteCodec::Deflate | ByteCodec::Gzip | ByteCodec::Brotli => Err(
366                Error::CompressionError("feature `compression` is not enabled".into()),
367            ),
368        }
369    }
370
371    fn log_decompression_stats(&self, stats: &CompressionStats) {
372        info!(
373            "Decompression stats: {}B -> {}B (ratio: {:.2}x, depth: {})",
374            stats.compressed_size, stats.decompressed_size, stats.ratio, stats.compression_depth
375        );
376    }
377}
378
379/// Secure decompression context for streaming operations.
380pub struct SecureDecompressionContext {
381    detector: CompressionBombDetector,
382    current_depth: usize,
383    max_concurrent_streams: usize,
384    active_streams: usize,
385}
386
387impl SecureDecompressionContext {
388    /// Create new secure decompression context.
389    pub fn new(detector: CompressionBombDetector, max_concurrent_streams: usize) -> Self {
390        Self {
391            detector,
392            current_depth: 0,
393            max_concurrent_streams,
394            active_streams: 0,
395        }
396    }
397
398    /// Start a new protected decompression stream.
399    ///
400    /// Returns an error if the concurrent stream limit would be exceeded.
401    ///
402    /// # Note
403    ///
404    /// The returned `CompressionBombProtector` wraps an empty in-memory cursor. Callers are
405    /// responsible for writing compressed bytes into the underlying buffer before reading. This API
406    /// is a concurrency-limit scaffold; true streaming wire integration is left for a future PR.
407    pub fn start_stream(
408        &mut self,
409        compressed_size: usize,
410    ) -> Result<crate::security::CompressionBombProtector<Cursor<Vec<u8>>>> {
411        if self.active_streams >= self.max_concurrent_streams {
412            return Err(Error::SecurityError(format!(
413                "Too many concurrent decompression streams: {}/{}",
414                self.active_streams, self.max_concurrent_streams
415            )));
416        }
417
418        let cursor = Cursor::new(Vec::new());
419        let protector =
420            self.detector
421                .protect_nested_reader(cursor, compressed_size, self.current_depth)?;
422
423        self.active_streams += 1;
424        info!(
425            "Started secure decompression stream (active: {})",
426            self.active_streams
427        );
428
429        Ok(protector)
430    }
431
432    /// Finish a decompression stream and decrement the active count.
433    pub fn finish_stream(&mut self) {
434        if self.active_streams > 0 {
435            self.active_streams -= 1;
436            info!(
437                "Finished secure decompression stream (active: {})",
438                self.active_streams
439            );
440        }
441    }
442
443    /// Get current context statistics.
444    pub fn stats(&self) -> DecompressionContextStats {
445        DecompressionContextStats {
446            current_depth: self.current_depth,
447            active_streams: self.active_streams,
448            max_concurrent_streams: self.max_concurrent_streams,
449        }
450    }
451}
452
453/// Statistics for a [`SecureDecompressionContext`].
454#[derive(Debug, Clone)]
455pub struct DecompressionContextStats {
456    /// Current nested decompression depth.
457    pub current_depth: usize,
458    /// Number of decompression streams currently in flight.
459    pub active_streams: usize,
460    /// Configured maximum number of concurrent streams.
461    pub max_concurrent_streams: usize,
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::security::CompressionBombConfig;
468
469    #[test]
470    fn test_secure_compressor_creation() {
471        let detector = CompressionBombDetector::default();
472        let compressor = SecureCompressor::new(detector, ByteCodec::None);
473        // Verify the compressor is created (not null pointer).
474        assert!(!std::ptr::addr_of!(compressor).cast::<u8>().is_null());
475    }
476
477    #[test]
478    fn test_secure_compression_none() {
479        let compressor = SecureCompressor::with_default_security(ByteCodec::None);
480        let data = b"Hello, world! This is test data for compression.";
481
482        let result = compressor.compress(data);
483        assert!(result.is_ok());
484
485        let compressed = result.unwrap();
486        assert_eq!(compressed.original_size, data.len());
487        assert_eq!(compressed.codec, ByteCodec::None);
488    }
489
490    #[test]
491    fn test_none_roundtrip() {
492        let compressor = SecureCompressor::with_default_security(ByteCodec::None);
493        let data = b"round-trip test";
494
495        let compressed = compressor.compress(data).unwrap();
496        let decompressed = compressor.decompress_protected(&compressed).unwrap();
497        assert_eq!(decompressed, data);
498    }
499
500    #[test]
501    fn test_compression_size_limit() {
502        let config = CompressionBombConfig {
503            max_compressed_size: 100, // Very small limit
504            ..Default::default()
505        };
506        let detector = CompressionBombDetector::new(config);
507        let compressor = SecureCompressor::new(detector, ByteCodec::None);
508
509        let large_data = vec![0u8; 1000]; // 1 KiB data
510        let result = compressor.compress(&large_data);
511
512        // Should fail pre-compression validation (compressed_size > max_compressed_size).
513        assert!(result.is_err());
514    }
515
516    #[test]
517    fn test_different_codecs_none() {
518        let compressor = SecureCompressor::with_default_security(ByteCodec::None);
519        let data = b"test data";
520
521        let result = compressor.compress(data);
522        assert!(result.is_ok());
523
524        let compressed = result.unwrap();
525        assert_eq!(compressed.compression_ratio, 1.0);
526        assert_eq!(compressed.codec, ByteCodec::None);
527    }
528
529    #[cfg(feature = "compression")]
530    mod compression_tests {
531        use super::*;
532
533        // ~4 KiB of repetitive JSON-like payload — should compress well.
534        fn repetitive_json() -> Vec<u8> {
535            let item = br#"{"id":1,"name":"test","value":42,"active":true}"#;
536            item.repeat(100)
537        }
538
539        #[test]
540        fn test_deflate_roundtrip() {
541            let compressor = SecureCompressor::with_default_security(ByteCodec::Deflate);
542            let data = repetitive_json();
543
544            let compressed = compressor.compress(&data).unwrap();
545            assert_eq!(compressed.codec, ByteCodec::Deflate);
546            assert!(
547                compressed.data.len() < data.len(),
548                "deflate must reduce size"
549            );
550
551            let decompressed = compressor.decompress_protected(&compressed).unwrap();
552            assert_eq!(decompressed, data);
553        }
554
555        #[test]
556        fn test_gzip_roundtrip() {
557            let compressor = SecureCompressor::with_default_security(ByteCodec::Gzip);
558            let data = repetitive_json();
559
560            let compressed = compressor.compress(&data).unwrap();
561            assert_eq!(compressed.codec, ByteCodec::Gzip);
562            assert!(compressed.data.len() < data.len(), "gzip must reduce size");
563
564            let decompressed = compressor.decompress_protected(&compressed).unwrap();
565            assert_eq!(decompressed, data);
566        }
567
568        #[test]
569        fn test_brotli_roundtrip() {
570            let compressor = SecureCompressor::with_default_security(ByteCodec::Brotli);
571            let data = repetitive_json();
572
573            let compressed = compressor.compress(&data).unwrap();
574            assert_eq!(compressed.codec, ByteCodec::Brotli);
575            assert!(
576                compressed.data.len() < data.len(),
577                "brotli must reduce size"
578            );
579
580            let decompressed = compressor.decompress_protected(&compressed).unwrap();
581            assert_eq!(decompressed, data);
582        }
583
584        #[test]
585        fn test_all_qualities_deflate() {
586            let data = repetitive_json();
587            for quality in [
588                CompressionQuality::Fast,
589                CompressionQuality::Balanced,
590                CompressionQuality::Best,
591            ] {
592                let c = SecureCompressor::with_quality(
593                    CompressionBombDetector::default(),
594                    ByteCodec::Deflate,
595                    quality,
596                );
597                let compressed = c.compress(&data).unwrap();
598                let decompressed = c.decompress_protected(&compressed).unwrap();
599                assert_eq!(decompressed, data);
600            }
601        }
602
603        #[test]
604        fn test_all_qualities_brotli() {
605            // Use Fast only to keep test time reasonable (quality 11 is slow).
606            let data = repetitive_json();
607            let c = SecureCompressor::with_quality(
608                CompressionBombDetector::default(),
609                ByteCodec::Brotli,
610                CompressionQuality::Fast,
611            );
612            let compressed = c.compress(&data).unwrap();
613            let decompressed = c.decompress_protected(&compressed).unwrap();
614            assert_eq!(decompressed, data);
615        }
616
617        #[test]
618        fn test_codec_mismatch_returns_error() {
619            // Compress with Brotli, but tell decompressor it is Gzip.
620            let c = SecureCompressor::with_default_security(ByteCodec::Brotli);
621            let data = b"codec mismatch test data";
622            let mut compressed = c.compress(data).unwrap();
623            compressed.codec = ByteCodec::Gzip; // wrong codec tag
624
625            let result = c.decompress_protected(&compressed);
626            assert!(
627                result.is_err(),
628                "wrong codec must produce an error, not garbage"
629            );
630        }
631
632        #[test]
633        fn test_bomb_detection_on_real_codec() {
634            // A very tight max_decompressed_size so any real inflation trips the guard.
635            let config = CompressionBombConfig {
636                max_decompressed_size: 200,  // Only 200 bytes allowed out
637                max_compressed_size: 10_000, // Allow the compressed input
638                max_ratio: 300.0,
639                check_interval_bytes: 64,
640                ..Default::default()
641            };
642            let detector = CompressionBombDetector::new(config);
643            let compressor =
644                SecureCompressor::new(CompressionBombDetector::default(), ByteCodec::Gzip);
645
646            // Produce a real gzip payload of ~4 KiB.
647            let data = repetitive_json();
648            let compressed = compressor.compress(&data).unwrap();
649
650            // Now decompress with a detector that caps at 200 bytes.
651            let strict_compressor = SecureCompressor::new(detector, ByteCodec::Gzip);
652            let result = strict_compressor.decompress_protected(&compressed);
653            assert!(
654                result.is_err(),
655                "bomb detector must stop oversized decompression"
656            );
657        }
658    }
659
660    #[test]
661    fn test_secure_decompression_context() {
662        let detector = CompressionBombDetector::default();
663        let mut context = SecureDecompressionContext::new(detector, 2);
664
665        assert!(context.start_stream(1024).is_ok());
666        assert!(context.start_stream(1024).is_ok());
667
668        // Third stream exceeds limit.
669        assert!(context.start_stream(1024).is_err());
670
671        context.finish_stream();
672        assert!(context.start_stream(1024).is_ok());
673    }
674
675    #[test]
676    fn test_context_stats() {
677        let detector = CompressionBombDetector::default();
678        let context = SecureDecompressionContext::new(detector, 5);
679
680        let stats = context.stats();
681        assert_eq!(stats.current_depth, 0);
682        assert_eq!(stats.active_streams, 0);
683        assert_eq!(stats.max_concurrent_streams, 5);
684    }
685
686    #[test]
687    fn test_context_finish_stream_underflow_safe() {
688        let detector = CompressionBombDetector::default();
689        let mut context = SecureDecompressionContext::new(detector, 5);
690
691        // finish_stream when active_streams == 0 must not underflow.
692        context.finish_stream();
693        let stats = context.stats();
694        assert_eq!(stats.active_streams, 0);
695    }
696
697    #[test]
698    fn test_byte_codec_default_is_none() {
699        assert_eq!(ByteCodec::default(), ByteCodec::None);
700    }
701
702    #[test]
703    fn test_byte_codec_clone() {
704        let codec = ByteCodec::None;
705        let cloned = codec.clone();
706        assert_eq!(codec, cloned);
707    }
708
709    #[test]
710    fn test_compression_quality_default_is_balanced() {
711        // Default quality must produce a valid compressor without error.
712        let c = SecureCompressor::with_default_security(ByteCodec::None);
713        let data = b"quality default test";
714        let compressed = c.compress(data).unwrap();
715        let decompressed = c.decompress_protected(&compressed).unwrap();
716        assert_eq!(decompressed.as_slice(), data);
717    }
718
719    #[test]
720    fn test_secure_compressed_data_clone() {
721        let c = SecureCompressor::with_default_security(ByteCodec::None);
722        let compressed = c.compress(b"clone test").unwrap();
723        let cloned = compressed.clone();
724        assert_eq!(compressed.data, cloned.data);
725        assert_eq!(compressed.original_size, cloned.original_size);
726        assert_eq!(compressed.codec, cloned.codec);
727    }
728
729    #[test]
730    fn test_none_roundtrip_empty_payload() {
731        let c = SecureCompressor::with_default_security(ByteCodec::None);
732        let compressed = c.compress(b"").unwrap();
733        let decompressed = c.decompress_protected(&compressed).unwrap();
734        assert_eq!(decompressed, b"");
735    }
736
737    #[test]
738    fn test_decompress_nested_none() {
739        let c = SecureCompressor::with_default_security(ByteCodec::None);
740        let data = b"nested roundtrip";
741        let compressed = c.compress(data).unwrap();
742        let decompressed = c.decompress_nested(&compressed, 0).unwrap();
743        assert_eq!(decompressed.as_slice(), data);
744    }
745
746    #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
747    mod zstd_dict_tests {
748        use super::*;
749        use crate::compression::zstd::{MAX_DICT_SIZE, N_TRAIN, ZstdDictCompressor};
750        use crate::security::CompressionBombConfig;
751        use std::sync::Arc;
752
753        fn repetitive_json() -> Vec<u8> {
754            let item = br#"{"id":1,"name":"test","value":42,"active":true}"#;
755            item.repeat(100)
756        }
757
758        fn trained_dict() -> crate::compression::zstd::ZstdDictionary {
759            let samples: Vec<Vec<u8>> = (0..N_TRAIN)
760                .map(|i| {
761                    format!(
762                        r#"{{"id":{i},"name":"item-{i}","value":{},"active":true}}"#,
763                        i * 10
764                    )
765                    .into_bytes()
766                })
767                .collect();
768            ZstdDictCompressor::train(&samples, MAX_DICT_SIZE).unwrap()
769        }
770
771        #[test]
772        fn test_zstd_dict_roundtrip_via_secure_compressor() {
773            let dict = Arc::new(trained_dict());
774            let compressor =
775                SecureCompressor::with_default_security(ByteCodec::ZstdDict(dict.clone()));
776            let data = repetitive_json();
777
778            let compressed = compressor.compress(&data).unwrap();
779            assert!(matches!(compressed.codec, ByteCodec::ZstdDict(_)));
780            assert!(
781                compressed.data.len() < data.len(),
782                "zstd dict must reduce size on repetitive data"
783            );
784
785            let decompressed = compressor.decompress_protected(&compressed).unwrap();
786            assert_eq!(decompressed, data);
787        }
788
789        #[test]
790        fn test_zstd_dict_bomb_detection() {
791            let dict = Arc::new(trained_dict());
792            let producer =
793                SecureCompressor::with_default_security(ByteCodec::ZstdDict(dict.clone()));
794            let data = repetitive_json();
795            let compressed = producer.compress(&data).unwrap();
796
797            let config = CompressionBombConfig {
798                max_decompressed_size: 200,
799                max_compressed_size: 10_000,
800                max_ratio: 300.0,
801                check_interval_bytes: 64,
802                ..Default::default()
803            };
804            let strict = SecureCompressor::new(
805                crate::security::CompressionBombDetector::new(config),
806                ByteCodec::ZstdDict(dict),
807            );
808            let result = strict.decompress_protected(&compressed);
809            assert!(
810                result.is_err(),
811                "bomb detector must block oversized zstd dict output"
812            );
813        }
814
815        #[test]
816        fn test_zstd_dict_codec_mismatch_errors() {
817            let dict = Arc::new(trained_dict());
818            let c = SecureCompressor::with_default_security(ByteCodec::ZstdDict(dict));
819            let data = b"codec mismatch test data";
820            let mut compressed = c.compress(data).unwrap();
821            // Lie about the codec — decoding as Gzip must fail.
822            compressed.codec = ByteCodec::Gzip;
823            assert!(
824                c.decompress_protected(&compressed).is_err(),
825                "wrong codec must produce an error"
826            );
827        }
828
829        #[test]
830        fn test_zstd_dict_empty_payload_roundtrip() {
831            let dict = Arc::new(trained_dict());
832            let c = SecureCompressor::with_default_security(ByteCodec::ZstdDict(dict));
833            let compressed = c.compress(b"").unwrap();
834            let decompressed = c.decompress_protected(&compressed).unwrap();
835            assert_eq!(decompressed, b"");
836        }
837
838        #[test]
839        fn test_zstd_dict_wrong_dictionary_errors() {
840            // Build two independent dictionaries from distinct corpora.
841            let samples_a: Vec<Vec<u8>> = (0..N_TRAIN)
842                .map(|i| format!(r#"{{"corpus":"alpha","id":{i},"score":{}}}"#, i * 7).into_bytes())
843                .collect();
844            let samples_b: Vec<Vec<u8>> = (0..N_TRAIN)
845                .map(|i| format!(r#"{{"corpus":"beta","seq":{i},"label":"x-{i}"}}"#).into_bytes())
846                .collect();
847
848            let dict_a = ZstdDictCompressor::train(&samples_a, MAX_DICT_SIZE).unwrap();
849            let dict_b = ZstdDictCompressor::train(&samples_b, MAX_DICT_SIZE).unwrap();
850
851            let data = b"some representative payload data";
852            let compressed =
853                ZstdDictCompressor::compress(data, &dict_a).expect("compress with dict_a");
854
855            // Decompressing dict_a-compressed bytes with dict_b must fail at libzstd level.
856            let result = ZstdDictCompressor::decompress(&compressed, &dict_b, data.len() * 4);
857            assert!(
858                result.is_err(),
859                "wrong dictionary must produce a libzstd error"
860            );
861        }
862    }
863
864    #[cfg(feature = "compression")]
865    mod extended_compression_tests {
866        use super::*;
867
868        // Non-repetitive payload: pseudo-random bytes unlikely to compress well.
869        fn incompressible_payload() -> Vec<u8> {
870            // Simple LCG to generate pseudo-random bytes without extra deps.
871            let mut state: u64 = 0x_dead_beef_cafe_babe;
872            (0..512)
873                .map(|_| {
874                    state = state
875                        .wrapping_mul(6_364_136_223_846_793_005)
876                        .wrapping_add(1);
877                    (state >> 33) as u8
878                })
879                .collect()
880        }
881
882        #[test]
883        fn test_deflate_roundtrip_incompressible() {
884            let c = SecureCompressor::with_default_security(ByteCodec::Deflate);
885            let data = incompressible_payload();
886            let compressed = c.compress(&data).unwrap();
887            assert_eq!(compressed.codec, ByteCodec::Deflate);
888            let decompressed = c.decompress_protected(&compressed).unwrap();
889            assert_eq!(decompressed, data);
890        }
891
892        #[test]
893        fn test_gzip_roundtrip_incompressible() {
894            let c = SecureCompressor::with_default_security(ByteCodec::Gzip);
895            let data = incompressible_payload();
896            let compressed = c.compress(&data).unwrap();
897            assert_eq!(compressed.codec, ByteCodec::Gzip);
898            let decompressed = c.decompress_protected(&compressed).unwrap();
899            assert_eq!(decompressed, data);
900        }
901
902        #[test]
903        fn test_brotli_roundtrip_incompressible() {
904            let c = SecureCompressor::with_default_security(ByteCodec::Brotli);
905            let data = incompressible_payload();
906            let compressed = c.compress(&data).unwrap();
907            assert_eq!(compressed.codec, ByteCodec::Brotli);
908            let decompressed = c.decompress_protected(&compressed).unwrap();
909            assert_eq!(decompressed, data);
910        }
911
912        #[test]
913        fn test_gzip_all_qualities() {
914            let item = br#"{"id":1,"name":"test","value":42}"#;
915            let data: Vec<u8> = item.repeat(50);
916            for quality in [
917                CompressionQuality::Fast,
918                CompressionQuality::Balanced,
919                CompressionQuality::Best,
920            ] {
921                let c = SecureCompressor::with_quality(
922                    CompressionBombDetector::default(),
923                    ByteCodec::Gzip,
924                    quality,
925                );
926                let compressed = c.compress(&data).unwrap();
927                let decompressed = c.decompress_protected(&compressed).unwrap();
928                assert_eq!(
929                    decompressed, data,
930                    "gzip quality {quality:?} roundtrip failed"
931                );
932            }
933        }
934
935        #[test]
936        fn test_brotli_balanced_quality() {
937            let item = br#"{"key":"value","n":99}"#;
938            let data: Vec<u8> = item.repeat(80);
939            let c = SecureCompressor::with_quality(
940                CompressionBombDetector::default(),
941                ByteCodec::Brotli,
942                CompressionQuality::Balanced,
943            );
944            let compressed = c.compress(&data).unwrap();
945            let decompressed = c.decompress_protected(&compressed).unwrap();
946            assert_eq!(decompressed, data);
947        }
948
949        #[test]
950        fn test_decompress_nested_with_depth() {
951            let c = SecureCompressor::with_default_security(ByteCodec::Deflate);
952            let item = br#"{"x":1}"#;
953            let data: Vec<u8> = item.repeat(100);
954            let compressed = c.compress(&data).unwrap();
955            let decompressed = c.decompress_nested(&compressed, 1).unwrap();
956            assert_eq!(decompressed, data);
957        }
958
959        #[test]
960        fn test_decompress_nested_depth_exceeded_returns_error() {
961            use crate::security::CompressionBombConfig;
962            let config = CompressionBombConfig {
963                max_compression_depth: 2,
964                ..Default::default()
965            };
966            let c = SecureCompressor::new(CompressionBombDetector::new(config), ByteCodec::Deflate);
967            let item = br#"{"x":1}"#;
968            let data: Vec<u8> = item.repeat(100);
969            let compressed = c.compress(&data).unwrap();
970            // depth 3 exceeds max_compression_depth 2 — must error.
971            let result = c.decompress_nested(&compressed, 3);
972            assert!(result.is_err(), "depth beyond limit must return an error");
973        }
974
975        #[test]
976        fn test_bomb_detection_deflate() {
977            use crate::security::CompressionBombConfig;
978            let config = CompressionBombConfig {
979                max_decompressed_size: 200,
980                max_compressed_size: 10_000,
981                max_ratio: 300.0,
982                check_interval_bytes: 64,
983                ..Default::default()
984            };
985            let item = br#"{"id":1,"name":"test","value":42,"active":true}"#;
986            let data: Vec<u8> = item.repeat(100);
987            let producer = SecureCompressor::with_default_security(ByteCodec::Deflate);
988            let compressed = producer.compress(&data).unwrap();
989
990            let strict =
991                SecureCompressor::new(CompressionBombDetector::new(config), ByteCodec::Deflate);
992            let result = strict.decompress_protected(&compressed);
993            assert!(
994                result.is_err(),
995                "bomb detector must block oversized deflate output"
996            );
997        }
998
999        #[test]
1000        fn test_bomb_detection_brotli() {
1001            use crate::security::CompressionBombConfig;
1002            let config = CompressionBombConfig {
1003                max_decompressed_size: 200,
1004                max_compressed_size: 10_000,
1005                max_ratio: 300.0,
1006                check_interval_bytes: 64,
1007                ..Default::default()
1008            };
1009            let item = br#"{"id":1,"name":"test","value":42,"active":true}"#;
1010            let data: Vec<u8> = item.repeat(100);
1011            let producer = SecureCompressor::with_default_security(ByteCodec::Brotli);
1012            let compressed = producer.compress(&data).unwrap();
1013
1014            let strict =
1015                SecureCompressor::new(CompressionBombDetector::new(config), ByteCodec::Brotli);
1016            let result = strict.decompress_protected(&compressed);
1017            assert!(
1018                result.is_err(),
1019                "bomb detector must block oversized brotli output"
1020            );
1021        }
1022
1023        #[test]
1024        fn test_codec_mismatch_deflate_as_gzip() {
1025            let c = SecureCompressor::with_default_security(ByteCodec::Deflate);
1026            let data = b"deflate mismatch test payload";
1027            let mut compressed = c.compress(data).unwrap();
1028            compressed.codec = ByteCodec::Gzip;
1029            let result = c.decompress_protected(&compressed);
1030            assert!(result.is_err(), "Deflate data decoded as Gzip must fail");
1031        }
1032
1033        #[test]
1034        fn test_empty_payload_all_codecs() {
1035            for codec in [ByteCodec::Deflate, ByteCodec::Gzip, ByteCodec::Brotli] {
1036                let label = format!("{codec:?}");
1037                let c = SecureCompressor::with_default_security(codec);
1038                let compressed = c.compress(b"").unwrap();
1039                let decompressed = c.decompress_protected(&compressed).unwrap();
1040                assert_eq!(decompressed, b"", "empty roundtrip failed for {label}");
1041            }
1042        }
1043    }
1044}