zipatch-rs 1.0.1

Parser for FFXIV ZiPatch patch files
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
pub(crate) mod adir;
pub(crate) mod afsp;
pub(crate) mod aply;
pub(crate) mod ddir;
pub(crate) mod fhdr;
pub(crate) mod sqpk;
pub(crate) mod util;

pub use adir::AddDirectory;
pub use afsp::ApplyFreeSpace;
pub use aply::{ApplyOption, ApplyOptionKind};
pub use ddir::DeleteDirectory;
pub use fhdr::{FileHeader, FileHeaderV2, FileHeaderV3};
pub use sqpk::{SqpackFile, SqpkCommand};
// Re-export SqpkCommand sub-types so callers can match on them
pub use sqpk::{
    IndexCommand, SqpkAddData, SqpkCompressedBlock, SqpkDeleteData, SqpkExpandData, SqpkFile,
    SqpkFileOperation, SqpkHeader, SqpkHeaderTarget, SqpkIndex, SqpkPatchInfo, SqpkTargetInfo,
    TargetFileKind, TargetHeaderKind,
};

use crate::reader::ReadExt;
use crate::{Result, ZiPatchError};
use tracing::trace;

const MAGIC: [u8; 12] = [
    0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
];

const MAX_CHUNK_SIZE: usize = 512 * 1024 * 1024;

/// One top-level chunk parsed from a `ZiPatch` stream.
///
/// Each variant corresponds to a 4-byte ASCII wire tag. The tag dispatch table
/// mirrors the C# reference in
/// `lib/FFXIVQuickLauncher/.../Patching/ZiPatch/Chunk/ZiPatchChunk.cs`.
///
/// # Observed frequency
///
/// SE's XIVARR+ patch files almost exclusively contain `FHDR`, `APLY`, and
/// `SQPK` chunks. `ADIR`/`DELD` can theoretically appear and are implemented,
/// but are rarely emitted in practice. `APFS` has never been observed in modern
/// patches (the reference implementation treats it as a no-op). `EOF_` is
/// consumed by [`ZiPatchReader`] and is never yielded to the caller.
///
/// # Exhaustiveness
///
/// The enum is `#[non_exhaustive]`. Match arms should include a wildcard to
/// remain forward-compatible as new chunk types are added.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Chunk {
    /// `FHDR` — the first chunk in every patch file; carries version and
    /// per-version patch metadata. See [`FileHeader`] for the versioned body.
    FileHeader(FileHeader),
    /// `APLY` — sets or clears a boolean apply-time flag on the
    /// [`crate::ApplyContext`] (e.g. "ignore missing files"). See [`ApplyOption`].
    ApplyOption(ApplyOption),
    /// `APFS` — free-space book-keeping emitted by old patcher tooling; treated
    /// as a no-op at apply time. See [`ApplyFreeSpace`].
    ApplyFreeSpace(ApplyFreeSpace),
    /// `ADIR` — instructs the patcher to create a directory under the game
    /// install root. See [`AddDirectory`].
    AddDirectory(AddDirectory),
    /// `DELD` — instructs the patcher to remove a directory under the game
    /// install root. See [`DeleteDirectory`].
    DeleteDirectory(DeleteDirectory),
    /// `SQPK` — the workhorse chunk; wraps one of eight sub-commands that
    /// add, delete, expand, or replace `SqPack` data. See [`SqpkCommand`].
    Sqpk(SqpkCommand),
    /// `EOF_` — marks the clean end of the patch stream. [`ZiPatchReader`]
    /// consumes this chunk internally; it is never yielded to the caller.
    EndOfFile,
}

