Skip to main content

zipatch_rs/chunk/
mod.rs

1//! Wire-format chunk types and the [`ZiPatchReader`] iterator.
2//!
3//! This module is the parsing layer: it decodes the raw `ZiPatch` byte
4//! stream into a stream of typed [`Chunk`] values. Each top-level
5//! variant corresponds to one 4-byte ASCII wire tag (`FHDR`, `APLY`,
6//! `SQPK`, …); the per-variant submodules below own the binary layout for
7//! their body. Nothing in this module touches the filesystem — apply-time
8//! effects live in [`crate::apply`].
9//!
10//! The [`ZiPatchReader`] iterator validates the 12-byte file magic on
11//! construction, then yields one [`Chunk`] per [`Iterator::next`] call
12//! until the internal `EOF_` terminator is consumed or a parse error
13//! surfaces.
14
15pub(crate) mod adir;
16pub(crate) mod afsp;
17pub(crate) mod aply;
18pub(crate) mod ddir;
19pub(crate) mod fhdr;
20pub(crate) mod sqpk;
21pub(crate) mod util;
22
23pub use adir::AddDirectory;
24pub use afsp::ApplyFreeSpace;
25pub use aply::{ApplyOption, ApplyOptionKind};
26pub use ddir::DeleteDirectory;
27pub use fhdr::{FileHeader, FileHeaderV2, FileHeaderV3};
28pub use sqpk::{SqpackFile, SqpkCommand};
29// Re-export SqpkCommand sub-types so callers can match on them
30pub use sqpk::{
31    IndexCommand, SqpkAddData, SqpkCompressedBlock, SqpkDeleteData, SqpkExpandData, SqpkFile,
32    SqpkFileOperation, SqpkHeader, SqpkHeaderTarget, SqpkIndex, SqpkPatchInfo, SqpkTargetInfo,
33    TargetFileKind, TargetHeaderKind,
34};
35
36use crate::reader::ReadExt;
37use crate::{Result, ZiPatchError};
38use tracing::trace;
39
40const MAGIC: [u8; 12] = [
41    0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
42];
43
44const MAX_CHUNK_SIZE: usize = 512 * 1024 * 1024;
45
46/// One top-level chunk parsed from a `ZiPatch` stream.
47///
48/// Each variant corresponds to a 4-byte ASCII wire tag. The tag dispatch table
49/// mirrors the C# reference in
50/// `lib/FFXIVQuickLauncher/.../Patching/ZiPatch/Chunk/ZiPatchChunk.cs`.
51///
52/// # Observed frequency
53///
54/// SE's XIVARR+ patch files almost exclusively contain `FHDR`, `APLY`, and
55/// `SQPK` chunks. `ADIR`/`DELD` can theoretically appear and are implemented,
56/// but are rarely emitted in practice. `APFS` has never been observed in modern
57/// patches (the reference implementation treats it as a no-op). `EOF_` is
58/// consumed by [`ZiPatchReader`] and is never yielded to the caller.
59///
60/// # Exhaustiveness
61///
62/// The enum is `#[non_exhaustive]`. Match arms should include a wildcard to
63/// remain forward-compatible as new chunk types are added.
64#[non_exhaustive]
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum Chunk {
67    /// `FHDR` — the first chunk in every patch file; carries version and
68    /// per-version patch metadata. See [`FileHeader`] for the versioned body.
69    FileHeader(FileHeader),
70    /// `APLY` — sets or clears a boolean apply-time flag on the
71    /// [`crate::ApplyContext`] (e.g. "ignore missing files"). See [`ApplyOption`].
72    ApplyOption(ApplyOption),
73    /// `APFS` — free-space book-keeping emitted by old patcher tooling; treated
74    /// as a no-op at apply time. See [`ApplyFreeSpace`].
75    ApplyFreeSpace(ApplyFreeSpace),
76    /// `ADIR` — instructs the patcher to create a directory under the game
77    /// install root. See [`AddDirectory`].
78    AddDirectory(AddDirectory),
79    /// `DELD` — instructs the patcher to remove a directory under the game
80    /// install root. See [`DeleteDirectory`].
81    DeleteDirectory(DeleteDirectory),
82    /// `SQPK` — the workhorse chunk; wraps one of eight sub-commands that
83    /// add, delete, expand, or replace `SqPack` data. See [`SqpkCommand`].
84    Sqpk(SqpkCommand),
85    /// `EOF_` — marks the clean end of the patch stream. [`ZiPatchReader`]
86    /// consumes this chunk internally; it is never yielded to the caller.
87    EndOfFile,
88}
89
90/// One parsed chunk plus its 4-byte ASCII tag and the byte count consumed
91/// from the input stream by its frame.
92///
93/// Returned by [`parse_chunk`]. The `consumed` count is exactly the size of
94/// the chunk's on-wire frame: `4 (body_len) + 4 (tag) + body_len + 4 (crc32)`
95/// = `body_len + 12`. This is what
96/// [`ZiPatchReader`](crate::ZiPatchReader) accumulates into its running
97/// byte counter for progress reporting.
98pub(crate) struct ParsedChunk {
99    pub(crate) chunk: Chunk,
100    pub(crate) tag: [u8; 4],
101    pub(crate) consumed: u64,
102}
103
104/// Parse one chunk frame from `r`.
105///
106/// # Wire framing
107///
108/// Each chunk is laid out as:
109///
110/// ```text
111/// [body_len: u32 BE] [tag: 4 bytes] [body: body_len bytes] [crc32: u32 BE]
112/// ```
113///
114/// The CRC32 is computed over `tag ++ body` (not over `body_len`), matching
115/// the C# `ChecksumBinaryReader` in the `XIVLauncher` reference. When
116/// `verify_checksums` is `true` and the stored CRC does not match the computed
117/// one, [`ZiPatchError::ChecksumMismatch`] is returned.
118///
119/// # Errors
120///
121/// - [`ZiPatchError::TruncatedPatch`] — the reader returns EOF while reading
122///   the `body_len` field (i.e. no more chunks are present but `EOF_` was
123///   never seen).
124/// - [`ZiPatchError::OversizedChunk`] — `body_len` exceeds 512 MiB.
125/// - [`ZiPatchError::ChecksumMismatch`] — CRC32 mismatch (only when
126///   `verify_checksums` is `true`).
127/// - [`ZiPatchError::UnknownChunkTag`] — tag is not recognised.
128/// - [`ZiPatchError::Io`] — any other I/O failure reading from `r`.
129pub(crate) fn parse_chunk<R: std::io::Read>(
130    r: &mut R,
131    verify_checksums: bool,
132) -> Result<ParsedChunk> {
133    let size = match r.read_u32_be() {
134        Ok(s) => s as usize,
135        Err(ZiPatchError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
136            return Err(ZiPatchError::TruncatedPatch);
137        }
138        Err(e) => return Err(e),
139    };
140    if size > MAX_CHUNK_SIZE {
141        return Err(ZiPatchError::OversizedChunk(size));
142    }
143    // buf layout: [tag: 4] [body: size] [crc32: 4]
144    let buf = r.read_exact_vec(size + 8)?;
145
146    let tag: [u8; 4] = buf[..4].try_into().unwrap();
147
148    let actual_crc = crc32fast::hash(&buf[..size + 4]);
149    let expected_crc = u32::from_be_bytes(buf[size + 4..].try_into().unwrap());
150    if verify_checksums && actual_crc != expected_crc {
151        return Err(ZiPatchError::ChecksumMismatch {
152            tag,
153            expected: expected_crc,
154            actual: actual_crc,
155        });
156    }
157
158    let body = &buf[4..size + 4];
159
160    trace!(tag = %String::from_utf8_lossy(&tag), "chunk");
161
162    // 4 (body_len) + 4 (tag) + size (body) + 4 (crc32)
163    let consumed = (size as u64) + 12;
164
165    let chunk = match &tag {
166        b"EOF_" => Chunk::EndOfFile,
167        b"FHDR" => Chunk::FileHeader(fhdr::parse(body)?),
168        b"APLY" => Chunk::ApplyOption(aply::parse(body)?),
169        b"APFS" => Chunk::ApplyFreeSpace(afsp::parse(body)?),
170        b"ADIR" => Chunk::AddDirectory(adir::parse(body)?),
171        b"DELD" => Chunk::DeleteDirectory(ddir::parse(body)?),
172        b"SQPK" => Chunk::Sqpk(sqpk::parse_sqpk(body)?),
173        _ => return Err(ZiPatchError::UnknownChunkTag(tag)),
174    };
175
176    Ok(ParsedChunk {
177        chunk,
178        tag,
179        consumed,
180    })
181}
182
183/// Iterator over the [`Chunk`]s in a `ZiPatch` stream.
184///
185/// `ZiPatchReader` wraps any [`std::io::Read`] source and yields one
186/// [`Chunk`] per call to [`Iterator::next`]. It validates the 12-byte file
187/// magic on construction, then reads chunks sequentially until the `EOF_`
188/// terminator is encountered or an error occurs.
189///
190/// # Stream contract
191///
192/// - **Magic** — the first 12 bytes must be `\x91ZIPATCH\r\n\x1a\n`. Any
193///   mismatch returns [`ZiPatchError::InvalidMagic`] from [`ZiPatchReader::new`].
194/// - **Framing** — every chunk is a length-prefixed frame:
195///   `[body_len: u32 BE] [tag: 4 B] [body: body_len B] [crc32: u32 BE]`.
196/// - **CRC32** — computed over `tag ++ body`. Verification is enabled by
197///   default; use [`ZiPatchReader::skip_checksum_verification`] to disable it.
198/// - **Termination** — the `EOF_` chunk is consumed internally and causes
199///   the iterator to return `None`. Call [`ZiPatchReader::is_complete`] after
200///   iteration to distinguish a clean end from a truncated stream.
201/// - **Fused** — once `None` is returned (either from `EOF_` or an error),
202///   subsequent calls to `next` also return `None`. The iterator implements
203///   [`std::iter::FusedIterator`].
204///
205/// # Errors
206///
207/// Each call to [`Iterator::next`] returns `Some(Err(e))` on parse failure,
208/// then `None` on all future calls. Possible errors include:
209/// - [`ZiPatchError::TruncatedPatch`] — stream ended before `EOF_`.
210/// - [`ZiPatchError::OversizedChunk`] — a declared chunk body exceeds 512 MiB.
211/// - [`ZiPatchError::ChecksumMismatch`] — CRC32 verification failed.
212/// - [`ZiPatchError::UnknownChunkTag`] — unrecognised 4-byte tag.
213/// - [`ZiPatchError::Io`] — underlying I/O failure.
214///
215/// # Example
216///
217/// Build a minimal in-memory patch (magic + `ADIR` + `EOF_`) and iterate it:
218///
219/// ```rust
220/// use std::io::Cursor;
221/// use zipatch_rs::{Chunk, ZiPatchReader};
222///
223/// // Helper: wrap tag + body into a correctly framed chunk with CRC32.
224/// fn make_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
225///     let mut crc_input = Vec::new();
226///     crc_input.extend_from_slice(tag);
227///     crc_input.extend_from_slice(body);
228///     let crc = crc32fast::hash(&crc_input);
229///
230///     let mut out = Vec::new();
231///     out.extend_from_slice(&(body.len() as u32).to_be_bytes());
232///     out.extend_from_slice(tag);
233///     out.extend_from_slice(body);
234///     out.extend_from_slice(&crc.to_be_bytes());
235///     out
236/// }
237///
238/// // 12-byte ZiPatch magic.
239/// let magic: [u8; 12] = [0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A];
240///
241/// // ADIR body: u32 BE name_len (7) + b"created".
242/// let mut adir_body = Vec::new();
243/// adir_body.extend_from_slice(&7u32.to_be_bytes());
244/// adir_body.extend_from_slice(b"created");
245///
246/// let mut patch = Vec::new();
247/// patch.extend_from_slice(&magic);
248/// patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
249/// patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
250///
251/// let chunks: Vec<_> = ZiPatchReader::new(Cursor::new(patch))
252///     .unwrap()
253///     .collect::<Result<_, _>>()
254///     .unwrap();
255///
256/// assert_eq!(chunks.len(), 1);
257/// assert!(matches!(chunks[0], Chunk::AddDirectory(_)));
258/// ```
259#[derive(Debug)]
260pub struct ZiPatchReader<R> {
261    inner: R,
262    done: bool,
263    verify_checksums: bool,
264    eof_seen: bool,
265    // Running total of bytes consumed from `inner`, including the 12-byte
266    // magic header. Updated after each successful `parse_chunk` call.
267    // Exposed via `bytes_read()` so the apply driver can fire monotonic
268    // progress events without instrumenting the underlying `Read` source.
269    bytes_read: u64,
270    // 4-byte ASCII tag of the most recently yielded chunk. `None` before the
271    // first successful `next()` and after iteration completes. Used by
272    // `apply_to` to attach the tag to per-chunk progress events without
273    // re-matching on the `Chunk` enum.
274    last_tag: Option<[u8; 4]>,
275}
276
277impl<R: std::io::Read> ZiPatchReader<R> {
278    /// Wrap `reader` and validate the leading 12-byte `ZiPatch` magic.
279    ///
280    /// Consumes exactly 12 bytes from `reader`. The magic is the byte sequence
281    /// `0x91 0x5A 0x49 0x50 0x41 0x54 0x43 0x48 0x0D 0x0A 0x1A 0x0A`
282    /// (i.e. `\x91ZIPATCH\r\n\x1a\n`).
283    ///
284    /// CRC32 verification is **enabled** by default. Call
285    /// [`ZiPatchReader::skip_checksum_verification`] before iterating to
286    /// disable it.
287    ///
288    /// # Errors
289    ///
290    /// - [`ZiPatchError::InvalidMagic`] — the first 12 bytes do not match the
291    ///   expected magic.
292    /// - [`ZiPatchError::Io`] — an I/O error occurred while reading the magic.
293    pub fn new(mut reader: R) -> Result<Self> {
294        let magic = reader.read_exact_vec(12)?;
295        if magic.as_slice() != MAGIC {
296            return Err(ZiPatchError::InvalidMagic);
297        }
298        Ok(Self {
299            inner: reader,
300            done: false,
301            verify_checksums: true,
302            eof_seen: false,
303            // The 12-byte magic header has already been consumed.
304            bytes_read: 12,
305            last_tag: None,
306        })
307    }
308
309    /// Enable per-chunk CRC32 verification (the default).
310    ///
311    /// This is the default state after [`ZiPatchReader::new`]. Calling this
312    /// method after construction is only necessary if
313    /// [`ZiPatchReader::skip_checksum_verification`] was previously called.
314    #[must_use]
315    pub fn verify_checksums(mut self) -> Self {
316        self.verify_checksums = true;
317        self
318    }
319
320    /// Disable per-chunk CRC32 verification.
321    ///
322    /// Useful when the source has already been verified out-of-band (e.g. a
323    /// download hash was checked before the file was opened), or when
324    /// processing known-good test data where the overhead is unnecessary.
325    #[must_use]
326    pub fn skip_checksum_verification(mut self) -> Self {
327        self.verify_checksums = false;
328        self
329    }
330
331    /// Returns `true` if iteration reached the `EOF_` terminator cleanly.
332    ///
333    /// A `false` return after `next()` yields `None` indicates the stream was
334    /// truncated — the download or file copy was incomplete. In that case the
335    /// iterator stopped because of a [`ZiPatchError::TruncatedPatch`] error,
336    /// not because the patch finished normally.
337    pub fn is_complete(&self) -> bool {
338        self.eof_seen
339    }
340
341    /// Returns the running total of bytes consumed from the patch stream.
342    ///
343    /// Starts at `12` after [`ZiPatchReader::new`] (the magic header has been
344    /// read) and increases monotonically by the size of each chunk's wire
345    /// frame after each successful [`Iterator::next`] call. Includes the
346    /// `EOF_` terminator's frame.
347    ///
348    /// On parse error, the counter is **not** advanced past the failing
349    /// chunk — it reflects the byte offset at the start of that chunk's
350    /// length prefix, not the broken position somewhere inside its frame.
351    /// Use this offset together with the surfaced error to point a user at
352    /// where the patch became unreadable.
353    ///
354    /// This is the same counter that the
355    /// [`apply_to`](crate::ZiPatchReader::apply_to) driver attaches to
356    /// [`ChunkEvent::bytes_read`](crate::ChunkEvent::bytes_read) when firing
357    /// progress events. Useful for the `bytes_applied / total_patch_size`
358    /// ratio in a progress bar.
359    #[must_use]
360    pub fn bytes_read(&self) -> u64 {
361        self.bytes_read
362    }
363
364    /// Returns the 4-byte ASCII tag of the most recently yielded chunk.
365    ///
366    /// `None` before the first successful [`Iterator::next`] call and after
367    /// the `EOF_` terminator has been consumed (or an error has been
368    /// surfaced). Used by [`apply_to`](crate::ZiPatchReader::apply_to) to
369    /// populate [`ChunkEvent::kind`](crate::ChunkEvent::kind).
370    #[must_use]
371    pub fn last_tag(&self) -> Option<[u8; 4]> {
372        self.last_tag
373    }
374}
375
376impl ZiPatchReader<std::io::BufReader<std::fs::File>> {
377    /// Open the file at `path`, wrap it in a [`std::io::BufReader`], and
378    /// validate the `ZiPatch` magic.
379    ///
380    /// This is a convenience constructor equivalent to:
381    ///
382    /// ```rust,no_run
383    /// # use std::io::BufReader;
384    /// # use std::fs::File;
385    /// # use zipatch_rs::ZiPatchReader;
386    /// let reader = ZiPatchReader::new(BufReader::new(File::open("patch.patch").unwrap())).unwrap();
387    /// ```
388    ///
389    /// # Errors
390    ///
391    /// - [`ZiPatchError::Io`] — the file could not be opened.
392    /// - [`ZiPatchError::InvalidMagic`] — the file does not start with the
393    ///   `ZiPatch` magic bytes.
394    pub fn from_path(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
395        let file = std::fs::File::open(path)?;
396        Self::new(std::io::BufReader::new(file))
397    }
398}
399
400impl<R: std::io::Read> Iterator for ZiPatchReader<R> {
401    type Item = Result<Chunk>;
402
403    fn next(&mut self) -> Option<Self::Item> {
404        if self.done {
405            return None;
406        }
407        match parse_chunk(&mut self.inner, self.verify_checksums) {
408            Ok(ParsedChunk {
409                chunk: Chunk::EndOfFile,
410                tag,
411                consumed,
412            }) => {
413                self.bytes_read += consumed;
414                self.last_tag = Some(tag);
415                self.done = true;
416                self.eof_seen = true;
417                None
418            }
419            Ok(ParsedChunk {
420                chunk,
421                tag,
422                consumed,
423            }) => {
424                self.bytes_read += consumed;
425                self.last_tag = Some(tag);
426                Some(Ok(chunk))
427            }
428            Err(e) => {
429                self.done = true;
430                Some(Err(e))
431            }
432        }
433    }
434}
435
436impl<R: std::io::Read> std::iter::FusedIterator for ZiPatchReader<R> {}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::test_utils::make_chunk;
442    use std::io::Cursor;
443
444    // --- parse_chunk error paths ---
445
446    #[test]
447    fn truncated_at_chunk_boundary_yields_truncated_patch() {
448        // Magic + no chunks: parse_chunk must see EOF on the body_len read and
449        // convert it to TruncatedPatch.  This exercises the
450        // `Err(ZiPatchError::Io(e)) if e.kind() == UnexpectedEof` arm at
451        // chunk/mod.rs line 121.
452        let mut patch = Vec::new();
453        patch.extend_from_slice(&MAGIC);
454        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
455        match reader
456            .next()
457            .expect("iterator must yield an error, not None")
458        {
459            Err(ZiPatchError::TruncatedPatch) => {}
460            other => panic!("expected TruncatedPatch, got {other:?}"),
461        }
462        assert!(!reader.is_complete(), "stream is not clean-ended");
463    }
464
465    #[test]
466    fn non_eof_io_error_on_body_len_read_propagates_as_io() {
467        // Exercises the `Err(e) => return Err(e)` arm at line 124: an I/O
468        // error that is NOT UnexpectedEof must propagate verbatim.
469        // We trigger this by passing a reader that errors immediately.
470        struct BrokenReader;
471        impl std::io::Read for BrokenReader {
472            fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
473                Err(std::io::Error::new(
474                    std::io::ErrorKind::BrokenPipe,
475                    "simulated broken pipe",
476                ))
477            }
478        }
479        let result = parse_chunk(&mut BrokenReader, false);
480        match result {
481            Err(ZiPatchError::Io(e)) => {
482                assert_eq!(
483                    e.kind(),
484                    std::io::ErrorKind::BrokenPipe,
485                    "non-EOF I/O error must propagate unchanged, got kind {:?}",
486                    e.kind()
487                );
488            }
489            Err(other) => panic!("expected ZiPatchError::Io(BrokenPipe), got {other:?}"),
490            Ok(_) => panic!("expected an error, got Ok"),
491        }
492    }
493
494    #[test]
495    fn truncated_after_one_chunk_yields_truncated_patch() {
496        // Magic + one well-formed ADIR + no more bytes: the second call to
497        // next() must surface TruncatedPatch, not None.
498        let mut adir_body = Vec::new();
499        adir_body.extend_from_slice(&4u32.to_be_bytes());
500        adir_body.extend_from_slice(b"test");
501        let chunk = make_chunk(b"ADIR", &adir_body);
502
503        let mut patch = Vec::new();
504        patch.extend_from_slice(&MAGIC);
505        patch.extend_from_slice(&chunk);
506
507        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
508        let first = reader.next().expect("first chunk must be present");
509        assert!(
510            first.is_ok(),
511            "first ADIR chunk should parse cleanly: {first:?}"
512        );
513        match reader.next().expect("second call must yield an error") {
514            Err(ZiPatchError::TruncatedPatch) => {}
515            other => panic!("expected TruncatedPatch on truncated stream, got {other:?}"),
516        }
517        assert!(
518            !reader.is_complete(),
519            "is_complete must be false after truncation"
520        );
521    }
522
523    #[test]
524    fn checksum_mismatch_returns_checksum_mismatch_error() {
525        // Corrupt the CRC32 field of an otherwise valid ADIR chunk and verify
526        // that parse_chunk returns ChecksumMismatch (not a panic or a wrong error).
527        let mut adir_body = Vec::new();
528        adir_body.extend_from_slice(&4u32.to_be_bytes());
529        adir_body.extend_from_slice(b"test");
530        let mut chunk = make_chunk(b"ADIR", &adir_body);
531        // Flip the last byte of the CRC32 field.
532        let last = chunk.len() - 1;
533        chunk[last] ^= 0xFF;
534
535        let mut cur = Cursor::new(chunk);
536        let result = parse_chunk(&mut cur, true);
537        assert!(
538            matches!(result, Err(ZiPatchError::ChecksumMismatch { .. })),
539            "corrupted CRC must yield ChecksumMismatch"
540        );
541    }
542
543    #[test]
544    fn unknown_chunk_tag_returns_unknown_chunk_tag_error() {
545        // A tag of all-Z bytes is not recognised; parse_chunk must return
546        // UnknownChunkTag carrying the raw 4-byte tag.
547        let chunk = make_chunk(b"ZZZZ", &[]);
548        let mut cur = Cursor::new(chunk);
549        match parse_chunk(&mut cur, false) {
550            Err(ZiPatchError::UnknownChunkTag(tag)) => {
551                assert_eq!(tag, *b"ZZZZ", "tag bytes must be preserved in error");
552            }
553            Err(other) => panic!("expected UnknownChunkTag, got {other:?}"),
554            Ok(_) => panic!("expected UnknownChunkTag, got Ok"),
555        }
556    }
557
558    #[test]
559    fn oversized_chunk_body_len_returns_oversized_chunk_error() {
560        // body_len == u32::MAX (> 512 MiB) must be rejected before any allocation.
561        let bytes = [0xFFu8, 0xFF, 0xFF, 0xFF];
562        let mut cur = Cursor::new(&bytes[..]);
563        let Err(ZiPatchError::OversizedChunk(size)) = parse_chunk(&mut cur, false) else {
564            panic!("expected OversizedChunk for u32::MAX body_len")
565        };
566        assert!(
567            size > MAX_CHUNK_SIZE,
568            "reported size {size} must exceed MAX_CHUNK_SIZE {MAX_CHUNK_SIZE}"
569        );
570    }
571
572    // --- ZiPatchReader byte-counter and tag accessors ---
573
574    #[test]
575    fn bytes_read_starts_at_12_before_first_chunk() {
576        // The magic header is 12 bytes; bytes_read must reflect that immediately
577        // after construction, before any chunk is read.
578        let mut patch = Vec::new();
579        patch.extend_from_slice(&MAGIC);
580        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
581        let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
582        assert_eq!(
583            reader.bytes_read(),
584            12,
585            "bytes_read must be 12 (magic only) before iteration starts"
586        );
587    }
588
589    #[test]
590    fn last_tag_is_none_before_first_chunk() {
591        // Before calling next(), last_tag must be None.
592        let mut patch = Vec::new();
593        patch.extend_from_slice(&MAGIC);
594        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
595        let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
596        assert_eq!(
597            reader.last_tag(),
598            None,
599            "last_tag must be None before any chunk is read"
600        );
601    }
602
603    #[test]
604    fn bytes_read_and_last_tag_track_each_chunk_frame() {
605        // MAGIC + ADIR("a") + EOF_ — verify bytes_read grows by the exact frame
606        // size after each chunk and that last_tag follows the stream.
607        let mut adir_body = Vec::new();
608        adir_body.extend_from_slice(&1u32.to_be_bytes());
609        adir_body.extend_from_slice(b"a");
610        // ADIR frame: 4(size) + 4(tag) + 5(body) + 4(crc) = 17 bytes
611        // EOF_  frame: 4 + 4 + 0 + 4 = 12 bytes
612
613        let mut patch = Vec::new();
614        patch.extend_from_slice(&MAGIC);
615        patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
616        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
617
618        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
619        assert_eq!(reader.bytes_read(), 12, "pre-read: magic only");
620        assert_eq!(reader.last_tag(), None, "pre-read: no tag yet");
621
622        let chunk = reader.next().unwrap().unwrap();
623        assert!(
624            matches!(chunk, Chunk::AddDirectory(_)),
625            "first chunk must be ADIR"
626        );
627        assert_eq!(
628            reader.bytes_read(),
629            12 + 17,
630            "after ADIR: magic + ADIR frame"
631        );
632        assert_eq!(
633            reader.last_tag(),
634            Some(*b"ADIR"),
635            "last_tag must be ADIR after first next()"
636        );
637
638        assert!(reader.next().is_none(), "EOF_ must terminate iteration");
639        assert_eq!(
640            reader.bytes_read(),
641            12 + 17 + 12,
642            "after EOF_: magic + ADIR + EOF_ frames"
643        );
644        assert_eq!(
645            reader.last_tag(),
646            Some(*b"EOF_"),
647            "last_tag must be EOF_ after stream ends"
648        );
649        assert!(reader.is_complete(), "is_complete must be true after EOF_");
650    }
651
652    #[test]
653    fn bytes_read_is_monotonically_non_decreasing() {
654        // Stream with two ADIR chunks + EOF_ — verify bytes_read only ever
655        // increases between calls to next() and that consuming the EOF_
656        // chunk (whose body is empty but whose frame is 12 bytes) still
657        // advances the counter past the last non-EOF position.
658        let make_adir = |name: &[u8]| -> Vec<u8> {
659            let mut body = Vec::new();
660            body.extend_from_slice(&(name.len() as u32).to_be_bytes());
661            body.extend_from_slice(name);
662            make_chunk(b"ADIR", &body)
663        };
664
665        let mut patch = Vec::new();
666        patch.extend_from_slice(&MAGIC);
667        patch.extend_from_slice(&make_adir(b"a"));
668        patch.extend_from_slice(&make_adir(b"bb"));
669        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
670
671        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
672        let mut prev = reader.bytes_read();
673        while let Some(result) = reader.next() {
674            result.unwrap();
675            let current = reader.bytes_read();
676            assert!(
677                current >= prev,
678                "bytes_read must be monotonically non-decreasing: {prev} -> {current}"
679            );
680            // For ADIR chunks with non-empty bodies, the increment must be
681            // strictly positive — a body of N bytes adds N + 12 frame bytes.
682            assert!(
683                current > prev,
684                "non-empty ADIR frame must strictly advance bytes_read: \
685                 {prev} -> {current}"
686            );
687            prev = current;
688        }
689        // EOF_ has been consumed: its 12-byte empty-body frame must have
690        // pushed the counter past the previous position.
691        assert!(
692            reader.bytes_read() > prev,
693            "consuming EOF_ must advance bytes_read by its 12-byte frame: \
694             {prev} -> {}",
695            reader.bytes_read()
696        );
697    }
698
699    // --- from_path constructor ---
700
701    #[test]
702    fn from_path_opens_minimal_patch_and_reaches_eof() {
703        let mut bytes = Vec::new();
704        bytes.extend_from_slice(&MAGIC);
705        bytes.extend_from_slice(&make_chunk(b"EOF_", &[]));
706
707        let tmp = tempfile::tempdir().unwrap();
708        let file_path = tmp.path().join("test.patch");
709        std::fs::write(&file_path, &bytes).unwrap();
710
711        let mut reader =
712            ZiPatchReader::from_path(&file_path).expect("from_path must open valid patch");
713        assert!(
714            reader.next().is_none(),
715            "EOF_ must terminate iteration immediately"
716        );
717        assert!(reader.is_complete(), "is_complete must be true after EOF_");
718    }
719
720    #[test]
721    fn from_path_returns_io_error_when_file_is_missing() {
722        let tmp = tempfile::tempdir().unwrap();
723        let file_path = tmp.path().join("nonexistent.patch");
724        assert!(
725            matches!(
726                ZiPatchReader::from_path(&file_path),
727                Err(ZiPatchError::Io(_))
728            ),
729            "from_path on a missing file must return ZiPatchError::Io"
730        );
731    }
732
733    // --- Iterator fused-ness and is_complete ---
734
735    #[test]
736    fn iterator_is_fused_after_error() {
737        // Once next() yields Some(Err(_)), all subsequent calls must yield None.
738        let mut patch = Vec::new();
739        patch.extend_from_slice(&MAGIC);
740        patch.extend_from_slice(&make_chunk(b"ZZZZ", &[])); // unknown tag → error
741
742        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
743        let first = reader.next();
744        assert!(
745            matches!(first, Some(Err(ZiPatchError::UnknownChunkTag(_)))),
746            "first call must yield the error: {first:?}"
747        );
748        // All subsequent calls must return None.
749        assert!(
750            reader.next().is_none(),
751            "fused: must return None after error"
752        );
753        assert!(reader.next().is_none(), "fused: still None on third call");
754    }
755
756    #[test]
757    fn is_complete_false_until_eof_seen() {
758        let mut adir_body = Vec::new();
759        adir_body.extend_from_slice(&1u32.to_be_bytes());
760        adir_body.extend_from_slice(b"x");
761
762        let mut patch = Vec::new();
763        patch.extend_from_slice(&MAGIC);
764        patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
765        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
766
767        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
768        assert!(
769            !reader.is_complete(),
770            "not complete before reading anything"
771        );
772        reader.next().unwrap().unwrap(); // consume ADIR
773        assert!(
774            !reader.is_complete(),
775            "not complete after ADIR, before EOF_"
776        );
777        assert!(reader.next().is_none(), "EOF_ consumed");
778        assert!(reader.is_complete(), "complete after EOF_ consumed");
779    }
780}