Skip to main content

summa_core/compression/
zstd.rs

1//! Zstd compression backend with dictionary support
2//!
3//! For static indexes, we use:
4//! - Maximum compression level (22) for best compression ratio
5//! - Trained dictionaries for even better compression of similar documents
6//! - Larger block sizes to improve compression efficiency
7
8use std::io;
9use std::io::Read;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12static NEXT_DICTIONARY_ID: AtomicU64 = AtomicU64::new(1);
13
14/// Compression level (1-22 for zstd)
15#[derive(Debug, Clone, Copy)]
16pub struct CompressionLevel(pub i32);
17
18impl CompressionLevel {
19    /// Fast compression (level 1)
20    pub const FAST: Self = Self(1);
21    /// Default compression (level 3)
22    pub const DEFAULT: Self = Self(3);
23    /// Better compression (level 9)
24    pub const BETTER: Self = Self(9);
25    /// Best compression (level 19)
26    pub const BEST: Self = Self(19);
27    /// Maximum compression (level 22) - slowest but smallest
28    pub const MAX: Self = Self(22);
29}
30
31impl Default for CompressionLevel {
32    fn default() -> Self {
33        Self::FAST // Level 3: good balance of speed and compression
34    }
35}
36
37/// Trained Zstd dictionary for improved compression
38#[derive(Clone)]
39pub struct CompressionDict {
40    raw_dict: crate::directories::OwnedBytes,
41    /// Stable across clones and never derived from an allocator address. The
42    /// thread-local codec caches can outlive a dictionary, so raw pointers are
43    /// vulnerable to allocator ABA reuse.
44    cache_id: u64,
45}
46
47impl CompressionDict {
48    /// Train a dictionary from sample data
49    ///
50    /// For best results, provide many small samples (e.g., serialized documents)
51    /// The dictionary size should typically be 16KB-112KB
52    pub fn train(samples: &[&[u8]], dict_size: usize) -> io::Result<Self> {
53        let raw_dict = zstd::dict::from_samples(samples, dict_size).map_err(io::Error::other)?;
54        Ok(Self {
55            raw_dict: crate::directories::OwnedBytes::new(raw_dict),
56            cache_id: NEXT_DICTIONARY_ID.fetch_add(1, Ordering::Relaxed),
57        })
58    }
59
60    /// Create dictionary from raw bytes (for loading saved dictionaries)
61    pub fn from_bytes(bytes: Vec<u8>) -> Self {
62        Self {
63            raw_dict: crate::directories::OwnedBytes::new(bytes),
64            cache_id: NEXT_DICTIONARY_ID.fetch_add(1, Ordering::Relaxed),
65        }
66    }
67
68    /// Create dictionary from OwnedBytes (zero-copy for mmap)
69    pub fn from_owned_bytes(bytes: crate::directories::OwnedBytes) -> Self {
70        Self {
71            raw_dict: bytes,
72            cache_id: NEXT_DICTIONARY_ID.fetch_add(1, Ordering::Relaxed),
73        }
74    }
75
76    /// Get raw dictionary bytes (for saving)
77    pub fn as_bytes(&self) -> &[u8] {
78        self.raw_dict.as_slice()
79    }
80
81    /// Dictionary size in bytes
82    pub fn len(&self) -> usize {
83        self.raw_dict.len()
84    }
85
86    /// Check if dictionary is empty
87    pub fn is_empty(&self) -> bool {
88        self.raw_dict.is_empty()
89    }
90
91    #[inline]
92    fn cache_id(&self) -> u64 {
93        self.cache_id
94    }
95}
96
97/// Compress data using Zstd
98///
99/// Uses a thread-local bulk compressor to avoid per-call encoder allocation.
100/// Only rebuilds when the compression level changes.
101pub fn compress(data: &[u8], level: CompressionLevel) -> io::Result<Vec<u8>> {
102    thread_local! {
103        static COMPRESSOR: std::cell::RefCell<Option<(i32, zstd::bulk::Compressor<'static>)>> =
104            const { std::cell::RefCell::new(None) };
105    }
106    COMPRESSOR.with(|cell| {
107        let mut slot = cell.borrow_mut();
108        if slot.as_ref().is_none_or(|(l, _)| *l != level.0) {
109            let cmp = zstd::bulk::Compressor::new(level.0).map_err(io::Error::other)?;
110            *slot = Some((level.0, cmp));
111        }
112        slot.as_mut()
113            .unwrap()
114            .1
115            .compress(data)
116            .map_err(io::Error::other)
117    })
118}
119
120/// Compress data using Zstd with a trained dictionary
121///
122/// Caches the dictionary compressor in a thread-local, keyed by dictionary
123/// pointer + compression level. Only rebuilt when dict or level changes.
124pub fn compress_with_dict(
125    data: &[u8],
126    level: CompressionLevel,
127    dict: &CompressionDict,
128) -> io::Result<Vec<u8>> {
129    thread_local! {
130        static DICT_CMP: std::cell::RefCell<Option<(u64, i32, zstd::bulk::Compressor<'static>)>> =
131            const { std::cell::RefCell::new(None) };
132    }
133    let dict_key = dict.cache_id();
134
135    DICT_CMP.with(|cell| {
136        let mut slot = cell.borrow_mut();
137        if slot
138            .as_ref()
139            .is_none_or(|(k, l, _)| *k != dict_key || *l != level.0)
140        {
141            let cmp = zstd::bulk::Compressor::with_dictionary(level.0, dict.as_bytes())
142                .map_err(io::Error::other)?;
143            *slot = Some((dict_key, level.0, cmp));
144        }
145        slot.as_mut()
146            .unwrap()
147            .2
148            .compress(data)
149            .map_err(io::Error::other)
150    })
151}
152
153/// Capacity hint for bulk decompressor (covers typical 256KB store blocks).
154/// Blocks that decompress larger than this fall back to streaming decode.
155const DECOMPRESS_CAPACITY: usize = 512 * 1024;
156
157/// Decompress data using Zstd
158///
159/// Fast path: reuses a thread-local bulk `Decompressor` with a 512KB
160/// capacity hint. Falls back to streaming decode for oversized blocks.
161pub fn decompress(data: &[u8]) -> io::Result<Vec<u8>> {
162    thread_local! {
163        static DECOMPRESSOR: std::cell::RefCell<zstd::bulk::Decompressor<'static>> =
164            std::cell::RefCell::new(zstd::bulk::Decompressor::new().unwrap());
165    }
166    DECOMPRESSOR.with(|dc| {
167        dc.borrow_mut()
168            .decompress(data, DECOMPRESS_CAPACITY)
169            .or_else(|_| zstd::decode_all(data))
170    })
171}
172
173/// The stable first-frame size is a capacity hint, not validation. A stream
174/// can contain more frames, so a short bulk attempt still needs the bounded
175/// streaming fallback. Unknown sizes never reserve the entire safety limit.
176fn limited_bulk_capacity(data: &[u8], max_output: usize) -> io::Result<usize> {
177    match zstd::zstd_safe::get_frame_content_size(data) {
178        Ok(Some(size)) => {
179            let size = usize::try_from(size).map_err(|_| {
180                io::Error::new(
181                    io::ErrorKind::InvalidData,
182                    "decompressed size exceeds address space",
183                )
184            })?;
185            if size > max_output {
186                return Err(io::Error::new(
187                    io::ErrorKind::InvalidData,
188                    "decompressed data exceeds configured limit",
189                ));
190            }
191            Ok(size)
192        }
193        // Let the actual decoder report malformed/truncated input. Empty and
194        // size-less streams retain their existing library-defined semantics.
195        Ok(None) | Err(_) => Ok(max_output.min(DECOMPRESS_CAPACITY)),
196    }
197}
198
199/// Decompress while rejecting output larger than `max_output` bytes.
200///
201/// Index files are trusted only after validation. Using an explicit bound at
202/// compressed block boundaries prevents a tiny corrupt frame from expanding
203/// until the process runs out of memory.
204pub fn decompress_limited(data: &[u8], max_output: usize) -> io::Result<Vec<u8>> {
205    let capacity = limited_bulk_capacity(data, max_output)?;
206    thread_local! {
207        static DECOMPRESSOR: std::cell::RefCell<zstd::bulk::Decompressor<'static>> =
208            std::cell::RefCell::new(zstd::bulk::Decompressor::new().unwrap());
209    }
210    DECOMPRESSOR.with(|dc| {
211        dc.borrow_mut().decompress(data, capacity).or_else(|_| {
212            let decoder = zstd::Decoder::new(data)?;
213            read_limited(decoder, max_output)
214        })
215    })
216}
217
218/// Decompress data using Zstd with a trained dictionary
219///
220/// Caches the dictionary decompressor in a thread-local, keyed by the
221/// dictionary's data pointer. Since a given `AsyncStoreReader` always holds
222/// the same `CompressionDict` (behind `Arc<OwnedBytes>`), the pointer is
223/// stable for the reader's lifetime. The decompressor is only rebuilt when
224/// a different dictionary is encountered (e.g., switching between segments).
225pub fn decompress_with_dict(data: &[u8], dict: &CompressionDict) -> io::Result<Vec<u8>> {
226    thread_local! {
227        static DICT_DC: std::cell::RefCell<Option<(u64, zstd::bulk::Decompressor<'static>)>> =
228            const { std::cell::RefCell::new(None) };
229    }
230    // Use the raw dict slice pointer as a stable identity key.
231    let dict_key = dict.cache_id();
232
233    DICT_DC.with(|cell| {
234        let mut slot = cell.borrow_mut();
235        // Rebuild decompressor only if dict changed
236        if slot.as_ref().is_none_or(|(k, _)| *k != dict_key) {
237            let dc = zstd::bulk::Decompressor::with_dictionary(dict.as_bytes())
238                .map_err(io::Error::other)?;
239            *slot = Some((dict_key, dc));
240        }
241        slot.as_mut()
242            .unwrap()
243            .1
244            .decompress(data, DECOMPRESS_CAPACITY)
245            .or_else(|_| {
246                let mut decoder = zstd::Decoder::with_dictionary(data, dict.as_bytes())?;
247                let mut output = Vec::new();
248                io::Read::read_to_end(&mut decoder, &mut output)?;
249                Ok(output)
250            })
251    })
252}
253
254/// Dictionary variant of [`decompress_limited`].
255pub fn decompress_with_dict_limited(
256    data: &[u8],
257    dict: &CompressionDict,
258    max_output: usize,
259) -> io::Result<Vec<u8>> {
260    let capacity = limited_bulk_capacity(data, max_output)?;
261    thread_local! {
262        static DICT_DC: std::cell::RefCell<Option<(u64, zstd::bulk::Decompressor<'static>)>> =
263            const { std::cell::RefCell::new(None) };
264    }
265    let dict_key = dict.cache_id();
266
267    DICT_DC.with(|cell| {
268        let mut slot = cell.borrow_mut();
269        if slot.as_ref().is_none_or(|(key, _)| *key != dict_key) {
270            let dc = zstd::bulk::Decompressor::with_dictionary(dict.as_bytes())
271                .map_err(io::Error::other)?;
272            *slot = Some((dict_key, dc));
273        }
274        slot.as_mut()
275            .unwrap()
276            .1
277            .decompress(data, capacity)
278            .or_else(|_| {
279                let decoder = zstd::Decoder::with_dictionary(data, dict.as_bytes())?;
280                read_limited(decoder, max_output)
281            })
282    })
283}
284
285fn read_limited(mut reader: impl Read, max_output: usize) -> io::Result<Vec<u8>> {
286    let read_limit = u64::try_from(max_output)
287        .unwrap_or(u64::MAX)
288        .saturating_add(1);
289    let initial_capacity = max_output.min(DECOMPRESS_CAPACITY);
290    let mut output = Vec::with_capacity(initial_capacity);
291    reader.by_ref().take(read_limit).read_to_end(&mut output)?;
292    if output.len() > max_output {
293        return Err(io::Error::new(
294            io::ErrorKind::InvalidData,
295            "decompressed data exceeds configured limit",
296        ));
297    }
298    Ok(output)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_roundtrip() {
307        let data = b"Hello, World! This is a test of compression.".repeat(100);
308        let compressed = compress(&data, CompressionLevel::default()).unwrap();
309        let decompressed = decompress(&compressed).unwrap();
310        assert_eq!(data, decompressed.as_slice());
311        assert!(compressed.len() < data.len());
312    }
313
314    #[test]
315    fn test_empty_data() {
316        let data: &[u8] = &[];
317        let compressed = compress(data, CompressionLevel::default()).unwrap();
318        let decompressed = decompress(&compressed).unwrap();
319        assert!(decompressed.is_empty());
320    }
321
322    #[test]
323    fn test_compression_levels() {
324        let data = b"Test data for compression levels".repeat(100);
325        for level in [1, 3, 9, 19] {
326            let compressed = compress(&data, CompressionLevel(level)).unwrap();
327            let decompressed = decompress(&compressed).unwrap();
328            assert_eq!(data.as_slice(), decompressed.as_slice());
329        }
330    }
331
332    #[test]
333    fn test_limited_decompression_rejects_oversized_output() {
334        let data = vec![7u8; 4096];
335        let compressed = compress(&data, CompressionLevel::default()).unwrap();
336        assert!(decompress_limited(&compressed, 1024).is_err());
337        assert_eq!(decompress_limited(&compressed, data.len()).unwrap(), data);
338    }
339
340    #[test]
341    fn bounded_small_block_decoding_does_not_reserve_the_safety_limit() {
342        let payload = vec![7u8; 16 * 1024];
343        let limit = 256 * 1024 * 1024;
344        let compressed = compress(&payload, CompressionLevel::default()).unwrap();
345        let output = decompress_limited(&compressed, limit).unwrap();
346        assert_eq!(output, payload);
347        assert!(
348            output.capacity() <= payload.len(),
349            "small block reserved {} bytes",
350            output.capacity()
351        );
352        let dict = CompressionDict::from_bytes(b"dictionary material".repeat(64));
353        let compressed = compress_with_dict(&payload, CompressionLevel::default(), &dict).unwrap();
354        let output = decompress_with_dict_limited(&compressed, &dict, limit).unwrap();
355        assert_eq!(output, payload);
356        assert!(output.capacity() <= payload.len());
357    }
358
359    #[test]
360    fn bounded_unknown_size_frames_preserve_large_output_and_reject_overflow() {
361        let payload = vec![17u8; DECOMPRESS_CAPACITY * 2];
362        let compressed = zstd::stream::encode_all(payload.as_slice(), 3).unwrap();
363        assert_eq!(
364            zstd::zstd_safe::get_frame_content_size(&compressed).unwrap(),
365            None
366        );
367        assert_eq!(
368            decompress_limited(&compressed, payload.len()).unwrap(),
369            payload
370        );
371        assert!(decompress_limited(&compressed, payload.len() - 1).is_err());
372        let dict = CompressionDict::from_bytes(b"dictionary material".repeat(64));
373        let mut encoder =
374            zstd::stream::Encoder::with_dictionary(Vec::new(), 3, dict.as_bytes()).unwrap();
375        std::io::Write::write_all(&mut encoder, &payload).unwrap();
376        let compressed = encoder.finish().unwrap();
377        assert_eq!(
378            decompress_with_dict_limited(&compressed, &dict, payload.len()).unwrap(),
379            payload
380        );
381        assert!(decompress_with_dict_limited(&compressed, &dict, payload.len() - 1).is_err());
382    }
383
384    #[test]
385    fn test_limited_dictionary_decompression_rejects_oversized_output() {
386        let dict = CompressionDict::from_bytes(b"seven seven seven ".repeat(64));
387        let data = b"seven ".repeat(1024);
388        let compressed = compress_with_dict(&data, CompressionLevel::default(), &dict).unwrap();
389        // Known content size: rejected before any decompression buffer grows.
390        assert!(decompress_with_dict_limited(&compressed, &dict, 1024).is_err());
391        assert!(decompress_with_dict_limited(&compressed, &dict, data.len() - 1).is_err());
392        assert_eq!(
393            decompress_with_dict_limited(&compressed, &dict, data.len()).unwrap(),
394            data
395        );
396        // Unknown content size: the streaming fallback enforces the same cap.
397        let mut encoder = zstd::Encoder::with_dictionary(Vec::new(), 3, dict.as_bytes()).unwrap();
398        encoder.include_contentsize(false).unwrap();
399        std::io::Write::write_all(&mut encoder, &data).unwrap();
400        let unknown = encoder.finish().unwrap();
401        assert_eq!(
402            zstd::zstd_safe::get_frame_content_size(&unknown).unwrap(),
403            None
404        );
405        assert!(decompress_with_dict_limited(&unknown, &dict, 1024).is_err());
406        assert!(decompress_with_dict_limited(&unknown, &dict, data.len() - 1).is_err());
407        assert_eq!(
408            decompress_with_dict_limited(&unknown, &dict, data.len()).unwrap(),
409            data
410        );
411    }
412
413    #[test]
414    fn test_dictionary_cache_identity_is_stable_and_unique() {
415        let first = CompressionDict::from_bytes(b"first dictionary material".repeat(16));
416        let first_clone = first.clone();
417        let second = CompressionDict::from_bytes(b"second dictionary material".repeat(16));
418        assert_eq!(first.cache_id(), first_clone.cache_id());
419        assert_ne!(first.cache_id(), second.cache_id());
420
421        let payload = b"dictionary cache switches must rebuild their codec state".repeat(64);
422        for dict in [&first, &second, &first_clone] {
423            let compressed =
424                compress_with_dict(&payload, CompressionLevel::default(), dict).unwrap();
425            assert_eq!(
426                decompress_with_dict_limited(&compressed, dict, payload.len()).unwrap(),
427                payload
428            );
429        }
430    }
431}
432
433#[cfg(test)]
434mod bounded_capacity_tests {
435    use super::*;
436
437    #[test]
438    fn small_bounded_zstd_frames_do_not_reserve_the_entire_safety_limit() {
439        let payload = b"small independently compressed dictionary block".repeat(20);
440        let encoded = compress(&payload, CompressionLevel::BETTER).unwrap();
441        let decoded = decompress_limited(&encoded, 64 * 1024 * 1024).unwrap();
442        assert_eq!(decoded, payload);
443        assert!(
444            decoded.capacity() <= 4096,
445            "capacity={}",
446            decoded.capacity()
447        );
448    }
449
450    #[test]
451    fn small_dictionary_frames_do_not_reserve_the_entire_safety_limit() {
452        let dict = CompressionDict::from_bytes(b"dictionary material for small blocks".repeat(20));
453        let payload = b"dictionary material for small blocks and metadata".repeat(20);
454        let encoded = compress_with_dict(&payload, CompressionLevel::BETTER, &dict).unwrap();
455        let decoded = decompress_with_dict_limited(&encoded, &dict, 64 * 1024 * 1024).unwrap();
456        assert_eq!(decoded, payload);
457        assert!(
458            decoded.capacity() <= 4096,
459            "capacity={}",
460            decoded.capacity()
461        );
462    }
463}
464
465#[cfg(test)]
466mod bounded_frame_tests {
467    use super::*;
468    use std::io::Write;
469
470    fn assert_plain_and_dictionary(encoded: &[u8], expected: &[u8], dict: &CompressionDict) {
471        assert_eq!(
472            decompress_limited(encoded, expected.len()).unwrap(),
473            expected
474        );
475        assert_eq!(
476            decompress_with_dict_limited(encoded, dict, expected.len()).unwrap(),
477            expected
478        );
479        if !expected.is_empty() {
480            assert!(decompress_limited(encoded, expected.len() - 1).is_err());
481            assert!(decompress_with_dict_limited(encoded, dict, expected.len() - 1).is_err());
482        }
483    }
484
485    #[test]
486    fn bounded_zstd_decoding_preserves_empty_unknown_size_and_concatenated_frames() {
487        let dict = CompressionDict::from_bytes(Vec::new());
488        let empty = compress(&[], CompressionLevel::FAST).unwrap();
489        assert_plain_and_dictionary(&empty, &[], &dict);
490        let payload = vec![77u8; DECOMPRESS_CAPACITY * 2 + 17];
491        let mut encoder = zstd::Encoder::new(Vec::new(), 1).unwrap();
492        encoder.include_contentsize(false).unwrap();
493        encoder.write_all(&payload).unwrap();
494        let unknown = encoder.finish().unwrap();
495        assert_eq!(
496            zstd::zstd_safe::get_frame_content_size(&unknown).unwrap(),
497            None
498        );
499        assert_eq!(
500            limited_bulk_capacity(&unknown, 64 * 1024 * 1024).unwrap(),
501            DECOMPRESS_CAPACITY
502        );
503        assert_plain_and_dictionary(&unknown, &payload, &dict);
504        let first = compress(b"first", CompressionLevel::FAST).unwrap();
505        let second = compress(b"second", CompressionLevel::FAST).unwrap();
506        let joined = [first.as_slice(), second.as_slice()].concat();
507        assert_eq!(zstd::decode_all(joined.as_slice()).unwrap(), b"firstsecond");
508        assert_plain_and_dictionary(&joined, b"firstsecond", &dict);
509        // Skippable frame is a standard Zstd frame with an eight-byte header.
510        let skipped = [
511            0x184D2A50u32.to_le_bytes().as_slice(),
512            3u32.to_le_bytes().as_slice(),
513            b"tag",
514            second.as_slice(),
515        ]
516        .concat();
517        assert_eq!(zstd::decode_all(skipped.as_slice()).unwrap(), b"second");
518        assert_plain_and_dictionary(&skipped, b"second", &dict);
519    }
520
521    #[test]
522    fn bounded_zstd_decoding_does_not_hide_truncation_checksum_or_trailing_corruption() {
523        let payload = b"known size does not prove frame integrity".repeat(100);
524        let mut encoder = zstd::Encoder::new(Vec::new(), 1).unwrap();
525        encoder.include_checksum(true).unwrap();
526        encoder.write_all(&payload).unwrap();
527        let good = encoder.finish().unwrap();
528        let mut bad_checksum = good.clone();
529        *bad_checksum.last_mut().unwrap() ^= 1;
530        let mut trailing = good.clone();
531        trailing.extend_from_slice(b"bad trailing frame");
532        for broken in [
533            &good[..good.len() - 1],
534            bad_checksum.as_slice(),
535            trailing.as_slice(),
536        ] {
537            assert!(zstd::decode_all(broken).is_err());
538            assert!(decompress_limited(broken, payload.len()).is_err());
539            let dict = CompressionDict::from_bytes(Vec::new());
540            assert!(decompress_with_dict_limited(broken, &dict, payload.len()).is_err());
541            assert_eq!(decompress_limited(&good, payload.len()).unwrap(), payload);
542            assert_eq!(
543                decompress_with_dict_limited(&good, &dict, payload.len()).unwrap(),
544                payload
545            );
546        }
547    }
548
549    #[test]
550    fn bounded_dictionary_decoding_preserves_concatenation_and_dictionary_switches() {
551        let first = CompressionDict::from_bytes(b"first raw dictionary contents".repeat(20));
552        let second = CompressionDict::from_bytes(b"second raw dictionary contents".repeat(20));
553        for dict in [&first, &second, &first] {
554            let a = compress_with_dict(
555                b"first raw dictionary contents",
556                CompressionLevel::FAST,
557                dict,
558            )
559            .unwrap();
560            let b = compress_with_dict(
561                b"second raw dictionary contents",
562                CompressionLevel::FAST,
563                dict,
564            )
565            .unwrap();
566            let joined = [a, b].concat();
567            let expected = b"first raw dictionary contentssecond raw dictionary contents";
568            assert_eq!(
569                decompress_with_dict_limited(&joined, dict, expected.len()).unwrap(),
570                expected
571            );
572            assert!(decompress_with_dict_limited(&joined, dict, expected.len() - 1).is_err());
573        }
574    }
575}