/// Parse one chunk frame from `r`.
///
/// # Wire framing
///
/// Each chunk is laid out as:
///
/// ```text
/// [body_len: u32 BE] [tag: 4 bytes] [body: body_len bytes] [crc32: u32 BE]
/// ```
///
/// The CRC32 is computed over `tag ++ body` (not over `body_len`), matching
/// the C# `ChecksumBinaryReader` in the `XIVLauncher` reference. When
/// `verify_checksums` is `true` and the stored CRC does not match the computed
/// one, [`ZiPatchError::ChecksumMismatch`] is returned.
///
/// # Errors
///
/// - [`ZiPatchError::TruncatedPatch`] — the reader returns EOF while reading
///   the `body_len` field (i.e. no more chunks are present but `EOF_` was
///   never seen).
/// - [`ZiPatchError::OversizedChunk`] — `body_len` exceeds 512 MiB.
/// - [`ZiPatchError::ChecksumMismatch`] — CRC32 mismatch (only when
///   `verify_checksums` is `true`).
/// - [`ZiPatchError::UnknownChunkTag`] — tag is not recognised.
/// - [`ZiPatchError::Io`] — any other I/O failure reading from `r`.
pub(crate) fn parse_chunk<R: std::io::Read>(r: &mut R, verify_checksums: bool) -> Result<Chunk> {
    let size = match r.read_u32_be() {
        Ok(s) => s as usize,
        Err(ZiPatchError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
            return Err(ZiPatchError::TruncatedPatch);
        }
        Err(e) => return Err(e),
    };
    if size > MAX_CHUNK_SIZE {
        return Err(ZiPatchError::OversizedChunk(size));
    }
    // buf layout: [tag: 4] [body: size] [crc32: 4]
    let buf = r.read_exact_vec(size + 8)?;

    let tag: [u8; 4] = buf[..4].try_into().unwrap();

    let actual_crc = crc32fast::hash(&buf[..size + 4]);
    let expected_crc = u32::from_be_bytes(buf[size + 4..].try_into().unwrap());
    if verify_checksums && actual_crc != expected_crc {
        return Err(ZiPatchError::ChecksumMismatch {
            tag,
            expected: expected_crc,
            actual: actual_crc,
        });
    }

    let body = &buf[4..size + 4];

    trace!(tag = %String::from_utf8_lossy(&tag), "chunk");

    match &tag {
        b"EOF_" => Ok(Chunk::EndOfFile),
        b"FHDR" => Ok(Chunk::FileHeader(fhdr::parse(body)?)),
        b"APLY" => Ok(Chunk::ApplyOption(aply::parse(body)?)),
        b"APFS" => Ok(Chunk::ApplyFreeSpace(afsp::parse(body)?)),
        b"ADIR" => Ok(Chunk::AddDirectory(adir::parse(body)?)),
        b"DELD" => Ok(Chunk::DeleteDirectory(ddir::parse(body)?)),
        b"SQPK" => Ok(Chunk::Sqpk(sqpk::parse_sqpk(body)?)),
        _ => Err(ZiPatchError::UnknownChunkTag(tag)),
    }
}

/// Iterator over the [`Chunk`]s in a `ZiPatch` stream.
///
/// `ZiPatchReader` wraps any [`std::io::Read`] source and yields one
/// [`Chunk`] per call to [`Iterator::next`]. It validates the 12-byte file
/// magic on construction, then reads chunks sequentially until the `EOF_`
/// terminator is encountered or an error occurs.
///
/// # Stream contract
///
/// - **Magic** — the first 12 bytes must be `\x91ZIPATCH\r\n\x1a\n`. Any
///   mismatch returns [`ZiPatchError::InvalidMagic`] from [`ZiPatchReader::new`].
/// - **Framing** — every chunk is a length-prefixed frame:
///   `[body_len: u32 BE] [tag: 4 B] [body: body_len B] [crc32: u32 BE]`.
/// - **CRC32** — computed over `tag ++ body`. Verification is enabled by
///   default; use [`ZiPatchReader::skip_checksum_verification`] to disable it.
/// - **Termination** — the `EOF_` chunk is consumed internally and causes
///   the iterator to return `None`. Call [`ZiPatchReader::is_complete`] after
///   iteration to distinguish a clean end from a truncated stream.
/// - **Fused** — once `None` is returned (either from `EOF_` or an error),
///   subsequent calls to `next` also return `None`. The iterator implements
///   [`std::iter::FusedIterator`].
///
/// # Errors
///
/// Each call to [`Iterator::next`] returns `Some(Err(e))` on parse failure,
/// then `None` on all future calls. Possible errors include:
/// - [`ZiPatchError::TruncatedPatch`] — stream ended before `EOF_`.
/// - [`ZiPatchError::OversizedChunk`] — a declared chunk body exceeds 512 MiB.
/// - [`ZiPatchError::ChecksumMismatch`] — CRC32 verification failed.
/// - [`ZiPatchError::UnknownChunkTag`] — unrecognised 4-byte tag.
/// - [`ZiPatchError::Io`] — underlying I/O failure.
///
/// # Example
///
/// Build a minimal in-memory patch (magic + `ADIR` + `EOF_`) and iterate it:
///
/// ```rust
/// use std::io::Cursor;
/// use zipatch_rs::{Chunk, ZiPatchReader};
///
/// // Helper: wrap tag + body into a correctly framed chunk with CRC32.
/// fn make_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
///     let mut crc_input = Vec::new();
///     crc_input.extend_from_slice(tag);
///     crc_input.extend_from_slice(body);
///     let crc = crc32fast::hash(&crc_input);
///
///     let mut out = Vec::new();
///     out.extend_from_slice(&(body.len() as u32).to_be_bytes());
///     out.extend_from_slice(tag);
///     out.extend_from_slice(body);
///     out.extend_from_slice(&crc.to_be_bytes());
///     out
/// }
///
/// // 12-byte ZiPatch magic.
/// let magic: [u8; 12] = [0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A];
///
/// // ADIR body: u32 BE name_len (7) + b"created".
/// let mut adir_body = Vec::new();
/// adir_body.extend_from_slice(&7u32.to_be_bytes());
/// adir_body.extend_from_slice(b"created");
///
/// let mut patch = Vec::new();
/// patch.extend_from_slice(&magic);
/// patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
/// patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
///
/// let chunks: Vec<_> = ZiPatchReader::new(Cursor::new(patch))
///     .unwrap()
///     .collect::<Result<_, _>>()
///     .unwrap();
///
/// assert_eq!(chunks.len(), 1);
/// assert!(matches!(chunks[0], Chunk::AddDirectory(_)));
/// ```
#[derive(Debug)]
pub struct ZiPatchReader<R> {
    inner: R,
    done: bool,
    verify_checksums: bool,
    eof_seen: bool,
}

