Skip to main content

libfw_core/
compress.rs

1//! Streaming compression abstractions backed by [`zrip`] (zstd).
2//!
3//! # Design
4//!
5//! Both [`Compressor`] and [`Decompressor`] are *stream-oriented*: they
6//! accept bounded input chunks and produce bounded output chunks, so the
7//! heap footprint of processing a file does **not** grow with the file
8//! size. The [`STREAM_BUF_SIZE`] sliding window
9//! keeps memory constant (~64 KiB typical, capped well below 4 MiB even
10//! for adversarial frames).
11//!
12//! # Wire format
13//!
14//! The compressed body is a concatenation of independent zstd *frames*:
15//! each [`Compressor::compress`] call emits one complete frame when the
16//! next call arrives (or on [`Compressor::finish`]).
17//!
18//! The decompressor buffers compressed bytes until a complete frame is
19//! present (frame boundaries are detected by a small built-in zstd frame
20//! parser), then decodes exactly that frame with
21//! [`zrip::decompress_with_limit`]. A frame may therefore span several
22//! `decompress()` inputs, and one input may contain several frames.
23//!
24//! # Constant memory
25//!
26//! - **Compressor**: buffers at most one input chunk internally plus the
27//!   zrip encoder workspace (~150 KiB).
28//! - **Decompressor**: holds at most one *compressed* frame
29//!   ([`MAX_PENDING_FRAME`]) and one *decompressed* frame
30//!   ([`MAX_FRAME_OUTPUT`]) at a time.
31//!
32//! The server should feed [`Compressor::compress`] in
33//! [`STREAM_BUF_SIZE`] windows (64 KiB), keeping
34//! frames ~64 KiB and the client's transient buffers small.
35
36use std::io::Write;
37
38use crate::error::{CompressError, DecompressError};
39use crate::{CHUNK_SIZE, STREAM_BUF_SIZE};
40
41/// Compression formats understood by libfw.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum CompressionFormat {
45    /// No compression; the body passes through verbatim.
46    #[serde(rename = "identity")]
47    None,
48    /// zstd via the [`zrip`] codec (levels -8..=4).
49    Zrip,
50}
51
52impl CompressionFormat {
53    /// Wire representation used in
54    /// [`crate::HEADER_COMPRESS`] / `Content-Encoding`.
55    pub fn as_str(self) -> &'static str {
56        match self {
57            CompressionFormat::None => "identity",
58            CompressionFormat::Zrip => "zrip",
59        }
60    }
61
62    /// Parses a header value (`identity`, `zrip`, …).
63    pub fn parse_header(s: &str) -> Option<Self> {
64        match s.trim().to_ascii_lowercase().as_str() {
65            "" | "identity" | "none" => Some(CompressionFormat::None),
66            "zrip" | "zstd" => Some(CompressionFormat::Zrip),
67            _ => None,
68        }
69    }
70}
71
72/// Safety ceiling for the decompressed size of a single frame.
73///
74/// The protocol keeps frames ≤ [`CHUNK_SIZE`]; this
75/// limit is a guard against buggy or hostile peers.
76pub const MAX_FRAME_OUTPUT: usize = CHUNK_SIZE as usize;
77
78/// Maximum compressed bytes buffered while waiting for a frame boundary.
79///
80/// The worst-case incompressible [`CHUNK_SIZE`] chunk
81/// compresses to ≈ chunk size + framing overhead, hence the slack.
82pub const MAX_PENDING_FRAME: usize = CHUNK_SIZE as usize + STREAM_BUF_SIZE;
83
84/// Default cap on the total bytes a single [`Decompressor::decompress`] call
85/// may append to the output buffer.
86///
87/// A hostile peer could otherwise send many *small* frames in one network
88/// chunk, each expanding to [`MAX_FRAME_OUTPUT`] — inflating memory by a
89/// large factor before the caller drains the buffer. The default (a handful
90/// of frames) keeps the transient peak bounded while comfortably allowing
91/// legitimate coalesced reads (e.g. a browser delivering several 64 KiB
92/// download frames at once).
93pub const MAX_OUTPUT_PER_CALL: usize = MAX_FRAME_OUTPUT.saturating_mul(8);
94
95/// Default zrip compression level (Fast strategy, good for network transfer).
96pub const ZRIP_DEFAULT_LEVEL: i32 = 1;
97
98/// Lowest zrip compression level (Fast strategy; least CPU, worst ratio).
99pub const ZRIP_MIN_LEVEL: i32 = -8;
100
101/// Highest zrip compression level (bounded memory, best ratio).
102pub const ZRIP_MAX_LEVEL: i32 = 4;
103
104/// Whether `level` is an addressable zrip compression level.
105pub fn is_valid_zrip_level(level: i32) -> bool {
106    (ZRIP_MIN_LEVEL..=ZRIP_MAX_LEVEL).contains(&level)
107}
108
109/// Clamp a requested zrip level into `min..=max`.
110///
111/// Servers must never trust a client-supplied level: out-of-range requests
112/// are clamped, and the level actually used is echoed back on the response
113/// header, so the wire format always reports what the client got.
114pub fn negotiate_level(req: Option<i32>, min: i32, max: i32, default: i32) -> i32 {
115    if min > max {
116        // Empty range (misconfigured advertisement): fall back to the
117        // default rather than panicking inside `i32::clamp`.
118        return default;
119    }
120    match req {
121        None => default,
122        Some(l) => l.clamp(min, max),
123    }
124}
125
126/// Streaming compressor. Feed it bounded input chunks; it appends the
127/// compressed bytes produced so far to the provided output buffer.
128pub trait Compressor: Send {
129    /// The format this compressor produces.
130    fn format(&self) -> CompressionFormat;
131
132    /// Compress `input`, appending compressed bytes to `out`.
133    ///
134    /// `input` should be kept ≤ [`CHUNK_SIZE`] to keep
135    /// the output frames small and memory constant. When the next chunk
136    /// arrives, the previous chunk's frame is finalized and emitted.
137    fn compress(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), CompressError>;
138
139    /// Finalize the stream, appending the last frame (if any) to `out`.
140    ///
141    /// Must be called exactly once; afterwards the compressor is spent.
142    fn finish(&mut self, out: &mut Vec<u8>) -> Result<(), CompressError>;
143}
144
145/// Streaming decompressor. Feed it arbitrary compressed bytes; decoded
146/// output is appended to the provided buffer.
147pub trait Decompressor: Send {
148    /// The format this decompressor consumes.
149    fn format(&self) -> CompressionFormat;
150
151    /// Decompress `input`, appending decoded bytes to `out`.
152    ///
153    /// Input may split frames arbitrarily. `out` should be drained by the
154    /// caller after each call to keep memory bounded.
155    fn decompress(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), DecompressError>;
156
157    /// Signal the end of the stream.
158    ///
159    /// Flushes any complete trailing frames and verifies the stream is
160    /// well-formed (no truncated frames). Call exactly once.
161    fn finish(&mut self, out: &mut Vec<u8>) -> Result<(), DecompressError>;
162}
163
164/// Construct a compressor for `format`.
165pub fn compressor(format: CompressionFormat) -> Result<Box<dyn Compressor>, CompressError> {
166    compressor_with_level(format, ZRIP_DEFAULT_LEVEL)
167}
168
169/// Construct a compressor for `format` at an explicit zrip level.
170///
171/// The level is only meaningful for [`CompressionFormat::Zrip`]; for
172/// [`CompressionFormat::None`] the body passes through verbatim regardless
173/// of `level`. Callers should pre-validate/clamp `level` with
174/// [`negotiate_level`] before constructing (constructors still error on
175/// out-of-range levels from zrip itself).
176pub fn compressor_with_level(
177    format: CompressionFormat,
178    level: i32,
179) -> Result<Box<dyn Compressor>, CompressError> {
180    match format {
181        CompressionFormat::None => Ok(Box::new(PassthroughCompressor)),
182        CompressionFormat::Zrip => Ok(Box::new(ZripCompressor::new(level)?)),
183    }
184}
185
186/// Construct a decompressor for `format`.
187pub fn decompressor(format: CompressionFormat) -> Box<dyn Decompressor> {
188    decompressor_with_limit(format, MAX_OUTPUT_PER_CALL)
189}
190
191/// Construct a decompressor for `format` with an explicit per-call output
192/// budget (bytes a single [`Decompressor::decompress`] call may append).
193///
194/// Use a tight budget (e.g. [`MAX_FRAME_OUTPUT`]) on the server where the
195/// peer is potentially hostile; use the default generous budget on the
196/// client so coalesced multi-frame reads are never rejected.
197pub fn decompressor_with_limit(
198    format: CompressionFormat,
199    max_output_per_call: usize,
200) -> Box<dyn Decompressor> {
201    match format {
202        CompressionFormat::None => Box::new(PassthroughDecompressor),
203        CompressionFormat::Zrip => Box::new(ZripDecompressor::with_max_output(max_output_per_call)),
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Passthrough (identity)
209// ---------------------------------------------------------------------------
210
211struct PassthroughCompressor;
212
213impl Compressor for PassthroughCompressor {
214    fn format(&self) -> CompressionFormat {
215        CompressionFormat::None
216    }
217
218    fn compress(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), CompressError> {
219        out.extend_from_slice(input);
220        Ok(())
221    }
222
223    fn finish(&mut self, _out: &mut Vec<u8>) -> Result<(), CompressError> {
224        Ok(())
225    }
226}
227
228struct PassthroughDecompressor;
229
230impl Decompressor for PassthroughDecompressor {
231    fn format(&self) -> CompressionFormat {
232        CompressionFormat::None
233    }
234
235    fn decompress(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), DecompressError> {
236        out.extend_from_slice(input);
237        Ok(())
238    }
239
240    fn finish(&mut self, _out: &mut Vec<u8>) -> Result<(), DecompressError> {
241        Ok(())
242    }
243}
244
245// ---------------------------------------------------------------------------
246// zrip (zstd) implementation
247// ---------------------------------------------------------------------------
248
249/// zstd compressor emitting one independent frame per input chunk.
250pub struct ZripCompressor {
251    encoder: Option<zrip::FrameEncoder<Vec<u8>>>,
252    /// Whether data has been written since the last frame boundary.
253    dirty: bool,
254}
255
256impl ZripCompressor {
257    /// Create a compressor at `level` (-8..=4; 0 = library default).
258    pub fn new(level: i32) -> Result<Self, CompressError> {
259        Ok(ZripCompressor {
260            encoder: Some(zrip::FrameEncoder::new(Vec::new(), level)?),
261            dirty: false,
262        })
263    }
264}
265
266impl Compressor for ZripCompressor {
267    fn format(&self) -> CompressionFormat {
268        CompressionFormat::Zrip
269    }
270
271    fn compress(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), CompressError> {
272        if input.is_empty() {
273            return Ok(());
274        }
275        let encoder = self
276            .encoder
277            .as_mut()
278            .ok_or_else(|| std::io::Error::other("compressor already finished"))?;
279        // Finalize the previous chunk's frame and hand back its compressed
280        // bytes.
281        if self.dirty {
282            let finished = encoder.reset(Vec::new())?;
283            out.extend_from_slice(&finished);
284        }
285        encoder.write_all(input)?;
286        self.dirty = true;
287        Ok(())
288    }
289
290    fn finish(&mut self, out: &mut Vec<u8>) -> Result<(), CompressError> {
291        let encoder = self
292            .encoder
293            .take()
294            .ok_or_else(|| std::io::Error::other("compressor already finished"))?;
295        let tail = encoder.finish()?;
296        out.extend_from_slice(&tail);
297        self.dirty = false;
298        Ok(())
299    }
300}
301
302/// zstd decompressor reassembling frames across arbitrary chunk boundaries.
303pub struct ZripDecompressor {
304    /// Compressed bytes not yet turned into complete frames.
305    pending: Vec<u8>,
306    finished: bool,
307    /// Hard cap on the total bytes appended to `out` during a single
308    /// [`Decompressor::decompress`] / [`Decompressor::finish`] call.
309    max_output_per_call: usize,
310}
311
312impl ZripDecompressor {
313    /// Create a decompressor with the default per-call output budget
314    /// ([`MAX_OUTPUT_PER_CALL`]).
315    pub fn new() -> Self {
316        ZripDecompressor::with_max_output(MAX_OUTPUT_PER_CALL)
317    }
318
319    /// Create a decompressor with an explicit per-call output budget.
320    pub fn with_max_output(max_output_per_call: usize) -> Self {
321        ZripDecompressor {
322            pending: Vec::with_capacity(STREAM_BUF_SIZE),
323            finished: false,
324            max_output_per_call,
325        }
326    }
327
328    /// Decode as many complete frames as `pending` holds, appending at most
329    /// `max_add` bytes to `out` across the whole call.
330    fn drain_frames(&mut self, out: &mut Vec<u8>, max_add: usize) -> Result<(), DecompressError> {
331        let mut added = 0usize;
332        // Consume via a cursor and compact `pending` once at the end: a
333        // per-frame `drain(..len)` would memmove the whole remaining buffer
334        // for every frame (O(n²) across a many-frame stream).
335        let mut consumed = 0usize;
336        loop {
337            let boundary = frame_boundary(&self.pending[consumed..]);
338            match boundary {
339                FrameBoundary::Complete { len, content_size } => {
340                    if content_size.is_some_and(|cs| cs > MAX_FRAME_OUTPUT as u64) {
341                        return Err(DecompressError::TooLarge {
342                            limit: MAX_FRAME_OUTPUT,
343                        });
344                    }
345                    let decoded = {
346                        let frame = &self.pending[consumed..consumed + len];
347                        zrip::decompress_with_limit(frame, MAX_FRAME_OUTPUT).map_err(|e| {
348                            DecompressError::Io(std::io::Error::new(
349                                std::io::ErrorKind::InvalidData,
350                                e,
351                            ))
352                        })?
353                    };
354                    // Enforce the per-call budget *before* appending so the
355                    // memory spike of a multi-frame bomb never materializes.
356                    if added.saturating_add(decoded.len()) > max_add {
357                        return Err(DecompressError::TooLarge { limit: max_add });
358                    }
359                    out.extend_from_slice(&decoded);
360                    added = added.saturating_add(decoded.len());
361                    consumed += len;
362                }
363                FrameBoundary::Incomplete => {
364                    // Keep the one compaction cheap: only memmove when the
365                    // consumed prefix is non-trivially large.
366                    if consumed > 0 {
367                        self.pending.drain(..consumed);
368                    }
369                    return Ok(());
370                }
371                FrameBoundary::Invalid => {
372                    return Err(DecompressError::Io(std::io::Error::new(
373                        std::io::ErrorKind::InvalidData,
374                        "invalid zstd frame data",
375                    )))
376                }
377            }
378        }
379    }
380}
381
382impl Default for ZripDecompressor {
383    fn default() -> Self {
384        ZripDecompressor::new()
385    }
386}
387
388impl Decompressor for ZripDecompressor {
389    fn format(&self) -> CompressionFormat {
390        CompressionFormat::Zrip
391    }
392
393    fn decompress(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), DecompressError> {
394        if self.finished {
395            return Err(DecompressError::Io(std::io::Error::other(
396                "decompressor already finished",
397            )));
398        }
399        if !input.is_empty() {
400            self.pending.extend_from_slice(input);
401        }
402        self.drain_frames(out, self.max_output_per_call)?;
403        if self.pending.len() > MAX_PENDING_FRAME {
404            return Err(DecompressError::TooLarge {
405                limit: MAX_PENDING_FRAME,
406            });
407        }
408        Ok(())
409    }
410
411    fn finish(&mut self, out: &mut Vec<u8>) -> Result<(), DecompressError> {
412        if self.finished {
413            return Ok(());
414        }
415        self.finished = true;
416        self.drain_frames(out, self.max_output_per_call)?;
417        if !self.pending.is_empty() {
418            return Err(DecompressError::Truncated(std::io::Error::new(
419                std::io::ErrorKind::UnexpectedEof,
420                format!("{} trailing compressed bytes", self.pending.len()),
421            )));
422        }
423        Ok(())
424    }
425}
426
427// ---------------------------------------------------------------------------
428// Minimal zstd frame-boundary parser
429// ---------------------------------------------------------------------------
430
431const ZSTD_MAGIC: u32 = 0xFD2F_B528;
432const SKIPPABLE_MASK: u32 = 0xFFFF_FFF0;
433const SKIPPABLE_MAGIC: u32 = 0x184D_2A50;
434
435enum FrameBoundary {
436    /// A complete frame occupying `len` bytes, declaring `content_size`
437    /// output bytes (`None` when the frame header does not state it).
438    Complete { len: usize, content_size: Option<u64> },
439    /// Need more bytes to know the boundary.
440    Incomplete,
441    /// Corrupt / unsupported.
442    Invalid,
443}
444
445/// Determine the byte length of the next zstd frame in `buf` (if present).
446fn frame_boundary(buf: &[u8]) -> FrameBoundary {
447    if buf.len() < 4 {
448        return FrameBoundary::Incomplete;
449    }
450    let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
451    if magic == ZSTD_MAGIC {
452        zstd_frame_boundary(buf)
453    } else if (magic & SKIPPABLE_MASK) == SKIPPABLE_MAGIC {
454        skippable_frame_boundary(buf)
455    } else {
456        FrameBoundary::Invalid
457    }
458}
459
460/// Skippable frames: `magic(4) | skip_size u32(4) | payload(skip_size)`.
461fn skippable_frame_boundary(buf: &[u8]) -> FrameBoundary {
462    if buf.len() < 8 {
463        return FrameBoundary::Incomplete;
464    }
465    let skip = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize;
466    let total = 8usize.saturating_add(skip);
467    if buf.len() >= total {
468        FrameBoundary::Complete {
469            len: total,
470            content_size: Some(0),
471        }
472    } else {
473        FrameBoundary::Incomplete
474    }
475}
476
477/// Walk the frame header + blocks of a standard zstd frame.
478///
479/// Layout: `magic(4) descriptor(1) [window(1)] [dict_id(0/1/2/4)]
480/// [content_size(0/1/2/4/8)] block* checksum(0/4)`.
481fn zstd_frame_boundary(buf: &[u8]) -> FrameBoundary {
482    if buf.len() < 5 {
483        return FrameBoundary::Incomplete;
484    }
485    let descriptor = buf[4];
486    // Reserved bits (3..=4) must be zero.
487    if descriptor & 0x18 != 0 {
488        return FrameBoundary::Invalid;
489    }
490    let single_segment = descriptor & 0x20 != 0;
491    let checksum = descriptor & 0x04 != 0;
492    let dict_id_flag = descriptor & 0x03;
493    let fcs_flag = (descriptor >> 6) & 0x03;
494
495    let mut hdr_len = 5usize;
496    if !single_segment {
497        hdr_len += 1; // window descriptor
498    }
499    let dict_id_size = match dict_id_flag {
500        0 => 0,
501        1 => 1,
502        2 => 2,
503        3 => 4,
504        _ => return FrameBoundary::Invalid,
505    };
506    hdr_len += dict_id_size;
507    let fcs_size: usize = match fcs_flag {
508        0 if single_segment => 1,
509        0 => 0,
510        1 => 2,
511        2 => 4,
512        3 => 8,
513        _ => return FrameBoundary::Invalid,
514    };
515    hdr_len += fcs_size;
516
517    if buf.len() < hdr_len {
518        return FrameBoundary::Incomplete;
519    }
520    let content_size = if fcs_size > 0 {
521        let mut v = 0u64;
522        for (i, &b) in buf[5..5 + fcs_size].iter().enumerate() {
523            v |= (b as u64) << (8 * i);
524        }
525        Some(v)
526    } else {
527        None
528    };
529
530    // Walk blocks until the last-block flag.
531    let mut off = hdr_len;
532    loop {
533        if buf.len() < off + 3 {
534            return FrameBoundary::Incomplete;
535        }
536        // Standard zstd block header (as read by the reference decoder):
537        // bit 0 = last_block, bits 1..=2 = block_type, bits 3..=23 = size.
538        let block_header = u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], 0]);
539        let last = block_header & 0x01 != 0;
540        let block_type = (block_header >> 1) & 0x03;
541        let block_size = (block_header >> 3) as usize;
542        if block_type == 3 {
543            // Reserved block type.
544            return FrameBoundary::Invalid;
545        }
546        off += 3 + block_size;
547        if off > MAX_PENDING_FRAME {
548            return FrameBoundary::Invalid;
549        }
550        if last {
551            break;
552        }
553    }
554    if checksum {
555        off += 4;
556    }
557    if buf.len() < off {
558        return FrameBoundary::Incomplete;
559    }
560    FrameBoundary::Complete {
561        len: off,
562        content_size,
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn roundtrip_chunks(data: &[u8], feed: &[usize]) {
571        let mut c = ZripCompressor::new(ZRIP_DEFAULT_LEVEL).unwrap();
572        let mut compressed = Vec::new();
573        let mut off = 0;
574        for &n in feed {
575            let end = (off + n).min(data.len());
576            if end > off {
577                c.compress(&data[off..end], &mut compressed).unwrap();
578            }
579            off = end;
580        }
581        c.finish(&mut compressed).unwrap();
582        assert!(!compressed.is_empty());
583
584        // Decode with arbitrarily split inputs, including mid-frame splits.
585        let mut d = ZripDecompressor::new();
586        let mut plain = Vec::new();
587        let mut step = 1;
588        let mut i = 0;
589        while i < compressed.len() {
590            let end = (i + step).min(compressed.len());
591            d.decompress(&compressed[i..end], &mut plain).unwrap();
592            i = end;
593            step = step % 5 + 1; // 1..=5
594        }
595        d.finish(&mut plain).unwrap();
596        assert_eq!(plain, data, "roundtrip mismatch with feed {feed:?}");
597    }
598
599    #[test]
600    fn roundtrip_single_chunk() {
601        let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
602        roundtrip_chunks(&data, &[data.len()]);
603    }
604
605    #[test]
606    fn roundtrip_multi_chunk_64k_windows() {
607        // Server-style: feed in 64 KiB sliding windows.
608        let data: Vec<u8> = (0..300_000u32).map(|i| (i / 7) as u8).collect();
609        let feed: Vec<usize> = std::iter::repeat(STREAM_BUF_SIZE).take(5).collect();
610        roundtrip_chunks(&data, &feed);
611    }
612
613    #[test]
614    fn roundtrip_highly_compressible() {
615        let data = b"libfw streaming compression test. ".repeat(10_000);
616        // Feed sizes cycling 1KiB..16KiB until the whole input is consumed.
617        let mut feed = Vec::new();
618        let mut consumed = 0;
619        for (_, &size) in [1024usize, 2048, 4096, 8192, 16384].iter().cycle().enumerate() {
620            feed.push(size);
621            consumed += size;
622            if consumed >= data.len() {
623                break;
624            }
625        }
626        roundtrip_chunks(&data, &feed);
627    }
628
629    #[test]
630    fn roundtrip_empty_stream() {
631        let mut c = ZripCompressor::new(ZRIP_DEFAULT_LEVEL).unwrap();
632        let mut compressed = Vec::new();
633        c.finish(&mut compressed).unwrap();
634
635        let mut d = ZripDecompressor::new();
636        let mut plain = Vec::new();
637        d.decompress(&compressed, &mut plain).unwrap();
638        d.finish(&mut plain).unwrap();
639        assert!(plain.is_empty());
640    }
641
642    #[test]
643    fn malformed_zstd_headers_are_rejected_without_panicking() {
644        // Keep the parser defensive: malformed / hostile frame headers must
645        // surface as Invalid instead of crashing the WASM guest with an
646        // internal `unreachable!`.
647        let bad = [0xFD, 0x2F, 0xB5, 0x28, 0xFF];
648        assert!(matches!(frame_boundary(&bad), FrameBoundary::Invalid));
649
650        let bad2 = [0xFD, 0x2F, 0xB5, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00];
651        assert!(matches!(frame_boundary(&bad2), FrameBoundary::Invalid));
652    }
653
654    #[test]
655    fn empty_input_chunks_are_noops() {
656        let mut c = ZripCompressor::new(ZRIP_DEFAULT_LEVEL).unwrap();
657        let mut out = Vec::new();
658        c.compress(&[], &mut out).unwrap();
659        c.compress(b"hello", &mut out).unwrap();
660        c.finish(&mut out).unwrap();
661
662        let mut d = ZripDecompressor::new();
663        let mut plain = Vec::new();
664        d.decompress(&out, &mut plain).unwrap();
665        d.finish(&mut plain).unwrap();
666        assert_eq!(plain, b"hello");
667    }
668
669    #[test]
670    fn format_header_roundtrip() {
671        assert_eq!(CompressionFormat::parse_header("zrip"), Some(CompressionFormat::Zrip));
672        assert_eq!(CompressionFormat::parse_header("ZSTD"), Some(CompressionFormat::Zrip));
673        assert_eq!(CompressionFormat::parse_header("identity"), Some(CompressionFormat::None));
674        assert_eq!(CompressionFormat::parse_header(""), Some(CompressionFormat::None));
675        assert_eq!(CompressionFormat::parse_header("br"), None);
676        assert_eq!(CompressionFormat::Zrip.as_str(), "zrip");
677        assert_eq!(CompressionFormat::None.as_str(), "identity");
678    }
679
680    #[test]
681    fn passthrough_roundtrip() {
682        let mut c = compressor(CompressionFormat::None).unwrap();
683        let mut d = decompressor(CompressionFormat::None);
684        let mut compressed = Vec::new();
685        let mut plain = Vec::new();
686        c.compress(b"abc", &mut compressed).unwrap();
687        c.finish(&mut compressed).unwrap();
688        d.decompress(&compressed, &mut plain).unwrap();
689        d.finish(&mut plain).unwrap();
690        assert_eq!(plain, b"abc");
691    }
692
693    #[test]
694    fn truncated_stream_is_detected() {
695        let mut c = ZripCompressor::new(ZRIP_DEFAULT_LEVEL).unwrap();
696        let mut compressed = Vec::new();
697        c.compress(&vec![7u8; 5000], &mut compressed).unwrap();
698        c.finish(&mut compressed).unwrap();
699        compressed.truncate(compressed.len() - 1); // chop one byte
700
701        let mut d = ZripDecompressor::new();
702        let mut plain = Vec::new();
703        d.decompress(&compressed, &mut plain).unwrap();
704        assert!(matches!(d.finish(&mut plain), Err(DecompressError::Truncated(_))));
705    }
706
707    #[test]
708    fn corrupt_stream_is_detected() {
709        let mut d = ZripDecompressor::new();
710        let mut plain = Vec::new();
711        let err = d.decompress(b"this is not a zstd frame at all", &mut plain);
712        assert!(err.is_err());
713    }
714
715    #[test]
716    fn per_call_output_budget_rejects_multi_frame_bomb() {
717        // A hostile peer delivers many small frames in ONE decompress() call;
718        // the cumulative decoded output must trip the per-call budget instead
719        // of ballooning memory.
720        let mut compressed = Vec::new();
721        for _ in 0..64 {
722            let mut c = ZripCompressor::new(ZRIP_DEFAULT_LEVEL).unwrap();
723            c.compress(&vec![7u8; STREAM_BUF_SIZE], &mut compressed)
724                .unwrap();
725            c.finish(&mut compressed).unwrap();
726        }
727        let mut d = ZripDecompressor::with_max_output(MAX_FRAME_OUTPUT);
728        let mut plain = Vec::new();
729        let err = d.decompress(&compressed, &mut plain);
730        assert!(
731            matches!(err, Err(DecompressError::TooLarge { .. })),
732            "expected TooLarge, got {err:?}"
733        );
734        // The buffer must not have been inflated past the budget.
735        assert!(plain.len() <= MAX_FRAME_OUTPUT);
736    }
737
738    #[test]
739    fn generous_default_budget_allows_coalesced_frames() {
740        // Several small frames in one call are fine under the default budget
741        // (what a browser may hand the client's decompressor).
742        let mut compressed = Vec::new();
743        for _ in 0..8 {
744            let mut c = ZripCompressor::new(ZRIP_DEFAULT_LEVEL).unwrap();
745            c.compress(&vec![7u8; STREAM_BUF_SIZE], &mut compressed)
746                .unwrap();
747            c.finish(&mut compressed).unwrap();
748        }
749        let mut d = ZripDecompressor::new();
750        let mut plain = Vec::new();
751        d.decompress(&compressed, &mut plain).unwrap();
752        d.finish(&mut plain).unwrap();
753        assert_eq!(plain.len(), 8 * STREAM_BUF_SIZE);
754    }
755
756    #[test]
757    fn zstd_compat_interop() {
758        // Our frames are standard zstd: the reference `zstd` codec can
759        // decode them, and we can decode its output.
760        let data: Vec<u8> = (0..50_000u32).map(|i| (i % 31) as u8).collect();
761        let mut c = ZripCompressor::new(ZRIP_DEFAULT_LEVEL).unwrap();
762        let mut compressed = Vec::new();
763        c.compress(&data, &mut compressed).unwrap();
764        c.finish(&mut compressed).unwrap();
765        let decoded = zstd::stream::decode_all(&compressed[..]).unwrap();
766        assert_eq!(decoded, data);
767
768        let zstd_enc = zstd::stream::encode_all(&data[..], 1).unwrap();
769        let mut d = ZripDecompressor::new();
770        let mut plain = Vec::new();
771        d.decompress(&zstd_enc, &mut plain).unwrap();
772        d.finish(&mut plain).unwrap();
773        assert_eq!(plain, data);
774    }
775}