Skip to main content

zipatch_rs/chunk/
mod.rs

1pub(crate) mod adir;
2pub(crate) mod afsp;
3pub(crate) mod aply;
4pub(crate) mod ddir;
5pub(crate) mod fhdr;
6pub(crate) mod sqpk;
7pub(crate) mod util;
8
9pub use adir::AddDirectory;
10pub use afsp::ApplyFreeSpace;
11pub use aply::{ApplyOption, ApplyOptionKind};
12pub use ddir::DeleteDirectory;
13pub use fhdr::{FileHeader, FileHeaderV2, FileHeaderV3};
14pub use sqpk::{SqpackFile, SqpkCommand};
15// Re-export SqpkCommand sub-types so callers can match on them
16pub use sqpk::{
17    IndexCommand, SqpkAddData, SqpkCompressedBlock, SqpkDeleteData, SqpkExpandData, SqpkFile,
18    SqpkFileOperation, SqpkHeader, SqpkHeaderTarget, SqpkIndex, SqpkPatchInfo, SqpkTargetInfo,
19    TargetFileKind, TargetHeaderKind,
20};
21
22use crate::reader::ReadExt;
23use crate::{Result, ZiPatchError};
24use tracing::trace;
25
26const MAGIC: [u8; 12] = [
27    0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
28];
29
30const MAX_CHUNK_SIZE: usize = 512 * 1024 * 1024;
31
32/// One top-level chunk parsed from a `ZiPatch` stream.
33///
34/// Each variant corresponds to a 4-byte ASCII wire tag. The tag dispatch table
35/// mirrors the C# reference in
36/// `lib/FFXIVQuickLauncher/.../Patching/ZiPatch/Chunk/ZiPatchChunk.cs`.
37///
38/// # Observed frequency
39///
40/// SE's XIVARR+ patch files almost exclusively contain `FHDR`, `APLY`, and
41/// `SQPK` chunks. `ADIR`/`DELD` can theoretically appear and are implemented,
42/// but are rarely emitted in practice. `APFS` has never been observed in modern
43/// patches (the reference implementation treats it as a no-op). `EOF_` is
44/// consumed by [`ZiPatchReader`] and is never yielded to the caller.
45///
46/// # Exhaustiveness
47///
48/// The enum is `#[non_exhaustive]`. Match arms should include a wildcard to
49/// remain forward-compatible as new chunk types are added.
50#[non_exhaustive]
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Chunk {
53    /// `FHDR` — the first chunk in every patch file; carries version and
54    /// per-version patch metadata. See [`FileHeader`] for the versioned body.
55    FileHeader(FileHeader),
56    /// `APLY` — sets or clears a boolean apply-time flag on the
57    /// [`crate::ApplyContext`] (e.g. "ignore missing files"). See [`ApplyOption`].
58    ApplyOption(ApplyOption),
59    /// `APFS` — free-space book-keeping emitted by old patcher tooling; treated
60    /// as a no-op at apply time. See [`ApplyFreeSpace`].
61    ApplyFreeSpace(ApplyFreeSpace),
62    /// `ADIR` — instructs the patcher to create a directory under the game
63    /// install root. See [`AddDirectory`].
64    AddDirectory(AddDirectory),
65    /// `DELD` — instructs the patcher to remove a directory under the game
66    /// install root. See [`DeleteDirectory`].
67    DeleteDirectory(DeleteDirectory),
68    /// `SQPK` — the workhorse chunk; wraps one of eight sub-commands that
69    /// add, delete, expand, or replace `SqPack` data. See [`SqpkCommand`].
70    Sqpk(SqpkCommand),
71    /// `EOF_` — marks the clean end of the patch stream. [`ZiPatchReader`]
72    /// consumes this chunk internally; it is never yielded to the caller.
73    EndOfFile,
74}
75
76/// Parse one chunk frame from `r`.
77///
78/// # Wire framing
79///
80/// Each chunk is laid out as:
81///
82/// ```text
83/// [body_len: u32 BE] [tag: 4 bytes] [body: body_len bytes] [crc32: u32 BE]
84/// ```
85///
86/// The CRC32 is computed over `tag ++ body` (not over `body_len`), matching
87/// the C# `ChecksumBinaryReader` in the `XIVLauncher` reference. When
88/// `verify_checksums` is `true` and the stored CRC does not match the computed
89/// one, [`ZiPatchError::ChecksumMismatch`] is returned.
90///
91/// # Errors
92///
93/// - [`ZiPatchError::TruncatedPatch`] — the reader returns EOF while reading
94///   the `body_len` field (i.e. no more chunks are present but `EOF_` was
95///   never seen).
96/// - [`ZiPatchError::OversizedChunk`] — `body_len` exceeds 512 MiB.
97/// - [`ZiPatchError::ChecksumMismatch`] — CRC32 mismatch (only when
98///   `verify_checksums` is `true`).
99/// - [`ZiPatchError::UnknownChunkTag`] — tag is not recognised.
100/// - [`ZiPatchError::Io`] — any other I/O failure reading from `r`.
101pub(crate) fn parse_chunk<R: std::io::Read>(r: &mut R, verify_checksums: bool) -> Result<Chunk> {
102    let size = match r.read_u32_be() {
103        Ok(s) => s as usize,
104        Err(ZiPatchError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
105            return Err(ZiPatchError::TruncatedPatch);
106        }
107        Err(e) => return Err(e),
108    };
109    if size > MAX_CHUNK_SIZE {
110        return Err(ZiPatchError::OversizedChunk(size));
111    }
112    // buf layout: [tag: 4] [body: size] [crc32: 4]
113    let buf = r.read_exact_vec(size + 8)?;
114
115    let tag: [u8; 4] = buf[..4].try_into().unwrap();
116
117    let actual_crc = crc32fast::hash(&buf[..size + 4]);
118    let expected_crc = u32::from_be_bytes(buf[size + 4..].try_into().unwrap());
119    if verify_checksums && actual_crc != expected_crc {
120        return Err(ZiPatchError::ChecksumMismatch {
121            tag,
122            expected: expected_crc,
123            actual: actual_crc,
124        });
125    }
126
127    let body = &buf[4..size + 4];
128
129    trace!(tag = %String::from_utf8_lossy(&tag), "chunk");
130
131    match &tag {
132        b"EOF_" => Ok(Chunk::EndOfFile),
133        b"FHDR" => Ok(Chunk::FileHeader(fhdr::parse(body)?)),
134        b"APLY" => Ok(Chunk::ApplyOption(aply::parse(body)?)),
135        b"APFS" => Ok(Chunk::ApplyFreeSpace(afsp::parse(body)?)),
136        b"ADIR" => Ok(Chunk::AddDirectory(adir::parse(body)?)),
137        b"DELD" => Ok(Chunk::DeleteDirectory(ddir::parse(body)?)),
138        b"SQPK" => Ok(Chunk::Sqpk(sqpk::parse_sqpk(body)?)),
139        _ => Err(ZiPatchError::UnknownChunkTag(tag)),
140    }
141}
142
143/// Iterator over the [`Chunk`]s in a `ZiPatch` stream.
144///
145/// `ZiPatchReader` wraps any [`std::io::Read`] source and yields one
146/// [`Chunk`] per call to [`Iterator::next`]. It validates the 12-byte file
147/// magic on construction, then reads chunks sequentially until the `EOF_`
148/// terminator is encountered or an error occurs.
149///
150/// # Stream contract
151///
152/// - **Magic** — the first 12 bytes must be `\x91ZIPATCH\r\n\x1a\n`. Any
153///   mismatch returns [`ZiPatchError::InvalidMagic`] from [`ZiPatchReader::new`].
154/// - **Framing** — every chunk is a length-prefixed frame:
155///   `[body_len: u32 BE] [tag: 4 B] [body: body_len B] [crc32: u32 BE]`.
156/// - **CRC32** — computed over `tag ++ body`. Verification is enabled by
157///   default; use [`ZiPatchReader::skip_checksum_verification`] to disable it.
158/// - **Termination** — the `EOF_` chunk is consumed internally and causes
159///   the iterator to return `None`. Call [`ZiPatchReader::is_complete`] after
160///   iteration to distinguish a clean end from a truncated stream.
161/// - **Fused** — once `None` is returned (either from `EOF_` or an error),
162///   subsequent calls to `next` also return `None`. The iterator implements
163///   [`std::iter::FusedIterator`].
164///
165/// # Errors
166///
167/// Each call to [`Iterator::next`] returns `Some(Err(e))` on parse failure,
168/// then `None` on all future calls. Possible errors include:
169/// - [`ZiPatchError::TruncatedPatch`] — stream ended before `EOF_`.
170/// - [`ZiPatchError::OversizedChunk`] — a declared chunk body exceeds 512 MiB.
171/// - [`ZiPatchError::ChecksumMismatch`] — CRC32 verification failed.
172/// - [`ZiPatchError::UnknownChunkTag`] — unrecognised 4-byte tag.
173/// - [`ZiPatchError::Io`] — underlying I/O failure.
174///
175/// # Example
176///
177/// Build a minimal in-memory patch (magic + `ADIR` + `EOF_`) and iterate it:
178///
179/// ```rust
180/// use std::io::Cursor;
181/// use zipatch_rs::{Chunk, ZiPatchReader};
182///
183/// // Helper: wrap tag + body into a correctly framed chunk with CRC32.
184/// fn make_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
185///     let mut crc_input = Vec::new();
186///     crc_input.extend_from_slice(tag);
187///     crc_input.extend_from_slice(body);
188///     let crc = crc32fast::hash(&crc_input);
189///
190///     let mut out = Vec::new();
191///     out.extend_from_slice(&(body.len() as u32).to_be_bytes());
192///     out.extend_from_slice(tag);
193///     out.extend_from_slice(body);
194///     out.extend_from_slice(&crc.to_be_bytes());
195///     out
196/// }
197///
198/// // 12-byte ZiPatch magic.
199/// let magic: [u8; 12] = [0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A];
200///
201/// // ADIR body: u32 BE name_len (7) + b"created".
202/// let mut adir_body = Vec::new();
203/// adir_body.extend_from_slice(&7u32.to_be_bytes());
204/// adir_body.extend_from_slice(b"created");
205///
206/// let mut patch = Vec::new();
207/// patch.extend_from_slice(&magic);
208/// patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
209/// patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
210///
211/// let chunks: Vec<_> = ZiPatchReader::new(Cursor::new(patch))
212///     .unwrap()
213///     .collect::<Result<_, _>>()
214///     .unwrap();
215///
216/// assert_eq!(chunks.len(), 1);
217/// assert!(matches!(chunks[0], Chunk::AddDirectory(_)));
218/// ```
219#[derive(Debug)]
220pub struct ZiPatchReader<R> {
221    inner: R,
222    done: bool,
223    verify_checksums: bool,
224    eof_seen: bool,
225}
226
227impl<R: std::io::Read> ZiPatchReader<R> {
228    /// Wrap `reader` and validate the leading 12-byte `ZiPatch` magic.
229    ///
230    /// Consumes exactly 12 bytes from `reader`. The magic is the byte sequence
231    /// `0x91 0x5A 0x49 0x50 0x41 0x54 0x43 0x48 0x0D 0x0A 0x1A 0x0A`
232    /// (i.e. `\x91ZIPATCH\r\n\x1a\n`).
233    ///
234    /// CRC32 verification is **enabled** by default. Call
235    /// [`ZiPatchReader::skip_checksum_verification`] before iterating to
236    /// disable it.
237    ///
238    /// # Errors
239    ///
240    /// - [`ZiPatchError::InvalidMagic`] — the first 12 bytes do not match the
241    ///   expected magic.
242    /// - [`ZiPatchError::Io`] — an I/O error occurred while reading the magic.
243    pub fn new(mut reader: R) -> Result<Self> {
244        let magic = reader.read_exact_vec(12)?;
245        if magic.as_slice() != MAGIC {
246            return Err(ZiPatchError::InvalidMagic);
247        }
248        Ok(Self {
249            inner: reader,
250            done: false,
251            verify_checksums: true,
252            eof_seen: false,
253        })
254    }
255
256    /// Enable per-chunk CRC32 verification (the default).
257    ///
258    /// This is the default state after [`ZiPatchReader::new`]. Calling this
259    /// method after construction is only necessary if
260    /// [`ZiPatchReader::skip_checksum_verification`] was previously called.
261    #[must_use]
262    pub fn verify_checksums(mut self) -> Self {
263        self.verify_checksums = true;
264        self
265    }
266
267    /// Disable per-chunk CRC32 verification.
268    ///
269    /// Useful when the source has already been verified out-of-band (e.g. a
270    /// download hash was checked before the file was opened), or when
271    /// processing known-good test data where the overhead is unnecessary.
272    #[must_use]
273    pub fn skip_checksum_verification(mut self) -> Self {
274        self.verify_checksums = false;
275        self
276    }
277
278    /// Returns `true` if iteration reached the `EOF_` terminator cleanly.
279    ///
280    /// A `false` return after `next()` yields `None` indicates the stream was
281    /// truncated — the download or file copy was incomplete. In that case the
282    /// iterator stopped because of a [`ZiPatchError::TruncatedPatch`] error,
283    /// not because the patch finished normally.
284    pub fn is_complete(&self) -> bool {
285        self.eof_seen
286    }
287}
288
289impl ZiPatchReader<std::io::BufReader<std::fs::File>> {
290    /// Open the file at `path`, wrap it in a [`std::io::BufReader`], and
291    /// validate the `ZiPatch` magic.
292    ///
293    /// This is a convenience constructor equivalent to:
294    ///
295    /// ```rust,no_run
296    /// # use std::io::BufReader;
297    /// # use std::fs::File;
298    /// # use zipatch_rs::ZiPatchReader;
299    /// let reader = ZiPatchReader::new(BufReader::new(File::open("patch.patch").unwrap())).unwrap();
300    /// ```
301    ///
302    /// # Errors
303    ///
304    /// - [`ZiPatchError::Io`] — the file could not be opened.
305    /// - [`ZiPatchError::InvalidMagic`] — the file does not start with the
306    ///   `ZiPatch` magic bytes.
307    pub fn from_path(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
308        let file = std::fs::File::open(path)?;
309        Self::new(std::io::BufReader::new(file))
310    }
311}
312
313impl<R: std::io::Read> Iterator for ZiPatchReader<R> {
314    type Item = Result<Chunk>;
315
316    fn next(&mut self) -> Option<Self::Item> {
317        if self.done {
318            return None;
319        }
320        match parse_chunk(&mut self.inner, self.verify_checksums) {
321            Ok(Chunk::EndOfFile) => {
322                self.done = true;
323                self.eof_seen = true;
324                None
325            }
326            Ok(chunk) => Some(Ok(chunk)),
327            Err(e) => {
328                self.done = true;
329                Some(Err(e))
330            }
331        }
332    }
333}
334
335impl<R: std::io::Read> std::iter::FusedIterator for ZiPatchReader<R> {}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::io::Cursor;
341
342    // Build a well-formed chunk: 4-byte BE size | 4-byte tag | body | 4-byte BE CRC32.
343    fn build_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
344        let size = body.len() as u32;
345        let mut buf = Vec::with_capacity(8 + body.len() + 4);
346        buf.extend_from_slice(&size.to_be_bytes());
347        buf.extend_from_slice(tag);
348        buf.extend_from_slice(body);
349        let crc = {
350            let mut crc_input = Vec::with_capacity(4 + body.len());
351            crc_input.extend_from_slice(tag);
352            crc_input.extend_from_slice(body);
353            crc32fast::hash(&crc_input)
354        };
355        buf.extend_from_slice(&crc.to_be_bytes());
356        buf
357    }
358
359    #[test]
360    fn truncated_at_chunk_boundary_maps_to_truncated_patch() {
361        // FHDR chunk: 4-byte version + 4-byte tag + 8-byte sizes — body is 24 bytes.
362        // We just need any well-formed chunk that isn't EOF_.
363        // Use ADIR with a minimal body: 4-byte path_len = 0 path "" — body is whatever
364        // adir::parse accepts. Simplest: a custom tag isn't viable since unknown tags error.
365        // Use APLY which has a u32 option_id and (typically) a u32 value — but to keep it
366        // independent of inner parser quirks, we'll just verify TruncatedPatch is returned
367        // when the reader is positioned exactly at a chunk boundary with no more bytes.
368        let mut patch = Vec::new();
369        patch.extend_from_slice(&MAGIC);
370        // No chunks at all — first parse_chunk hits EOF on the 4-byte size read.
371        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
372        let first = reader.next().expect("iterator must yield once");
373        match first {
374            Err(ZiPatchError::TruncatedPatch) => {}
375            other => panic!("expected TruncatedPatch, got {other:?}"),
376        }
377        assert!(!reader.is_complete());
378    }
379
380    #[test]
381    fn truncated_after_one_chunk_maps_to_truncated_patch() {
382        // Magic + one ADIR chunk (well-formed) + nothing else.
383        // The first ADIR yields successfully, the next call must yield TruncatedPatch.
384        // ADIR body is just a length-prefixed name (u32 BE name_len + name bytes).
385        let mut adir_body = Vec::new();
386        adir_body.extend_from_slice(&4u32.to_be_bytes()); // name_len
387        adir_body.extend_from_slice(b"test"); // name
388        let chunk = build_chunk(b"ADIR", &adir_body);
389
390        let mut patch = Vec::new();
391        patch.extend_from_slice(&MAGIC);
392        patch.extend_from_slice(&chunk);
393
394        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
395        let first = reader.next().expect("first chunk");
396        assert!(first.is_ok(), "first chunk should parse: {first:?}");
397        let second = reader.next().expect("iterator yields TruncatedPatch");
398        match second {
399            Err(ZiPatchError::TruncatedPatch) => {}
400            other => panic!("expected TruncatedPatch, got {other:?}"),
401        }
402        assert!(!reader.is_complete());
403    }
404
405    #[test]
406    fn oversized_chunk_size_rejected() {
407        // Craft a stream where the first 4 bytes read by parse_chunk encode u32::MAX.
408        let bytes = [0xFFu8, 0xFF, 0xFF, 0xFF];
409        let mut cur = Cursor::new(&bytes[..]);
410        let Err(ZiPatchError::OversizedChunk(size)) = parse_chunk(&mut cur, false) else {
411            panic!("expected OversizedChunk for oversized chunk")
412        };
413        assert!(
414            size > MAX_CHUNK_SIZE,
415            "expected size > MAX_CHUNK_SIZE, got {size}"
416        );
417    }
418
419    #[test]
420    fn from_path_opens_and_parses_patch_file() {
421        // Write a minimal valid patch (MAGIC + EOF_) to a temp file, then use from_path.
422        let mut bytes = Vec::new();
423        bytes.extend_from_slice(&MAGIC);
424        bytes.extend_from_slice(&build_chunk(b"EOF_", &[]));
425
426        let tmp = tempfile::tempdir().unwrap();
427        let file_path = tmp.path().join("test.patch");
428        std::fs::write(&file_path, &bytes).unwrap();
429
430        let mut reader = ZiPatchReader::from_path(&file_path).expect("from_path opens patch file");
431        assert!(reader.next().is_none(), "EOF_ should terminate iteration");
432        assert!(reader.is_complete());
433    }
434
435    #[test]
436    fn from_path_returns_io_error_on_missing_file() {
437        let tmp = tempfile::tempdir().unwrap();
438        let file_path = tmp.path().join("nonexistent.patch");
439        assert!(matches!(
440            ZiPatchReader::from_path(&file_path),
441            Err(ZiPatchError::Io(_))
442        ));
443    }
444}