Skip to main content

nodedb_codec/
zstd_codec.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Zstd compression codec for cold/archived partitions.
4//!
5//! Higher compression ratio than LZ4 (~5-10x for structured data), slower
6//! decompression. Best for sealed partitions that are read infrequently.
7//!
8//! Platform strategy:
9//! - Native: `zstd` crate (C libzstd, fastest)
10//! - WASM: `ruzstd` crate (pure Rust decoder, no C dependency)
11//!
12//! Wire format:
13//! ```text
14//! [4 bytes] uncompressed size (LE u32)
15//! [1 byte]  compression level used
16//! [N bytes] Zstd frame (standard format, decodable by any Zstd implementation)
17//! ```
18//!
19//! The 5-byte header prepended to the standard Zstd frame allows us to
20//! pre-allocate the output buffer on decode and store the level for metadata.
21
22use std::io::Read;
23
24use crate::bounds::{
25    MAX_DECODED_BYTES, MAX_DECOMPRESSION_RATIO, checked_add, checked_mul, decoded_len,
26    encode_input_len, u32_to_usize,
27};
28use crate::error::CodecError;
29
30/// Default Zstd compression level (3 = good balance of speed and ratio).
31pub const DEFAULT_LEVEL: i32 = 3;
32
33/// High compression level for cold storage (19 = near-maximum ratio).
34pub const HIGH_LEVEL: i32 = 19;
35
36/// Header size: 4 bytes uncompressed size + 1 byte level.
37const HEADER_SIZE: usize = 5;
38const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
39const MAX_ZSTD_WINDOW_LOG: u32 = 26;
40
41// ---------------------------------------------------------------------------
42// Public encode / decode API
43// ---------------------------------------------------------------------------
44
45/// Compress raw bytes using Zstd at the default level (3).
46pub fn encode(data: &[u8]) -> Result<Vec<u8>, CodecError> {
47    encode_with_level(data, DEFAULT_LEVEL)
48}
49
50/// Compress raw bytes using Zstd at a specific level (1-22).
51pub fn encode_with_level(data: &[u8], level: i32) -> Result<Vec<u8>, CodecError> {
52    let encoded_len = encode_input_len(data.len(), "Zstd")?;
53    let level = level.clamp(1, 22);
54
55    let compressed = compress_native(data, level)?;
56
57    let mut out = Vec::with_capacity(HEADER_SIZE + compressed.len());
58    out.extend_from_slice(&encoded_len.to_le_bytes());
59    out.push(level as u8);
60    out.extend_from_slice(&compressed);
61    Ok(out)
62}
63
64/// Decompress Zstd-compressed bytes.
65pub fn decode(data: &[u8]) -> Result<Vec<u8>, CodecError> {
66    if data.len() < HEADER_SIZE {
67        return Err(CodecError::Truncated {
68            expected: HEADER_SIZE,
69            actual: data.len(),
70        });
71    }
72
73    let uncompressed_size = decoded_len(
74        u32_to_usize(
75            u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
76            "Zstd output",
77        )?,
78        "Zstd",
79    )?;
80    let frame = &data[HEADER_SIZE..];
81    if frame.is_empty() && uncompressed_size != 0 {
82        return Err(CodecError::Truncated {
83            expected: HEADER_SIZE + 1,
84            actual: data.len(),
85        });
86    }
87    validate_frame_window(frame, uncompressed_size)?;
88    let ratio_limit = checked_mul(
89        frame.len().max(1),
90        MAX_DECOMPRESSION_RATIO,
91        "Zstd ratio limit",
92    )?;
93    if uncompressed_size > ratio_limit {
94        return Err(CodecError::ResourceLimit {
95            resource: "Zstd decompression ratio".into(),
96            requested: uncompressed_size,
97            limit: ratio_limit,
98        });
99    }
100    decompress_native(frame, uncompressed_size)
101}
102
103/// Get the uncompressed size from the header without decompressing.
104pub fn uncompressed_size(data: &[u8]) -> Result<usize, CodecError> {
105    if data.len() < HEADER_SIZE {
106        return Err(CodecError::Truncated {
107            expected: HEADER_SIZE,
108            actual: data.len(),
109        });
110    }
111    decoded_len(
112        u32_to_usize(
113            u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
114            "Zstd output",
115        )?,
116        "Zstd",
117    )
118}
119
120/// Get the compression level from the header.
121pub fn compression_level(data: &[u8]) -> Result<i32, CodecError> {
122    if data.len() < HEADER_SIZE {
123        return Err(CodecError::Truncated {
124            expected: HEADER_SIZE,
125            actual: data.len(),
126        });
127    }
128    Ok(data[4] as i32)
129}
130
131fn validate_frame_window(frame: &[u8], expected_size: usize) -> Result<(), CodecError> {
132    let magic = frame.get(..4).ok_or(CodecError::Truncated {
133        expected: 4,
134        actual: frame.len(),
135    })?;
136    if magic != ZSTD_MAGIC {
137        return Err(CodecError::Corrupt {
138            detail: "invalid Zstd frame magic".into(),
139        });
140    }
141    let descriptor = *frame.get(4).ok_or(CodecError::Truncated {
142        expected: 5,
143        actual: frame.len(),
144    })?;
145    if descriptor & 0x08 != 0 {
146        return Err(CodecError::Corrupt {
147            detail: "reserved Zstd frame-header bit is set".into(),
148        });
149    }
150
151    let single_segment = descriptor & 0x20 != 0;
152    let mut cursor = 5usize;
153    if !single_segment {
154        let window_descriptor = *frame.get(cursor).ok_or(CodecError::Truncated {
155            expected: cursor + 1,
156            actual: frame.len(),
157        })?;
158        cursor = checked_add(cursor, 1, "Zstd window descriptor")?;
159        let window_log = 10u32 + u32::from(window_descriptor >> 3);
160        if window_log > MAX_ZSTD_WINDOW_LOG {
161            return Err(CodecError::ResourceLimit {
162                resource: "Zstd window bytes".into(),
163                requested: usize::MAX,
164                limit: MAX_DECODED_BYTES,
165            });
166        }
167        let window_base = 1usize << window_log;
168        let window_add = checked_mul(
169            window_base / 8,
170            usize::from(window_descriptor & 0x07),
171            "Zstd window mantissa",
172        )?;
173        let window_size = checked_add(window_base, window_add, "Zstd window size")?;
174        decoded_len(window_size, "Zstd window")?;
175    }
176
177    let dictionary_id_size = match descriptor & 0x03 {
178        0 => 0,
179        1 => 1,
180        2 => 2,
181        _ => 4,
182    };
183    cursor = checked_add(cursor, dictionary_id_size, "Zstd dictionary id")?;
184    frame.get(..cursor).ok_or(CodecError::Truncated {
185        expected: cursor,
186        actual: frame.len(),
187    })?;
188
189    let content_flag = descriptor >> 6;
190    let content_size_len = match (content_flag, single_segment) {
191        (0, false) => 0,
192        (0, true) => 1,
193        (1, _) => 2,
194        (2, _) => 4,
195        _ => 8,
196    };
197    if content_size_len != 0 {
198        let end = checked_add(cursor, content_size_len, "Zstd content size")?;
199        let bytes = frame.get(cursor..end).ok_or(CodecError::Truncated {
200            expected: end,
201            actual: frame.len(),
202        })?;
203        let mut declared = 0u64;
204        for (shift, byte) in bytes.iter().enumerate() {
205            declared |= u64::from(*byte) << (shift * 8);
206        }
207        if content_size_len == 2 {
208            declared = declared
209                .checked_add(256)
210                .ok_or_else(|| CodecError::Corrupt {
211                    detail: "Zstd content size overflow".into(),
212                })?;
213        }
214        let declared = usize::try_from(declared).map_err(|_| CodecError::ResourceLimit {
215            resource: "Zstd declared content bytes".into(),
216            requested: usize::MAX,
217            limit: MAX_DECODED_BYTES,
218        })?;
219        decoded_len(declared, "Zstd declared content")?;
220        if declared != expected_size {
221            return Err(CodecError::Corrupt {
222                detail: format!(
223                    "Zstd frame content size {declared} differs from envelope {expected_size}"
224                ),
225            });
226        }
227    }
228    Ok(())
229}
230
231// ---------------------------------------------------------------------------
232// Platform-specific compression / decompression
233// ---------------------------------------------------------------------------
234
235#[cfg(not(target_arch = "wasm32"))]
236fn compress_native(data: &[u8], level: i32) -> Result<Vec<u8>, CodecError> {
237    use std::io::Write;
238
239    let mut encoder =
240        zstd::Encoder::new(Vec::new(), level).map_err(|error| CodecError::CompressFailed {
241            detail: format!("zstd encoder init: {error}"),
242        })?;
243    encoder
244        .window_log(MAX_ZSTD_WINDOW_LOG)
245        .map_err(|error| CodecError::CompressFailed {
246            detail: format!("zstd encoder window limit: {error}"),
247        })?;
248    encoder
249        .write_all(data)
250        .map_err(|error| CodecError::CompressFailed {
251            detail: format!("zstd compress: {error}"),
252        })?;
253    encoder
254        .finish()
255        .map_err(|error| CodecError::CompressFailed {
256            detail: format!("zstd encoder finish: {error}"),
257        })
258}
259
260#[cfg(not(target_arch = "wasm32"))]
261fn decompress_native(frame: &[u8], expected_size: usize) -> Result<Vec<u8>, CodecError> {
262    let mut output = Vec::with_capacity(expected_size);
263    let mut decoder = zstd::Decoder::new(std::io::Cursor::new(frame)).map_err(|e| {
264        CodecError::DecompressFailed {
265            detail: format!("zstd decoder init: {e}"),
266        }
267    })?;
268    decoder
269        .window_log_max(MAX_ZSTD_WINDOW_LOG)
270        .map_err(|error| CodecError::DecompressFailed {
271            detail: format!("zstd window limit: {error}"),
272        })?;
273    let mut buffer = [0u8; 8192];
274    loop {
275        let count = decoder
276            .read(&mut buffer)
277            .map_err(|e| CodecError::DecompressFailed {
278                detail: format!("zstd decompress: {e}"),
279            })?;
280        if count == 0 {
281            break;
282        }
283        let next_len = checked_add(output.len(), count, "Zstd streaming output")?;
284        if next_len > expected_size || next_len > MAX_DECODED_BYTES {
285            return Err(CodecError::ResourceLimit {
286                resource: "Zstd streamed output".into(),
287                requested: next_len,
288                limit: expected_size.min(MAX_DECODED_BYTES),
289            });
290        }
291        output.extend_from_slice(&buffer[..count]);
292    }
293
294    if output.len() != expected_size {
295        return Err(CodecError::Corrupt {
296            detail: format!(
297                "zstd size mismatch: expected {expected_size}, got {}",
298                output.len()
299            ),
300        });
301    }
302
303    Ok(output)
304}
305
306// WASM: use ruzstd for decompression. Compression on WASM uses a simple
307// fallback (ruzstd is decode-only; if full Zstd encoding is needed on WASM,
308// we'd need the zstd crate compiled to WASM via C-to-WASM toolchain).
309// For Pattern C (Lite-local), cold compression happens infrequently, so
310// we fall back to LZ4 encoding on WASM and only support Zstd decoding.
311
312#[cfg(target_arch = "wasm32")]
313fn compress_native(_data: &[u8], _level: i32) -> Result<Vec<u8>, CodecError> {
314    // ruzstd is decode-only. On WASM, we encode using a minimal Zstd frame.
315    // For production WASM builds that need Zstd encoding, compile the C zstd
316    // library to WASM. For now, return an error directing callers to use LZ4.
317    Err(CodecError::CompressFailed {
318        detail: "Zstd encoding not available on WASM — use LZ4 codec instead".into(),
319    })
320}
321
322#[cfg(target_arch = "wasm32")]
323fn decompress_native(frame: &[u8], expected_size: usize) -> Result<Vec<u8>, CodecError> {
324    use ruzstd::decoding::StreamingDecoder;
325
326    let mut decoder = StreamingDecoder::new(std::io::Cursor::new(frame)).map_err(|e| {
327        CodecError::DecompressFailed {
328            detail: format!("ruzstd decoder init: {e}"),
329        }
330    })?;
331
332    let mut output = Vec::with_capacity(expected_size);
333    let mut buffer = [0u8; 8192];
334    loop {
335        let count = decoder
336            .read(&mut buffer)
337            .map_err(|e| CodecError::DecompressFailed {
338                detail: format!("ruzstd decompress: {e}"),
339            })?;
340        if count == 0 {
341            break;
342        }
343        let next_len = checked_add(output.len(), count, "ruzstd streaming output")?;
344        if next_len > expected_size || next_len > MAX_DECODED_BYTES {
345            return Err(CodecError::ResourceLimit {
346                resource: "ruzstd streamed output".into(),
347                requested: next_len,
348                limit: expected_size.min(MAX_DECODED_BYTES),
349            });
350        }
351        output.extend_from_slice(&buffer[..count]);
352    }
353
354    if output.len() != expected_size {
355        return Err(CodecError::Corrupt {
356            detail: format!(
357                "zstd size mismatch: expected {expected_size}, got {}",
358                output.len()
359            ),
360        });
361    }
362
363    Ok(output)
364}
365
366// ---------------------------------------------------------------------------
367// Streaming encoder / decoder types
368// ---------------------------------------------------------------------------
369
370/// Streaming Zstd encoder. Accumulates data and compresses on `finish()`.
371pub struct ZstdEncoder {
372    buf: Vec<u8>,
373    level: i32,
374}
375
376impl ZstdEncoder {
377    pub fn new() -> Self {
378        Self {
379            buf: Vec::with_capacity(4096),
380            level: DEFAULT_LEVEL,
381        }
382    }
383
384    pub fn with_level(level: i32) -> Self {
385        Self {
386            buf: Vec::with_capacity(4096),
387            level: level.clamp(1, 22),
388        }
389    }
390
391    pub fn push(&mut self, data: &[u8]) -> Result<(), CodecError> {
392        let next_len = checked_add(self.buf.len(), data.len(), "Zstd streaming input")?;
393        decoded_len(next_len, "Zstd streaming input")?;
394        self.buf.extend_from_slice(data);
395        Ok(())
396    }
397
398    pub fn len(&self) -> usize {
399        self.buf.len()
400    }
401
402    pub fn is_empty(&self) -> bool {
403        self.buf.is_empty()
404    }
405
406    pub fn finish(self) -> Result<Vec<u8>, CodecError> {
407        encode_with_level(&self.buf, self.level)
408    }
409}
410
411impl Default for ZstdEncoder {
412    fn default() -> Self {
413        Self::new()
414    }
415}
416
417/// Zstd decoder wrapper.
418pub struct ZstdDecoder;
419
420impl ZstdDecoder {
421    pub fn decode_all(data: &[u8]) -> Result<Vec<u8>, CodecError> {
422        decode(data)
423    }
424
425    pub fn uncompressed_size(data: &[u8]) -> Result<usize, CodecError> {
426        uncompressed_size(data)
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn empty_data() {
436        let encoded = encode(&[]).unwrap();
437        let decoded = decode(&encoded).unwrap();
438        assert!(decoded.is_empty());
439    }
440
441    #[test]
442    fn small_data_roundtrip() {
443        let data = b"hello world, zstd compression test";
444        let encoded = encode(data).unwrap();
445        let decoded = decode(&encoded).unwrap();
446        assert_eq!(decoded, data);
447    }
448
449    #[test]
450    fn large_data_roundtrip() {
451        let line = "2024-01-15 ERROR database connection timeout host=db-prod-01 retry=3\n";
452        let data: Vec<u8> = line.as_bytes().repeat(1000);
453        let encoded = encode(&data).unwrap();
454        let decoded = decode(&encoded).unwrap();
455        assert_eq!(decoded, data);
456
457        let ratio = data.len() as f64 / encoded.len() as f64;
458        assert!(
459            ratio > 5.0,
460            "repetitive logs should compress >5x with zstd, got {ratio:.1}x"
461        );
462    }
463
464    #[test]
465    fn high_compression_level() {
466        let data: Vec<u8> = (0..10_000).map(|i| (i % 256) as u8).collect();
467        let default_encoded = encode(&data).unwrap();
468        let high_encoded = encode_with_level(&data, HIGH_LEVEL).unwrap();
469
470        // High level should produce smaller output (or equal).
471        assert!(high_encoded.len() <= default_encoded.len() + 10);
472
473        // Both should roundtrip correctly.
474        assert_eq!(decode(&default_encoded).unwrap(), data);
475        assert_eq!(decode(&high_encoded).unwrap(), data);
476    }
477
478    #[test]
479    fn header_metadata() {
480        let data = vec![42u8; 1000];
481        let encoded = encode_with_level(&data, 7).unwrap();
482
483        assert_eq!(uncompressed_size(&encoded).unwrap(), 1000);
484        assert_eq!(compression_level(&encoded).unwrap(), 7);
485    }
486
487    #[test]
488    fn better_ratio_than_lz4() {
489        // Structured data where Zstd should beat LZ4.
490        let mut data = Vec::new();
491        for i in 0..5000 {
492            let line = format!(
493                "{{\"timestamp\":{},\"level\":\"INFO\",\"msg\":\"request handled\",\"duration\":{}}}",
494                1700000000 + i,
495                i % 100
496            );
497            data.extend_from_slice(line.as_bytes());
498            data.push(b'\n');
499        }
500
501        let zstd_encoded = encode(&data).unwrap();
502        let lz4_encoded = crate::lz4::encode(&data).expect("LZ4 encode");
503
504        // Zstd should compress better than LZ4.
505        assert!(
506            zstd_encoded.len() < lz4_encoded.len(),
507            "zstd ({}) should be smaller than lz4 ({})",
508            zstd_encoded.len(),
509            lz4_encoded.len()
510        );
511
512        // Both roundtrip correctly.
513        assert_eq!(decode(&zstd_encoded).unwrap(), data);
514        assert_eq!(crate::lz4::decode(&lz4_encoded).unwrap(), data);
515    }
516
517    #[test]
518    fn hostile_declared_output_and_ratio_are_rejected_before_decoder_allocation() {
519        let mut huge = vec![0u8; HEADER_SIZE];
520        huge[..4].copy_from_slice(&u32::MAX.to_le_bytes());
521        assert!(matches!(
522            decode(&huge),
523            Err(CodecError::ResourceLimit { .. })
524        ));
525
526        let mut ratio = vec![0u8; HEADER_SIZE];
527        ratio[..4].copy_from_slice(&(MAX_DECODED_BYTES as u32).to_le_bytes());
528        ratio.extend_from_slice(&ZSTD_MAGIC);
529        ratio.push(0xa0); // Single segment with a four-byte content-size field.
530        ratio.extend_from_slice(&(MAX_DECODED_BYTES as u32).to_le_bytes());
531        assert!(matches!(
532            decode(&ratio),
533            Err(CodecError::ResourceLimit { .. })
534        ));
535        assert!(matches!(
536            decode(&ratio[..HEADER_SIZE]),
537            Err(CodecError::Truncated { .. })
538        ));
539    }
540
541    #[test]
542    fn hostile_zstd_window_is_rejected_before_decoder_creation() {
543        let mut frame = vec![0u8; HEADER_SIZE];
544        frame[..4].copy_from_slice(&1u32.to_le_bytes());
545        frame.extend_from_slice(&ZSTD_MAGIC);
546        frame.push(0); // Multi-segment frame, no content-size field.
547        frame.push(0xf8); // Window log 41, far above the 64 MiB ceiling.
548        assert!(matches!(
549            decode(&frame),
550            Err(CodecError::ResourceLimit { resource, .. }) if resource == "Zstd window bytes"
551        ));
552    }
553
554    #[test]
555    fn streaming_encoder() {
556        let parts: Vec<&[u8]> = vec![b"part one ", b"part two ", b"part three"];
557        let full: Vec<u8> = parts.iter().flat_map(|p| p.iter().copied()).collect();
558
559        let mut enc = ZstdEncoder::new();
560        for part in &parts {
561            enc.push(part).expect("push");
562        }
563        let encoded = enc.finish().unwrap();
564        let decoded = decode(&encoded).unwrap();
565        assert_eq!(decoded, full);
566    }
567
568    #[test]
569    fn truncated_input_errors() {
570        assert!(decode(&[]).is_err());
571        assert!(decode(&[0, 0, 0, 0]).is_err()); // header too short
572    }
573
574    #[test]
575    fn streaming_input_limit_precedes_buffer_growth() {
576        let mut encoder = ZstdEncoder {
577            buf: vec![0; MAX_DECODED_BYTES],
578            level: DEFAULT_LEVEL,
579        };
580        assert!(matches!(
581            encoder.push(&[1]),
582            Err(CodecError::ResourceLimit { .. })
583        ));
584        assert_eq!(encoder.len(), MAX_DECODED_BYTES);
585    }
586
587    #[test]
588    fn level_clamping() {
589        let data = b"test data for clamping";
590        // Level 0 → clamped to 1, level 99 → clamped to 22.
591        let encoded_low = encode_with_level(data, 0).unwrap();
592        let encoded_high = encode_with_level(data, 99).unwrap();
593        assert_eq!(decode(&encoded_low).unwrap(), data);
594        assert_eq!(decode(&encoded_high).unwrap(), data);
595    }
596}