Skip to main content

zipatch_rs/
lib.rs

1//! Parser and applier for FFXIV `ZiPatch` (`.patch`) binary files.
2//!
3//! `zipatch-rs` decodes the binary patch format that Square Enix ships for
4//! Final Fantasy XIV and writes the decoded changes to a local game installation.
5//! The library never touches the network — it operates entirely on byte streams
6//! you supply.
7//!
8//! # Architecture
9//!
10//! The crate is split into three layers that share types but are otherwise
11//! independent:
12//!
13//! ## Layer 1 — I/O primitives (`reader`)
14//!
15//! `reader::ReadExt` is a crate-internal extension trait that adds typed
16//! big- and little-endian reads on top of [`std::io::Read`]. It is not part
17//! of the public API; the parsing layer uses it exclusively.
18//!
19//! ## Layer 2 — Parsing ([`chunk`])
20//!
21//! [`ZiPatchReader`] is an [`Iterator`] over [`Chunk`] values. Construct it
22//! from any [`std::io::Read`] source (a [`std::fs::File`], a
23//! [`std::io::Cursor<Vec<u8>>`], a network stream, …). It validates the
24//! 12-byte file magic on construction, then yields one [`Chunk`] per
25//! [`Iterator::next`] call until it sees the `EOF_` terminator or hits an
26//! error.
27//!
28//! Nothing in the parsing layer allocates file handles, stats paths, or
29//! performs I/O against the install tree. Parse-only users can consume
30//! [`ZiPatchReader`] without ever importing [`apply`].
31//!
32//! ## Layer 3 — Applying ([`apply`])
33//!
34//! The [`Apply`] trait bridges parsing and application: every [`Chunk`]
35//! variant implements it, and each implementation writes the patch change to
36//! disk via an [`ApplyContext`]. [`ApplyContext`] holds the install root, the
37//! target [`Platform`], behavioural flags, and an internal file-handle cache
38//! that avoids re-opening the same `.dat` file for every chunk.
39//!
40//! # Quick start
41//!
42//! The most common usage: open a patch file, build a context, apply every
43//! chunk in stream order.
44//!
45//! ```no_run
46//! use std::fs::File;
47//! use zipatch_rs::{ApplyContext, ZiPatchReader};
48//!
49//! let patch_file = File::open("H2017.07.11.0000.0000a.patch").unwrap();
50//! let mut ctx = ApplyContext::new("/opt/ffxiv/game");
51//!
52//! ZiPatchReader::new(patch_file)
53//!     .unwrap()
54//!     .apply_to(&mut ctx)
55//!     .unwrap();
56//! ```
57//!
58//! # Inspecting a patch without applying it
59//!
60//! Iterate the reader directly to inspect chunks without touching the
61//! filesystem:
62//!
63//! ```no_run
64//! use zipatch_rs::{Chunk, ZiPatchReader};
65//! use std::fs::File;
66//!
67//! let reader = ZiPatchReader::new(File::open("patch.patch").unwrap()).unwrap();
68//! for chunk in reader {
69//!     match chunk.unwrap() {
70//!         Chunk::FileHeader(h) => println!("patch version: {:?}", h),
71//!         Chunk::AddDirectory(d) => println!("mkdir {}", d.name),
72//!         Chunk::Sqpk(cmd) => println!("sqpk: {cmd:?}"),
73//!         _ => {}
74//!     }
75//! }
76//! ```
77//!
78//! # In-memory doctest
79//!
80//! The following example builds a minimal well-formed patch in memory — magic
81//! header, one `ADIR` chunk (which creates a directory), and an `EOF_`
82//! terminator — then applies it to a temporary directory. This mirrors the
83//! technique used in the crate's own unit tests.
84//!
85//! ```rust
86//! use std::io::Cursor;
87//! use zipatch_rs::{ApplyContext, Chunk, ZiPatchReader};
88//!
89//! // ZiPatch file magic: \x91ZIPATCH\r\n\x1a\n
90//! const MAGIC: [u8; 12] = [
91//!     0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48,
92//!     0x0D, 0x0A, 0x1A, 0x0A,
93//! ];
94//!
95//! /// Wrap `tag + body` into a length-prefixed, CRC32-verified chunk frame.
96//! fn make_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
97//!     // CRC is computed over tag ++ body (NOT including the leading body_len).
98//!     let mut crc_input = Vec::new();
99//!     crc_input.extend_from_slice(tag);
100//!     crc_input.extend_from_slice(body);
101//!     let crc = crc32fast::hash(&crc_input);
102//!
103//!     let mut out = Vec::new();
104//!     out.extend_from_slice(&(body.len() as u32).to_be_bytes()); // body_len: u32 BE
105//!     out.extend_from_slice(tag);                                // tag: 4 bytes
106//!     out.extend_from_slice(body);                               // body: body_len bytes
107//!     out.extend_from_slice(&crc.to_be_bytes());                 // crc32: u32 BE
108//!     out
109//! }
110//!
111//! // ADIR body: big-endian u32 name length followed by the name bytes.
112//! let mut adir_body = Vec::new();
113//! adir_body.extend_from_slice(&7u32.to_be_bytes()); // name_len
114//! adir_body.extend_from_slice(b"created");          // name
115//!
116//! // Assemble the full patch stream.
117//! let mut patch = Vec::new();
118//! patch.extend_from_slice(&MAGIC);
119//! patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
120//! patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
121//!
122//! // Apply to a temporary directory.
123//! let tmp = tempfile::tempdir().unwrap();
124//! let mut ctx = ApplyContext::new(tmp.path());
125//! ZiPatchReader::new(Cursor::new(patch))
126//!     .unwrap()
127//!     .apply_to(&mut ctx)
128//!     .unwrap();
129//!
130//! assert!(tmp.path().join("created").is_dir());
131//! ```
132//!
133//! # Error handling
134//!
135//! Every fallible operation returns [`Result<T>`], which is an alias for
136//! `std::result::Result<T, `[`ZiPatchError`]`>`. Parse errors and apply
137//! errors share the same type so callers need only one error arm.
138//!
139//! # Progress and cancellation
140//!
141//! [`ApplyContext::with_observer`] installs an [`ApplyObserver`] that is
142//! called after each chunk applies (with the chunk index, tag, and running
143//! byte count from [`ZiPatchReader::bytes_read`]) and polled inside long-
144//! running chunks for cancellation. Returning
145//! [`std::ops::ControlFlow::Break`] from a per-chunk callback, or `true`
146//! from [`ApplyObserver::should_cancel`], aborts the apply call with
147//! [`ZiPatchError::Cancelled`]. Parsing-only consumers and existing
148//! [`apply_to`](ZiPatchReader::apply_to) callers that never install an
149//! observer pay nothing — the default is a no-op.
150//!
151//! # Tracing
152//!
153//! The library emits structured [`tracing`] events and spans across the
154//! parse, plan-build, apply, and verify entry points. Levels follow a
155//! "one event per logical operation at `info!`, per-target/per-fs-op at
156//! `debug!`, per-region/byte-level work at `trace!`" cadence. The top-level
157//! spans (`apply_patch`, `apply_plan`, `build_plan_patch`, `compute_crc32`,
158//! `verify_plan`) are emitted at the `info` level so a subscriber configured
159//! at the default level can scope output via span filtering, while per-target
160//! sub-spans (`apply_target`) emit at `debug`. Recoverable anomalies — stale
161//! manifest entries, unknown platform IDs, missing-but-ignored files — fire
162//! `warn!`; errors are returned via [`ZiPatchError`] rather than logged. No
163//! subscriber is configured here — that is the consumer's responsibility.
164//!
165//! [`tracing`]: https://docs.rs/tracing
166
167#![deny(missing_docs)]
168
169/// Filesystem application of parsed chunks ([`Apply`], [`ApplyContext`]).
170pub mod apply;
171/// Wire-format chunk types and the [`ZiPatchReader`] iterator.
172pub mod chunk;
173/// Error type returned by parsing and applying ([`ZiPatchError`]).
174pub mod error;
175/// Indexed-apply plan model and single-patch builder
176/// ([`Plan`], [`PlanBuilder`]).
177pub mod index;
178pub(crate) mod reader;
179
180/// Shared chunk-framing fixtures for unit and integration tests.
181///
182/// Exposed under `#[cfg(test)]` to all tests in this crate, and behind the
183/// `test-utils` feature flag to downstream consumers. **Not part of the
184/// stable public API** — see the module rustdoc for details.
185#[cfg(any(test, feature = "test-utils"))]
186pub mod test_utils;
187
188/// Fuzz-only re-exports of crate-internal primitives.
189///
190/// `cfg(fuzzing)` is set automatically by cargo-fuzz when compiling a fuzz
191/// target — it is never set in normal `cargo build` / `cargo test` / CI builds.
192/// Nothing exported from this module is part of the public API.
193#[cfg(fuzzing)]
194#[doc(hidden)]
195pub mod fuzz_internal {
196    pub use crate::reader::ReadExt;
197}
198
199pub use apply::{Apply, ApplyContext, ApplyObserver, ChunkEvent, NoopObserver};
200pub use chunk::{Chunk, ZiPatchReader};
201pub use error::ZiPatchError;
202pub use index::{IndexApplier, Plan, PlanBuilder, Verifier};
203
204#[cfg(any(test, feature = "test-utils"))]
205pub use index::MemoryPatchSource;
206
207/// Crate-wide `Result` alias parameterised over [`ZiPatchError`].
208pub type Result<T> = std::result::Result<T, ZiPatchError>;
209
210impl<R: std::io::Read> chunk::ZiPatchReader<R> {
211    /// Iterate every chunk in the patch stream and apply each one to `ctx`.
212    ///
213    /// This is the primary high-level entry point for applying a patch. It
214    /// drives the [`ZiPatchReader`] iterator to completion, calling
215    /// [`Apply::apply`] on each yielded [`Chunk`] in stream order.
216    ///
217    /// Chunks **must** be applied in order — the `ZiPatch` format is a
218    /// sequential log and later chunks may depend on filesystem state produced
219    /// by earlier ones (e.g. a directory created by an `ADIR` chunk that a
220    /// subsequent `SQPK AddFile` writes into).
221    ///
222    /// # Errors
223    ///
224    /// Stops at the first parse or apply error and returns it immediately.
225    /// Any filesystem changes already applied by earlier chunks are **not**
226    /// rolled back — the format does not provide transactional semantics.
227    ///
228    /// Possible error variants:
229    /// - [`ZiPatchError::Io`] — underlying I/O failure (read or write).
230    /// - [`ZiPatchError::InvalidMagic`] — caught at construction, not here.
231    /// - [`ZiPatchError::UnknownChunkTag`] — an unrecognised 4-byte tag was
232    ///   encountered.
233    /// - [`ZiPatchError::ChecksumMismatch`] — a chunk's CRC32 did not match.
234    /// - [`ZiPatchError::TruncatedPatch`] — the stream ended before `EOF_`.
235    /// - [`ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk carried a
236    ///   negative offset.
237    /// - [`ZiPatchError::Decompress`] — a compressed block could not be
238    ///   inflated.
239    /// - [`ZiPatchError::UnsupportedPlatform`] — a `SqpkTargetInfo` chunk
240    ///   declared a `platform_id` outside `0`/`1`/`2`, and a subsequent SQPK
241    ///   data chunk requested `SqPack` `.dat`/`.index` path resolution.
242    /// - [`ZiPatchError::Cancelled`] — an installed
243    ///   [`ApplyObserver`] requested cancellation.
244    ///
245    /// # Panics
246    ///
247    /// Never panics under normal operation. The internal
248    /// [`ZiPatchReader::last_tag`] is unwrapped after a successful
249    /// [`Iterator::next`] — this is an internal invariant of the iterator
250    /// (every `Some(Ok(_))` updates the tag) and would only fail on a bug
251    /// in this crate.
252    ///
253    /// # Example
254    ///
255    /// ```no_run
256    /// use std::fs::File;
257    /// use zipatch_rs::{ApplyContext, ZiPatchReader};
258    ///
259    /// let mut ctx = ApplyContext::new("/opt/ffxiv/game");
260    /// ZiPatchReader::new(File::open("update.patch").unwrap())
261    ///     .unwrap()
262    ///     .apply_to(&mut ctx)
263    ///     .unwrap();
264    /// ```
265    pub fn apply_to(mut self, ctx: &mut apply::ApplyContext) -> Result<()> {
266        let span = tracing::info_span!("apply_patch");
267        let _enter = span.enter();
268        let started = std::time::Instant::now();
269        // Run the chunk loop in an IIFE so the outer function can flush the
270        // file-handle cache on the way out — both on success (to make the
271        // durability guarantee meaningful: returning `Ok` implies the writes
272        // reached the OS) and on error (so partial progress, e.g. mid-stream
273        // cancellation, is observable in the filesystem). A flush failure
274        // only escapes when there was no primary error to begin with;
275        // otherwise the primary error takes precedence.
276        let mut chunks_applied: usize = 0;
277        let result: Result<()> = (|| {
278            use apply::Apply;
279            use std::ops::ControlFlow;
280            let mut index: usize = 0;
281            // Hand-rolled loop (instead of `for chunk in self`) so we can read
282            // `self.bytes_read()` and `self.last_tag()` after each successful
283            // `next()` without giving up ownership of the iterator.
284            while let Some(chunk) = self.next() {
285                let chunk = chunk?;
286                chunk.apply(ctx)?;
287                // Snapshot the byte counter and tag *after* the apply completes —
288                // `bytes_read` is monotonic relative to the patch stream, not the
289                // apply progress, but for byte-driven UI progress that is exactly
290                // what we want: the consumer sees how far through the patch file
291                // they are once a chunk's effects have landed on disk.
292                let bytes_read = self.bytes_read();
293                let tag = self
294                    .last_tag()
295                    .expect("last_tag is set whenever next() yielded Some(Ok(_))");
296                let event = apply::ChunkEvent {
297                    index,
298                    kind: tag,
299                    bytes_read,
300                };
301                if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
302                    return Err(ZiPatchError::Cancelled);
303                }
304                index += 1;
305            }
306            chunks_applied = index;
307            Ok(())
308        })();
309        let flush_result = ctx.flush();
310        let final_result = match flush_result {
311            Err(e) if result.is_ok() => Err(ZiPatchError::Io(e)),
312            _ => result,
313        };
314        if final_result.is_ok() {
315            tracing::info!(
316                chunks = chunks_applied,
317                bytes_read = self.bytes_read(),
318                elapsed_ms = started.elapsed().as_millis() as u64,
319                "apply_to: patch applied"
320            );
321        }
322        final_result
323    }
324}
325
326/// Target platform for `SqPack` file path resolution.
327///
328/// FFXIV's `SqPack` archive files live in platform-specific subdirectories
329/// under the game install root. For example, a data file for the Windows
330/// client lives at `sqpack/ffxiv/000000.win32.dat0`, while the PS4 equivalent
331/// is `sqpack/ffxiv/000000.ps4.dat0`. The [`Platform`] value stored in an
332/// [`ApplyContext`] selects which suffix is used when resolving chunk targets
333/// to filesystem paths.
334///
335/// # Default
336///
337/// An [`ApplyContext`] defaults to [`Platform::Win32`]. Override this at
338/// construction time with [`ApplyContext::with_platform`].
339///
340/// # Runtime override via `SqpkTargetInfo`
341///
342/// In practice, real FFXIV patch files begin with an `SQPK T` chunk
343/// ([`chunk::SqpkTargetInfo`]) that declares the target platform. When
344/// [`Apply::apply`] is called on that chunk (see `src/apply/sqpk.rs`,
345/// `apply_target_info`), it overwrites [`ApplyContext::platform`] with the
346/// decoded [`Platform`] value. This means the default is only relevant for
347/// synthetic patches or when you know the target in advance and want to assert
348/// it before the stream starts.
349///
350/// # Forward compatibility
351///
352/// The enum is `#[non_exhaustive]`. The [`Platform::Unknown`] variant
353/// preserves unrecognised platform IDs so that newer patch files do not fail
354/// parsing when a new platform is introduced. Path resolution for `SqPack`
355/// `.dat`/`.index` files refuses to guess and returns
356/// [`ZiPatchError::UnsupportedPlatform`] carrying the raw `platform_id` —
357/// silently substituting a default layout would risk writing platform-specific
358/// data to the wrong file.
359///
360/// # Display
361///
362/// Implements [`std::fmt::Display`]: `"Win32"`, `"PS3"`, `"PS4"`, or
363/// `"Unknown(N)"` where `N` is the raw platform ID.
364///
365/// # Example
366///
367/// ```rust
368/// use zipatch_rs::{ApplyContext, Platform};
369///
370/// let ctx = ApplyContext::new("/opt/ffxiv/game")
371///     .with_platform(Platform::Win32);
372///
373/// assert_eq!(ctx.platform(), Platform::Win32);
374/// assert_eq!(format!("{}", Platform::Unknown(99)), "Unknown(99)");
375/// ```
376///
377/// [`chunk::SqpkTargetInfo`]: crate::chunk::SqpkTargetInfo
378#[non_exhaustive]
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
381pub enum Platform {
382    /// Windows / PC client (`win32` path suffix).
383    ///
384    /// This is the platform used by all current PC releases of FFXIV and is
385    /// the default for [`ApplyContext`].
386    Win32,
387    /// `PlayStation` 3 client (`ps3` path suffix).
388    ///
389    /// PS3 support was discontinued after FFXIV: A Realm Reborn. Patches
390    /// targeting this platform are no longer issued by Square Enix, but the
391    /// variant is retained for completeness.
392    Ps3,
393    /// `PlayStation` 4 client (`ps4` path suffix).
394    ///
395    /// Active platform alongside Windows. PS4 patches share the same chunk
396    /// structure as Windows patches but target different file paths.
397    Ps4,
398    /// Unrecognised platform ID preserved from a `SqpkTargetInfo` chunk.
399    ///
400    /// When `apply_target_info` in `src/apply/sqpk.rs` encounters a
401    /// `platform_id` it does not recognise, it stores the raw `u16` value
402    /// here and emits a `warn!` tracing event. Subsequent `SqPack` path
403    /// resolution returns [`ZiPatchError::UnsupportedPlatform`] carrying
404    /// the same `u16` rather than silently routing writes to a default
405    /// layout — quietly substituting `win32` paths for an unknown platform
406    /// would corrupt the on-disk install with platform-specific data
407    /// written to the wrong files. Non-SqPack chunks (e.g. `ADIR`, `DELD`,
408    /// or `SqpkFile` operations resolved via `generic_path`) continue to
409    /// apply, so an unknown platform only aborts at the first `.dat` or
410    /// `.index` lookup.
411    Unknown(u16),
412}
413
414impl std::fmt::Display for Platform {
415    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416        match self {
417            Platform::Win32 => f.write_str("Win32"),
418            Platform::Ps3 => f.write_str("PS3"),
419            Platform::Ps4 => f.write_str("PS4"),
420            Platform::Unknown(id) => write!(f, "Unknown({id})"),
421        }
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::test_utils::{MAGIC, make_chunk};
429    use std::io::Cursor;
430    use std::ops::ControlFlow;
431    use std::sync::Arc;
432    use std::sync::atomic::{AtomicUsize, Ordering};
433
434    /// One uncompressed block carrying 8 bytes of payload, framed as a
435    /// `SqpkCompressedBlock` would appear inside an `SqpkFile` `AddFile` body.
436    ///
437    /// Block layout: 16-byte header + 8 data bytes + 104 alignment-pad bytes
438    /// (rounded up to the 128-byte boundary via `(8 + 143) & !127 = 128`).
439    fn make_sqpk_file_block(byte: u8) -> Vec<u8> {
440        let mut out = Vec::new();
441        out.extend_from_slice(&16i32.to_le_bytes()); // header_size
442        out.extend_from_slice(&0u32.to_le_bytes()); // pad
443        out.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
444        out.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
445        out.extend_from_slice(&[byte; 8]); // data
446        out.extend_from_slice(&[0u8; 104]); // 128-byte alignment padding
447        out
448    }
449
450    /// Build an SQPK `F`(`AddFile`) chunk that targets `path` and contains
451    /// `block_count` uncompressed blocks of 8 bytes each.
452    fn make_sqpk_addfile_chunk(path: &str, block_count: usize) -> Vec<u8> {
453        // SQPK `F` command body layout — see `chunk/sqpk/file.rs` docs.
454        let path_bytes: Vec<u8> = {
455            let mut p = path.as_bytes().to_vec();
456            p.push(0); // NUL terminator
457            p
458        };
459
460        let mut cmd_body = Vec::new();
461        cmd_body.push(b'A'); // operation = AddFile
462        cmd_body.extend_from_slice(&[0u8; 2]); // alignment
463        cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_offset = 0
464        cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_size
465        cmd_body.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
466        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // expansion_id
467        cmd_body.extend_from_slice(&[0u8; 2]); // padding
468        cmd_body.extend_from_slice(&path_bytes);
469        for i in 0..block_count {
470            cmd_body.extend_from_slice(&make_sqpk_file_block(0xA0 + (i as u8)));
471        }
472
473        // SQPK chunk body: i32 BE inner_size + 'F' command byte + cmd_body
474        let inner_size = 5 + cmd_body.len();
475        let mut sqpk_body = Vec::new();
476        sqpk_body.extend_from_slice(&(inner_size as i32).to_be_bytes());
477        sqpk_body.push(b'F');
478        sqpk_body.extend_from_slice(&cmd_body);
479
480        make_chunk(b"SQPK", &sqpk_body)
481    }
482
483    // --- Platform Display ---
484
485    #[test]
486    fn platform_display_all_variants() {
487        assert_eq!(format!("{}", Platform::Win32), "Win32");
488        assert_eq!(format!("{}", Platform::Ps3), "PS3");
489        assert_eq!(format!("{}", Platform::Ps4), "PS4");
490        assert_eq!(format!("{}", Platform::Unknown(42)), "Unknown(42)");
491        // Zero unknown ID is distinct from Win32.
492        assert_eq!(format!("{}", Platform::Unknown(0)), "Unknown(0)");
493    }
494
495    // --- apply_to: basic end-to-end ---
496
497    #[test]
498    fn apply_to_applies_adir_chunk_to_filesystem() {
499        // Verify that a well-formed ADIR + EOF_ patch creates the directory.
500        let mut adir_body = Vec::new();
501        adir_body.extend_from_slice(&7u32.to_be_bytes());
502        adir_body.extend_from_slice(b"created");
503
504        let mut patch = Vec::new();
505        patch.extend_from_slice(&MAGIC);
506        patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
507        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
508
509        let tmp = tempfile::tempdir().unwrap();
510        let mut ctx = ApplyContext::new(tmp.path());
511        ZiPatchReader::new(Cursor::new(patch))
512            .unwrap()
513            .apply_to(&mut ctx)
514            .unwrap();
515
516        assert!(
517            tmp.path().join("created").is_dir(),
518            "ADIR must have created the directory"
519        );
520    }
521
522    #[test]
523    fn apply_to_empty_patch_succeeds_without_side_effects() {
524        // MAGIC + EOF_ only: apply_to must return Ok(()) with no filesystem changes.
525        let mut patch = Vec::new();
526        patch.extend_from_slice(&MAGIC);
527        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
528
529        let tmp = tempfile::tempdir().unwrap();
530        let mut ctx = ApplyContext::new(tmp.path());
531        ZiPatchReader::new(Cursor::new(patch))
532            .unwrap()
533            .apply_to(&mut ctx)
534            .unwrap();
535        // No new entries should appear in the temp dir.
536        let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
537        assert!(
538            entries.is_empty(),
539            "empty patch must not create any files/dirs"
540        );
541    }
542
543    // --- apply_to: error propagation ---
544
545    #[test]
546    fn apply_to_propagates_parse_error_as_unknown_chunk_tag() {
547        // ZZZZ is not a known tag; apply_to must surface UnknownChunkTag.
548        let mut patch = Vec::new();
549        patch.extend_from_slice(&MAGIC);
550        patch.extend_from_slice(&make_chunk(b"ZZZZ", &[]));
551
552        let tmp = tempfile::tempdir().unwrap();
553        let mut ctx = ApplyContext::new(tmp.path());
554        let err = ZiPatchReader::new(Cursor::new(patch))
555            .unwrap()
556            .apply_to(&mut ctx)
557            .unwrap_err();
558        assert!(
559            matches!(err, ZiPatchError::UnknownChunkTag(_)),
560            "expected UnknownChunkTag, got {err:?}"
561        );
562    }
563
564    #[test]
565    fn apply_to_propagates_apply_error_from_delete_directory() {
566        // DELD on a non-existent directory without ignore_missing must return Io.
567        let mut deld_body = Vec::new();
568        deld_body.extend_from_slice(&14u32.to_be_bytes());
569        deld_body.extend_from_slice(b"does_not_exist");
570
571        let mut patch = Vec::new();
572        patch.extend_from_slice(&MAGIC);
573        patch.extend_from_slice(&make_chunk(b"DELD", &deld_body));
574        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
575
576        let tmp = tempfile::tempdir().unwrap();
577        let mut ctx = ApplyContext::new(tmp.path());
578        let err = ZiPatchReader::new(Cursor::new(patch))
579            .unwrap()
580            .apply_to(&mut ctx)
581            .unwrap_err();
582        assert!(
583            matches!(err, ZiPatchError::Io(_)),
584            "expected ZiPatchError::Io for missing dir without ignore_missing, got {err:?}"
585        );
586    }
587
588    // --- Progress / observer / cancellation tests ---
589
590    /// Observer that returns `should_cancel() == true` after `cancel_after` calls.
591    struct CancelAfter {
592        calls: usize,
593        cancel_after: usize,
594    }
595
596    impl ApplyObserver for CancelAfter {
597        fn should_cancel(&mut self) -> bool {
598            let now = self.calls;
599            self.calls += 1;
600            now >= self.cancel_after
601        }
602    }
603
604    #[test]
605    fn observer_fires_for_each_non_eof_chunk_with_correct_fields() {
606        // Two ADIR chunks — observer must receive exactly two events, in order,
607        // with 0-based index, correct tag, and a monotonically increasing
608        // bytes_read that matches the exact wire-frame sizes.
609        let log: Arc<std::sync::Mutex<Vec<ChunkEvent>>> =
610            Arc::new(std::sync::Mutex::new(Vec::new()));
611        let log_clone = log.clone();
612
613        let mut a = Vec::new();
614        a.extend_from_slice(&1u32.to_be_bytes());
615        a.extend_from_slice(b"a");
616        let mut b = Vec::new();
617        b.extend_from_slice(&1u32.to_be_bytes());
618        b.extend_from_slice(b"b");
619
620        let mut patch = Vec::new();
621        patch.extend_from_slice(&MAGIC);
622        patch.extend_from_slice(&make_chunk(b"ADIR", &a));
623        patch.extend_from_slice(&make_chunk(b"ADIR", &b));
624        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
625
626        let tmp = tempfile::tempdir().unwrap();
627        let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |ev| {
628            log_clone.lock().unwrap().push(ev);
629            ControlFlow::Continue(())
630        });
631        ZiPatchReader::new(Cursor::new(patch))
632            .unwrap()
633            .apply_to(&mut ctx)
634            .unwrap();
635
636        let events = log.lock().unwrap();
637        assert_eq!(
638            events.len(),
639            2,
640            "two non-EOF chunks must fire exactly two events"
641        );
642        // Index must be 0-based and monotonically increasing.
643        assert_eq!(events[0].index, 0, "first event index must be 0");
644        assert_eq!(events[1].index, 1, "second event index must be 1");
645        // Tag must reflect the chunk wire tag.
646        assert_eq!(events[0].kind, *b"ADIR");
647        assert_eq!(events[1].kind, *b"ADIR");
648        // ADIR body for name "a": 4 (name_len) + 1 (byte) = 5
649        // Frame: 4(size) + 4(tag) + 5(body) + 4(crc) = 17
650        assert_eq!(
651            events[0].bytes_read,
652            12 + 17,
653            "bytes_read after first ADIR must be magic + one 17-byte frame"
654        );
655        assert_eq!(
656            events[1].bytes_read,
657            12 + 17 + 17,
658            "bytes_read after second ADIR must be magic + two 17-byte frames"
659        );
660        // Strict monotonicity.
661        assert!(
662            events[0].bytes_read < events[1].bytes_read,
663            "bytes_read must strictly increase between events"
664        );
665    }
666
667    #[test]
668    fn observer_break_on_first_chunk_aborts_immediately_leaving_first_applied() {
669        // Observer that always breaks: only the first chunk's apply runs, then
670        // apply_to returns Cancelled. Second and third chunks are never reached.
671        let mut a = Vec::new();
672        a.extend_from_slice(&1u32.to_be_bytes());
673        a.extend_from_slice(b"a");
674        let mut b_body = Vec::new();
675        b_body.extend_from_slice(&1u32.to_be_bytes());
676        b_body.extend_from_slice(b"b");
677        let mut c = Vec::new();
678        c.extend_from_slice(&1u32.to_be_bytes());
679        c.extend_from_slice(b"c");
680
681        let mut patch = Vec::new();
682        patch.extend_from_slice(&MAGIC);
683        patch.extend_from_slice(&make_chunk(b"ADIR", &a));
684        patch.extend_from_slice(&make_chunk(b"ADIR", &b_body));
685        patch.extend_from_slice(&make_chunk(b"ADIR", &c));
686        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
687
688        let count = Arc::new(AtomicUsize::new(0));
689        let count_clone = count.clone();
690
691        let tmp = tempfile::tempdir().unwrap();
692        let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |_| {
693            count_clone.fetch_add(1, Ordering::Relaxed);
694            ControlFlow::Break(())
695        });
696        let err = ZiPatchReader::new(Cursor::new(patch))
697            .unwrap()
698            .apply_to(&mut ctx)
699            .unwrap_err();
700
701        assert!(
702            matches!(err, ZiPatchError::Cancelled),
703            "observer Break must produce ZiPatchError::Cancelled, got {err:?}"
704        );
705        assert_eq!(
706            count.load(Ordering::Relaxed),
707            1,
708            "exactly one on_chunk_applied call fires before the abort takes effect"
709        );
710        // The first ADIR's apply completed before the event fired.
711        assert!(
712            tmp.path().join("a").is_dir(),
713            "first ADIR must have been applied before Cancelled was returned"
714        );
715        // Second and third ADIRs were never reached.
716        assert!(
717            !tmp.path().join("b").exists(),
718            "second ADIR must NOT have been applied after Cancelled"
719        );
720        assert!(
721            !tmp.path().join("c").exists(),
722            "third ADIR must NOT have been applied after Cancelled"
723        );
724    }
725
726    #[test]
727    fn observer_break_on_last_chunk_before_eof_leaves_all_earlier_applied() {
728        // Three ADIRs: observer continues for the first two, breaks on the third.
729        // After Cancelled, a/ and b/ must exist; c/ was the breaker's chunk
730        // (its apply ran before the event fired) and d/ (hypothetical fourth) never runs.
731        let make_adir_chunk = |name: &[u8]| -> Vec<u8> {
732            let mut body = Vec::new();
733            body.extend_from_slice(&(name.len() as u32).to_be_bytes());
734            body.extend_from_slice(name);
735            make_chunk(b"ADIR", &body)
736        };
737
738        let mut patch = Vec::new();
739        patch.extend_from_slice(&MAGIC);
740        patch.extend_from_slice(&make_adir_chunk(b"a"));
741        patch.extend_from_slice(&make_adir_chunk(b"b"));
742        patch.extend_from_slice(&make_adir_chunk(b"c"));
743        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
744
745        let call_count = Arc::new(AtomicUsize::new(0));
746        let cc = call_count.clone();
747        let tmp = tempfile::tempdir().unwrap();
748        let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |_| {
749            let n = cc.fetch_add(1, Ordering::Relaxed) + 1;
750            if n >= 3 {
751                ControlFlow::Break(())
752            } else {
753                ControlFlow::Continue(())
754            }
755        });
756
757        let err = ZiPatchReader::new(Cursor::new(patch))
758            .unwrap()
759            .apply_to(&mut ctx)
760            .unwrap_err();
761
762        assert!(
763            matches!(err, ZiPatchError::Cancelled),
764            "expected Cancelled, got {err:?}"
765        );
766        // First two ADIRs fully applied.
767        assert!(tmp.path().join("a").is_dir(), "a/ must exist");
768        assert!(tmp.path().join("b").is_dir(), "b/ must exist");
769        // Third ADIR's apply ran before the event — c/ exists.
770        assert!(
771            tmp.path().join("c").is_dir(),
772            "c/ must exist (apply ran before event fired)"
773        );
774    }
775
776    #[test]
777    fn sqpk_file_cancellation_mid_block_loop_returns_aborted() {
778        // Three blocks of 8 bytes each. Observer cancels after 2 should_cancel
779        // polls, so at most 2 blocks are written before abort.
780        let chunk = make_sqpk_addfile_chunk("created/test.dat", 3);
781
782        let mut patch = Vec::new();
783        patch.extend_from_slice(&MAGIC);
784        patch.extend_from_slice(&chunk);
785        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
786
787        let tmp = tempfile::tempdir().unwrap();
788        let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
789            calls: 0,
790            cancel_after: 2,
791        });
792
793        let err = ZiPatchReader::new(Cursor::new(patch))
794            .unwrap()
795            .apply_to(&mut ctx)
796            .unwrap_err();
797
798        assert!(
799            matches!(err, ZiPatchError::Cancelled),
800            "mid-block cancellation must return Cancelled, got {err:?}"
801        );
802
803        // File exists (create=true opened it) but the third block must not have
804        // been written.  With `cancel_after = 2`, `should_cancel` returns true
805        // on the third poll (the one that gates block 3), so exactly the first
806        // two 8-byte blocks (= 16 bytes) reach disk.  Pin this exactly so an
807        // off-by-one in where `should_cancel` is polled inside the block loop
808        // would surface as a failing test rather than passing by inequality.
809        let target = tmp.path().join("created").join("test.dat");
810        assert!(
811            target.is_file(),
812            "target file must exist (was created before cancel)"
813        );
814        let len = std::fs::metadata(&target).unwrap().len();
815        assert_eq!(
816            len, 16,
817            "partial write: exactly 2 of 3 blocks (= 16 bytes) must have \
818             been written before cancellation"
819        );
820    }
821
822    #[test]
823    fn sqpk_file_single_block_no_mid_loop_cancel_opportunity() {
824        // A single-block AddFile provides no between-block cancellation
825        // opportunity. An observer that cancels only on the second call to
826        // should_cancel must NOT abort — the loop executes exactly one block
827        // and then the chunk completes normally. The chunk-boundary event fires
828        // next, and a Continue there lets apply_to succeed.
829        let chunk = make_sqpk_addfile_chunk("created/single.dat", 1);
830
831        let mut patch = Vec::new();
832        patch.extend_from_slice(&MAGIC);
833        patch.extend_from_slice(&chunk);
834        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
835
836        let tmp = tempfile::tempdir().unwrap();
837        let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
838            calls: 0,
839            cancel_after: 2, // never reaches 2nd call within a single block
840        });
841
842        // should succeed: only 1 should_cancel call (call 0 < 2 = cancel_after)
843        ZiPatchReader::new(Cursor::new(patch))
844            .unwrap()
845            .apply_to(&mut ctx)
846            .unwrap();
847
848        let target = tmp.path().join("created").join("single.dat");
849        assert!(
850            target.is_file(),
851            "single-block AddFile must complete and create the file"
852        );
853        assert_eq!(
854            std::fs::metadata(&target).unwrap().len(),
855            8,
856            "single block of 8 bytes must be fully written"
857        );
858    }
859
860    #[test]
861    fn sqpk_file_cancel_on_very_first_block_writes_zero_blocks() {
862        // Observer cancels immediately (cancel_after = 0).  The first
863        // should_cancel poll inside the block loop fires before any block data
864        // is written, so the file must be empty (truncated by set_len(0) but
865        // no block data written).
866        let chunk = make_sqpk_addfile_chunk("created/zero.dat", 3);
867
868        let mut patch = Vec::new();
869        patch.extend_from_slice(&MAGIC);
870        patch.extend_from_slice(&chunk);
871        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
872
873        let tmp = tempfile::tempdir().unwrap();
874        let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
875            calls: 0,
876            cancel_after: 0, // cancel on very first check
877        });
878
879        let err = ZiPatchReader::new(Cursor::new(patch))
880            .unwrap()
881            .apply_to(&mut ctx)
882            .unwrap_err();
883
884        assert!(
885            matches!(err, ZiPatchError::Cancelled),
886            "immediate cancel must return Cancelled, got {err:?}"
887        );
888
889        let target = tmp.path().join("created").join("zero.dat");
890        let len = std::fs::metadata(&target).unwrap().len();
891        assert_eq!(
892            len, 0,
893            "cancel before first block: file must be empty, got {len} bytes"
894        );
895    }
896
897    #[test]
898    fn closure_observer_composes_ergonomically_with_with_observer() {
899        // Verify the intended ergonomic usage path: a closure recording state,
900        // passed directly to with_observer via the blanket impl on FnMut.
901        let events = Arc::new(std::sync::Mutex::new(Vec::<(usize, [u8; 4])>::new()));
902        let ev_clone = events.clone();
903
904        let make_adir = |name: &[u8]| -> Vec<u8> {
905            let mut body = Vec::new();
906            body.extend_from_slice(&(name.len() as u32).to_be_bytes());
907            body.extend_from_slice(name);
908            make_chunk(b"ADIR", &body)
909        };
910
911        let mut patch = Vec::new();
912        patch.extend_from_slice(&MAGIC);
913        patch.extend_from_slice(&make_adir(b"d1"));
914        patch.extend_from_slice(&make_adir(b"d2"));
915        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
916
917        let tmp = tempfile::tempdir().unwrap();
918        let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |ev: ChunkEvent| {
919            ev_clone.lock().unwrap().push((ev.index, ev.kind));
920            ControlFlow::Continue(())
921        });
922
923        ZiPatchReader::new(Cursor::new(patch))
924            .unwrap()
925            .apply_to(&mut ctx)
926            .unwrap();
927
928        let recorded = events.lock().unwrap();
929        assert_eq!(recorded.len(), 2);
930        assert_eq!(recorded[0], (0, *b"ADIR"));
931        assert_eq!(recorded[1], (1, *b"ADIR"));
932    }
933
934    #[test]
935    fn default_no_observer_apply_succeeds_as_before() {
936        // Regression: without with_observer the apply must succeed exactly as
937        // it did before the observer API was introduced.
938        let mut adir_body = Vec::new();
939        adir_body.extend_from_slice(&7u32.to_be_bytes());
940        adir_body.extend_from_slice(b"created");
941
942        let mut patch = Vec::new();
943        patch.extend_from_slice(&MAGIC);
944        patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
945        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
946
947        let tmp = tempfile::tempdir().unwrap();
948        let mut ctx = ApplyContext::new(tmp.path()); // no with_observer call
949        ZiPatchReader::new(Cursor::new(patch))
950            .unwrap()
951            .apply_to(&mut ctx)
952            .unwrap();
953        assert!(
954            tmp.path().join("created").is_dir(),
955            "ADIR must be applied when no observer is set"
956        );
957    }
958}