impl<R: std::io::Read> ZiPatchReader<R> {
    /// Wrap `reader` and validate the leading 12-byte `ZiPatch` magic.
    ///
    /// Consumes exactly 12 bytes from `reader`. The magic is the byte sequence
    /// `0x91 0x5A 0x49 0x50 0x41 0x54 0x43 0x48 0x0D 0x0A 0x1A 0x0A`
    /// (i.e. `\x91ZIPATCH\r\n\x1a\n`).
    ///
    /// CRC32 verification is **enabled** by default. Call
    /// [`ZiPatchReader::skip_checksum_verification`] before iterating to
    /// disable it.
    ///
    /// # Errors
    ///
    /// - [`ZiPatchError::InvalidMagic`] — the first 12 bytes do not match the
    ///   expected magic.
    /// - [`ZiPatchError::Io`] — an I/O error occurred while reading the magic.
    pub fn new(mut reader: R) -> Result<Self> {
        let magic = reader.read_exact_vec(12)?;
        if magic.as_slice() != MAGIC {
            return Err(ZiPatchError::InvalidMagic);
        }
        Ok(Self {
            inner: reader,
            done: false,
            verify_checksums: true,
            eof_seen: false,
        })
    }

    /// Enable per-chunk CRC32 verification (the default).
    ///
    /// This is the default state after [`ZiPatchReader::new`]. Calling this
    /// method after construction is only necessary if
    /// [`ZiPatchReader::skip_checksum_verification`] was previously called.
    #[must_use]
    pub fn verify_checksums(mut self) -> Self {
        self.verify_checksums = true;
        self
    }

    /// Disable per-chunk CRC32 verification.
    ///
    /// Useful when the source has already been verified out-of-band (e.g. a
    /// download hash was checked before the file was opened), or when
    /// processing known-good test data where the overhead is unnecessary.
    #[must_use]
    pub fn skip_checksum_verification(mut self) -> Self {
        self.verify_checksums = false;
        self
    }

    /// Returns `true` if iteration reached the `EOF_` terminator cleanly.
    ///
    /// A `false` return after `next()` yields `None` indicates the stream was
    /// truncated — the download or file copy was incomplete. In that case the
    /// iterator stopped because of a [`ZiPatchError::TruncatedPatch`] error,
    /// not because the patch finished normally.
    pub fn is_complete(&self) -> bool {
        self.eof_seen
    }
}

impl ZiPatchReader<std::io::BufReader<std::fs::File>> {
    /// Open the file at `path`, wrap it in a [`std::io::BufReader`], and
    /// validate the `ZiPatch` magic.
    ///
    /// This is a convenience constructor equivalent to:
    ///
    /// ```rust,no_run
    /// # use std::io::BufReader;
    /// # use std::fs::File;
    /// # use zipatch_rs::ZiPatchReader;
    /// let reader = ZiPatchReader::new(BufReader::new(File::open("patch.patch").unwrap())).unwrap();
    /// ```
    ///
    /// # Errors
    ///
    /// - [`ZiPatchError::Io`] — the file could not be opened.
    /// - [`ZiPatchError::InvalidMagic`] — the file does not start with the
    ///   `ZiPatch` magic bytes.
    pub fn from_path(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
        let file = std::fs::File::open(path)?;
        Self::new(std::io::BufReader::new(file))
    }
}

impl<R: std::io::Read> Iterator for ZiPatchReader<R> {
    type Item = Result<Chunk>;

    fn next(&mut self) -> Option<Self::Item> {
        if self.done {
            return None;
        }
        match parse_chunk(&mut self.inner, self.verify_checksums) {
            Ok(Chunk::EndOfFile) => {
                self.done = true;
                self.eof_seen = true;
                None
            }
            Ok(chunk) => Some(Ok(chunk)),
            Err(e) => {
                self.done = true;
                Some(Err(e))
            }
        }
    }
}

