Skip to main content

libzstd_bitexact_rs/
stream_encode.rs

1//! Streaming compression with `ZSTD_compressStream2` semantics, aiming for
2//! **byte-identical output to C libzstd 1.5.7** for the same sequence of
3//! compress/flush/finish operations.
4//!
5//! This is a port of the buffered-mode path (`ZSTD_bm_buffered`, the
6//! `ZSTD_compressStream2` default): input is staged in an internal buffer of
7//! `windowSize + blockSize` bytes (`ZSTD_resetCCtx_internal`) and compressed
8//! one `blockSize` chunk at a time through the shared frame machinery
9//! ([`crate::compress::FrameCompressor`] = `ZSTD_compressContinue` /
10//! `ZSTD_compressEnd`). The output-side staging buffer of the C code is pure
11//! plumbing (bytes are identical with any output capacity), so it is not
12//! reproduced.
13//!
14//! Parameter derivation differs from the one-shot path exactly as in C: with
15//! no pledged content size, `ZSTD_getCParamsFromCCtxParams` resolves with
16//! `ZSTD_CONTENTSIZE_UNKNOWN`, which selects the "default" srcSize class and
17//! skips the window resize — so a stream compressed in chunks legitimately
18//! differs from `ZSTD_compress` of the same bytes (windowed frame header, no
19//! content size) unless the size is pledged up front or the whole input is
20//! handed to the first [`StreamEncoder::finish`] call (the C auto-pledge).
21//!
22//! Current scope: when the staged stream outgrows the input buffer
23//! (`windowSize + blockSize` bytes — 640 KiB at level 1 up to 64 MiB+ at the
24//! top levels), the buffer wraps and the previous segment becomes an
25//! *extDict*. All nine strategies' extDict match finders are ported, and
26//! index overflow correction recycles the 32-bit index space past 3500 MiB,
27//! so every level streams without any length limit — including the
28//! configurations where C auto-enables long-distance matching (`strategy >=
29//! btopt && windowLog >= 27`, i.e. level 22 at unknown content size), whose
30//! LDM match finder ([`crate::ldm`]) is bit-exact.
31//!
32//! [`StreamEncoder::with_workers`] switches to the **multithreaded** streaming
33//! path (`ZSTD_compressStream2` with `nbWorkers >= 1`), reproduced
34//! single-threaded by buffering the input into job-sized sections
35//! ([`crate::compress::MtStreamState`] / [`crate::compress_mt`]).
36
37use crate::compress::{FrameCompressor, MtStreamState, ZSTDMT_JOBSIZE_MIN, compress_mt};
38use crate::error::Error;
39
40/// `ZSTD_EndDirective`.
41#[derive(PartialEq, Eq, Clone, Copy)]
42enum EndOp {
43    Continue,
44    Flush,
45    End,
46}
47
48/// Streaming Zstandard encoder (`ZSTD_compressStream2` semantics).
49///
50/// Output produced by a given sequence of [`compress`](Self::compress),
51/// [`flush`](Self::flush) and [`finish`](Self::finish) calls is byte-identical
52/// to C libzstd 1.5.7 fed the same input chunks with the same
53/// `ZSTD_e_continue` / `ZSTD_e_flush` / `ZSTD_e_end` directives.
54///
55/// ```
56/// let mut out = Vec::new();
57/// let mut enc = libzstd_bitexact_rs::StreamEncoder::new(3);
58/// enc.compress(b"hello ", &mut out).unwrap();
59/// enc.compress(b"world", &mut out).unwrap();
60/// enc.finish(b"", &mut out).unwrap();
61/// assert_eq!(libzstd_bitexact_rs::decompress(&out).unwrap(), b"hello world");
62/// ```
63pub struct StreamEncoder {
64    level: i32,
65    /// `ZSTD_CCtx_setPledgedSrcSize`: applies at (deferred) init time.
66    requested_pledged: Option<u64>,
67    checksum: bool,
68    /// `ZSTD_CCtx_loadDictionary`: the dictionary primes the frame via an
69    /// internally-built CDict (Path B). `None` for plain streaming.
70    dict: Option<Vec<u8>>,
71    /// `ZSTD_c_nbWorkers` / `ZSTD_c_jobSize` / `ZSTD_c_overlapLog`: when
72    /// `nb_workers >= 1` the stream uses the multithreaded job-splitting path
73    /// (reproduced single-threaded via [`MtStreamState`]).
74    nb_workers: u32,
75    job_size: u64,
76    overlap_log: i32,
77    /// `None` until the first operation (`zcss_init`): parameters are
78    /// resolved lazily so that a first-call `finish` can auto-pledge.
79    state: Option<StreamState>,
80    /// Set instead of `state` when the multithreaded streaming path is active.
81    mt_state: Option<MtStreamState>,
82    frame_ended: bool,
83}
84
85struct StreamState {
86    fc: FrameCompressor,
87    /// The C `inBuff`, `windowSize + blockSize` bytes.
88    in_buff: Vec<u8>,
89    /// `zcs->inToCompress` / `zcs->inBuffPos` / `zcs->inBuffTarget`.
90    in_to_compress: usize,
91    in_buff_pos: usize,
92    in_buff_target: usize,
93}
94
95impl StreamEncoder {
96    /// A streaming encoder with unknown content size (the
97    /// `ZSTD_compressStream2` default).
98    pub fn new(level: i32) -> Self {
99        StreamEncoder {
100            level,
101            requested_pledged: None,
102            checksum: false,
103            dict: None,
104            nb_workers: 0,
105            job_size: 0,
106            overlap_log: 0,
107            state: None,
108            mt_state: None,
109            frame_ended: false,
110        }
111    }
112
113    /// A streaming encoder primed with a dictionary (`ZSTD_CCtx_loadDictionary`
114    /// semantics: an internal CDict is built and **attached**, Path B). Output is
115    /// byte-identical to C `ZSTD_compressStream2` after `ZSTD_CCtx_loadDictionary`
116    /// (e.g. `zstd::stream::write::Encoder::with_dictionary`) for the same
117    /// operations.
118    ///
119    /// Current scope: the unknown-content-size (attach) path — the natural
120    /// streaming case. A stream whose source grows past the window (so C would
121    /// drop the attached dictionary) returns a clean [`Error::Encode`]; so does a
122    /// pledged size above the strategy's attach cutoff (the copy path) and a
123    /// dictionary with <= 8 bytes of content. Raw and trained dictionaries are
124    /// both supported.
125    pub fn with_dictionary(level: i32, dict: &[u8]) -> Self {
126        StreamEncoder {
127            level,
128            requested_pledged: None,
129            checksum: false,
130            dict: if dict.is_empty() {
131                None
132            } else {
133                Some(dict.to_vec())
134            },
135            nb_workers: 0,
136            job_size: 0,
137            overlap_log: 0,
138            state: None,
139            mt_state: None,
140            frame_ended: false,
141        }
142    }
143
144    /// `ZSTD_CCtx_setPledgedSrcSize`: declare the total content size up
145    /// front. Compression parameters are then derived from it exactly as in
146    /// the one-shot path, the frame header carries the content size, and the
147    /// stream errors if the fed input does not match the pledge. Note that C
148    /// overrides the pledge when the *first* operation is `finish` (the input
149    /// of that call becomes the pledge); this port is faithful to that.
150    pub fn with_pledged_src_size(level: i32, size: u64) -> Self {
151        StreamEncoder {
152            level,
153            requested_pledged: Some(size),
154            checksum: false,
155            dict: None,
156            nb_workers: 0,
157            job_size: 0,
158            overlap_log: 0,
159            state: None,
160            mt_state: None,
161            frame_ended: false,
162        }
163    }
164
165    /// Enable the content checksum (`ZSTD_c_checksumFlag`): XXH64 of the
166    /// content, low 32 bits appended to the frame.
167    ///
168    /// # Panics
169    /// If streaming has already started (the flag applies at init time).
170    pub fn with_checksum(mut self, on: bool) -> Self {
171        assert!(
172            self.state.is_none() && self.mt_state.is_none(),
173            "checksum flag must be set before streaming starts"
174        );
175        self.checksum = on;
176        self
177    }
178
179    /// `ZSTD_c_nbWorkers` (+ optional `ZSTD_c_jobSize` / `ZSTD_c_overlapLog`,
180    /// `0` = C default): enable multithreaded streaming. C's MT output is
181    /// deterministic and worker-count-independent, so this reproduces it
182    /// **single-threaded** (see [`crate::compress_mt`]).
183    ///
184    /// Current scope: unknown-size streaming (the default) via
185    /// [`compress`](Self::compress) + [`finish`](Self::finish), with or without a
186    /// content checksum ([`with_checksum`](Self::with_checksum)). A first-call
187    /// `finish` below `ZSTDMT_JOBSIZE_MIN` (512 KiB) produces the single-threaded
188    /// frame, and above it the one-shot MT frame. [`flush`](Self::flush), a
189    /// pledged size, and a dictionary with workers are not supported yet (clean
190    /// [`Error::Encode`]).
191    ///
192    /// # Panics
193    /// If streaming has already started (workers apply at init time).
194    pub fn with_workers(mut self, nb_workers: u32, job_size: u64, overlap_log: i32) -> Self {
195        assert!(
196            self.state.is_none() && self.mt_state.is_none(),
197            "workers must be set before streaming starts"
198        );
199        self.nb_workers = nb_workers;
200        self.job_size = job_size;
201        self.overlap_log = overlap_log;
202        self
203    }
204
205    /// `ZSTD_compressStream2(.., ZSTD_e_continue)`: consume `input`, appending
206    /// any output produced to `out`. Input is buffered internally; output is
207    /// only produced once full blocks are available.
208    pub fn compress(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), Error> {
209        self.stream_op(input, EndOp::Continue, out)
210    }
211
212    /// `ZSTD_compressStream2(.., ZSTD_e_flush)`: compress whatever is
213    /// buffered and emit it, ending the current block. The frame stays open.
214    pub fn flush(&mut self, out: &mut Vec<u8>) -> Result<(), Error> {
215        self.stream_op(&[], EndOp::Flush, out)
216    }
217
218    /// `ZSTD_compressStream2(.., ZSTD_e_end)`: consume `input` (pass `b""`
219    /// for none), then end the frame — last block, optional checksum.
220    ///
221    /// When this is the *first* operation on the encoder, C auto-pledges the
222    /// content size from this call's input, making the result byte-identical
223    /// to the one-shot `ZSTD_compress2`; this port does the same.
224    pub fn finish(mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), Error> {
225        self.stream_op(input, EndOp::End, out)
226    }
227
228    /// `ZSTD_CCtx_init_compressStream2` + `ZSTD_compressBegin_internal`
229    /// (buffered): resolve parameters (auto-pledging if the first operation
230    /// is `ZSTD_e_end`) and size the input staging buffer.
231    fn init(&mut self, end_op: EndOp, in_size: usize) -> Result<(), Error> {
232        // Multithreaded streaming. A first-call `finish` auto-pledges to its
233        // input size (the no-dict large case was already delegated to the
234        // one-shot MT frame in `stream_op`; here it reaches only the dictionary
235        // path, which streams through `MtStreamState`).
236        if self.nb_workers > 0 {
237            let pledged = if end_op == EndOp::End {
238                Some(in_size as u64)
239            } else {
240                self.requested_pledged
241            };
242            // Engage MT for an unknown size, or a pledged size above the MT floor;
243            // a pledge at or below it falls through to the single-threaded path.
244            let engages = match pledged {
245                Some(p) => p > ZSTDMT_JOBSIZE_MIN,
246                None => true,
247            };
248            if engages {
249                self.mt_state = Some(MtStreamState::new(
250                    self.level,
251                    self.job_size,
252                    self.overlap_log,
253                    self.checksum,
254                    pledged,
255                    self.dict.as_deref(),
256                )?);
257                return Ok(());
258            }
259        }
260        let pledged = if end_op == EndOp::End {
261            // "auto-determine pledgedSrcSize" — overrides any prior pledge.
262            Some(in_size as u64)
263        } else {
264            self.requested_pledged
265        };
266        // `inBuffTarget = blockSizeMax + (blockSizeMax == pledgedSrcSize)`: for a
267        // pledge of exactly one block, avoid the automatic flush on reaching end
268        // of block, which would cost a 3-byte empty last block.
269        let one_block_pledge = |bs: usize| (pledged == Some(bs as u64)) as usize;
270        self.state = Some(if let Some(dict) = &self.dict {
271            // `ZSTD_CCtx_loadDictionary` → internal CDict → attach. The dict
272            // content is a permanent prefix of the staging buffer; input is staged
273            // (and the first chunk compressed) from `content_len`.
274            let init =
275                crate::compress::streaming_cdict_init(dict, self.level, pledged, self.checksum)?;
276            let block_size = init.fc.block_size_max();
277            let in_buff_target = init.content_len + block_size + one_block_pledge(block_size);
278            StreamState {
279                fc: init.fc,
280                in_buff: init.in_buff,
281                in_to_compress: init.content_len,
282                in_buff_pos: init.content_len,
283                in_buff_target,
284            }
285        } else {
286            let fc = FrameCompressor::new(self.level, pledged, self.checksum);
287            let block_size = fc.block_size_max();
288            let in_buff_size = fc.window_size() + block_size;
289            StreamState {
290                fc,
291                in_buff: vec![0u8; in_buff_size],
292                in_to_compress: 0,
293                in_buff_pos: 0,
294                in_buff_target: block_size + one_block_pledge(block_size),
295            }
296        });
297        Ok(())
298    }
299
300    /// Drive the multithreaded streaming path: buffer `input` into jobs
301    /// ([`MtStreamState`]). A `flush` emits the buffered partial section as a
302    /// (non-last) job, leaving the frame open.
303    fn mt_drive(&mut self, input: &[u8], op: EndOp, out: &mut Vec<u8>) -> Result<(), Error> {
304        match op {
305            EndOp::Continue => self.mt_state.as_mut().unwrap().push(input, out),
306            EndOp::Flush => self.mt_state.as_mut().unwrap().flush(out),
307            EndOp::End => {
308                self.mt_state.as_mut().unwrap().end(input, out)?;
309                self.frame_ended = true;
310                Ok(())
311            }
312        }
313    }
314
315    /// `ZSTD_compressStream_generic`, buffered mode. The output never blocks
316    /// (we append to a `Vec`), so the `zcss_flush` stage disappears and the
317    /// loop alternates load → compress until the directive is satisfied.
318    fn stream_op(&mut self, mut input: &[u8], op: EndOp, out: &mut Vec<u8>) -> Result<(), Error> {
319        if self.frame_ended {
320            return Err(Error::Encode("frame already finished"));
321        }
322        if self.state.is_none() && self.mt_state.is_none() {
323            // A first-call `finish` auto-pledges the content size. With workers and
324            // a size above the MT floor, C delegates to `ZSTD_compress2` — the
325            // one-shot MT frame (known size), not unknown-size streaming.
326            // A first-call `finish` auto-pledges to this call's input size
327            // (overriding any prior pledge), so above the MT floor it is the
328            // one-shot MT frame for that size regardless of `requested_pledged`.
329            if op == EndOp::End
330                && self.nb_workers > 0
331                && self.dict.is_none()
332                && input.len() as u64 > ZSTDMT_JOBSIZE_MIN
333            {
334                out.extend_from_slice(&compress_mt(
335                    input,
336                    self.level,
337                    self.nb_workers,
338                    self.job_size,
339                    self.overlap_log,
340                    self.checksum,
341                )?);
342                self.frame_ended = true;
343                return Ok(());
344            }
345            self.init(op, input.len())?;
346        }
347        if self.mt_state.is_some() {
348            return self.mt_drive(input, op, out);
349        }
350        let st = self.state.as_mut().expect("initialized above");
351        let block_size = st.fc.block_size_max();
352
353        loop {
354            // zcss_load: complete loading into the input buffer.
355            let to_load = st.in_buff_target - st.in_buff_pos;
356            let loaded = to_load.min(input.len());
357            st.in_buff[st.in_buff_pos..st.in_buff_pos + loaded].copy_from_slice(&input[..loaded]);
358            st.in_buff_pos += loaded;
359            input = &input[loaded..];
360            if op == EndOp::Continue && st.in_buff_pos < st.in_buff_target {
361                // Not enough input to fill a full block: stop here.
362                break;
363            }
364            if op == EndOp::Flush && st.in_buff_pos == st.in_to_compress {
365                // Nothing pending.
366                break;
367            }
368
369            // Streaming CDict attach: stop cleanly before the source grows past
370            // the window (where C's checkDictValidity/enforceMaxDist would drop
371            // the attached dict — that loadedDictEnd machinery isn't ported).
372            if st.fc.cdict_attach_overflow(st.in_buff_pos) {
373                return Err(Error::Encode(
374                    "streaming source outgrew the window with an attached dictionary \
375                     (large dict streams are not supported yet)",
376                ));
377            }
378
379            // Compress the staged chunk.
380            let last_block = op == EndOp::End && input.is_empty();
381            if last_block {
382                st.fc
383                    .compress_end(out, &st.in_buff, st.in_to_compress, st.in_buff_pos)?;
384                self.frame_ended = true;
385            } else {
386                st.fc.compress_continue(
387                    out,
388                    &st.in_buff,
389                    st.in_to_compress,
390                    st.in_buff_pos,
391                    false,
392                )?;
393            }
394
395            // Prepare the next block; past the buffer end, wrap to the start.
396            // The wrapped chunk is non-contiguous, turning the live window
397            // into the extDict.
398            st.in_buff_target = st.in_buff_pos + block_size;
399            if st.in_buff_target > st.in_buff.len() {
400                st.in_buff_pos = 0;
401                st.in_buff_target = block_size;
402            }
403            st.in_to_compress = st.in_buff_pos;
404            if self.frame_ended {
405                break;
406            }
407        }
408        Ok(())
409    }
410}