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