impl<R: std::io::Read> std::iter::FusedIterator for ZiPatchReader<R> {}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    // Build a well-formed chunk: 4-byte BE size | 4-byte tag | body | 4-byte BE CRC32.
    fn build_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
        let size = body.len() as u32;
        let mut buf = Vec::with_capacity(8 + body.len() + 4);
        buf.extend_from_slice(&size.to_be_bytes());
        buf.extend_from_slice(tag);
        buf.extend_from_slice(body);
        let crc = {
            let mut crc_input = Vec::with_capacity(4 + body.len());
            crc_input.extend_from_slice(tag);
            crc_input.extend_from_slice(body);
            crc32fast::hash(&crc_input)
        };
        buf.extend_from_slice(&crc.to_be_bytes());
        buf
    }

    #[test]
    fn truncated_at_chunk_boundary_maps_to_truncated_patch() {
        // FHDR chunk: 4-byte version + 4-byte tag + 8-byte sizes — body is 24 bytes.
        // We just need any well-formed chunk that isn't EOF_.
        // Use ADIR with a minimal body: 4-byte path_len = 0 path "" — body is whatever
        // adir::parse accepts. Simplest: a custom tag isn't viable since unknown tags error.
        // Use APLY which has a u32 option_id and (typically) a u32 value — but to keep it
        // independent of inner parser quirks, we'll just verify TruncatedPatch is returned
        // when the reader is positioned exactly at a chunk boundary with no more bytes.
        let mut patch = Vec::new();
        patch.extend_from_slice(&MAGIC);
        // No chunks at all — first parse_chunk hits EOF on the 4-byte size read.
        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
        let first = reader.next().expect("iterator must yield once");
        match first {
            Err(ZiPatchError::TruncatedPatch) => {}
            other => panic!("expected TruncatedPatch, got {other:?}"),
        }
        assert!(!reader.is_complete());
    }

    #[test]
    fn truncated_after_one_chunk_maps_to_truncated_patch() {
        // Magic + one ADIR chunk (well-formed) + nothing else.
        // The first ADIR yields successfully, the next call must yield TruncatedPatch.
        // ADIR body is just a length-prefixed name (u32 BE name_len + name bytes).
        let mut adir_body = Vec::new();
        adir_body.extend_from_slice(&4u32.to_be_bytes()); // name_len
        adir_body.extend_from_slice(b"test"); // name
        let chunk = build_chunk(b"ADIR", &adir_body);

        let mut patch = Vec::new();
        patch.extend_from_slice(&MAGIC);
        patch.extend_from_slice(&chunk);

        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
        let first = reader.next().expect("first chunk");
        assert!(first.is_ok(), "first chunk should parse: {first:?}");
        let second = reader.next().expect("iterator yields TruncatedPatch");
        match second {
            Err(ZiPatchError::TruncatedPatch) => {}
            other => panic!("expected TruncatedPatch, got {other:?}"),
        }
        assert!(!reader.is_complete());
    }

    #[test]
    fn oversized_chunk_size_rejected() {
        // Craft a stream where the first 4 bytes read by parse_chunk encode u32::MAX.
        let bytes = [0xFFu8, 0xFF, 0xFF, 0xFF];
        let mut cur = Cursor::new(&bytes[..]);
        let Err(ZiPatchError::OversizedChunk(size)) = parse_chunk(&mut cur, false) else {
            panic!("expected OversizedChunk for oversized chunk")
        };
        assert!(
            size > MAX_CHUNK_SIZE,
            "expected size > MAX_CHUNK_SIZE, got {size}"
        );
    }

    #[test]
    fn from_path_opens_and_parses_patch_file() {
        // Write a minimal valid patch (MAGIC + EOF_) to a temp file, then use from_path.
        let mut bytes = Vec::new();
        bytes.extend_from_slice(&MAGIC);
        bytes.extend_from_slice(&build_chunk(b"EOF_", &[]));

        let tmp = tempfile::tempdir().unwrap();
        let file_path = tmp.path().join("test.patch");
        std::fs::write(&file_path, &bytes).unwrap();

        let mut reader = ZiPatchReader::from_path(&file_path).expect("from_path opens patch file");
        assert!(reader.next().is_none(), "EOF_ should terminate iteration");
        assert!(reader.is_complete());
    }

    #[test]
    fn from_path_returns_io_error_on_missing_file() {
        let tmp = tempfile::tempdir().unwrap();
        let file_path = tmp.path().join("nonexistent.patch");
        assert!(matches!(
            ZiPatchReader::from_path(&file_path),
            Err(ZiPatchError::Io(_))
        ));
    }
}