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 at `trace!`, `debug!`, and
154//! `warn!` levels. No subscriber is configured here — configure output in your
155//! application binary (or in `gaveloc`'s launcher binary).
156//!
157//! [`tracing`]: https://docs.rs/tracing
158
159#![deny(missing_docs)]
160
161/// Filesystem application of parsed chunks ([`Apply`], [`ApplyContext`]).
162pub mod apply;
163/// Wire-format chunk types and the [`ZiPatchReader`] iterator.
164pub mod chunk;
165/// Error type returned by parsing and applying ([`ZiPatchError`]).
166pub mod error;
167pub(crate) mod reader;
168
169/// Shared chunk-framing fixtures for unit and integration tests.
170///
171/// Exposed under `#[cfg(test)]` to all tests in this crate, and behind the
172/// `test-utils` feature flag to downstream consumers. **Not part of the
173/// stable public API** — see the module rustdoc for details.
174#[cfg(any(test, feature = "test-utils"))]
175pub mod test_utils;
176
177pub use apply::{Apply, ApplyContext, ApplyObserver, ChunkEvent, NoopObserver};
178pub use chunk::{Chunk, ZiPatchReader};
179pub use error::ZiPatchError;
180
181/// Crate-wide `Result` alias parameterised over [`ZiPatchError`].
182pub type Result<T> = std::result::Result<T, ZiPatchError>;
183
184impl<R: std::io::Read> chunk::ZiPatchReader<R> {
185    /// Iterate every chunk in the patch stream and apply each one to `ctx`.
186    ///
187    /// This is the primary high-level entry point for applying a patch. It
188    /// drives the [`ZiPatchReader`] iterator to completion, calling
189    /// [`Apply::apply`] on each yielded [`Chunk`] in stream order.
190    ///
191    /// Chunks **must** be applied in order — the `ZiPatch` format is a
192    /// sequential log and later chunks may depend on filesystem state produced
193    /// by earlier ones (e.g. a directory created by an `ADIR` chunk that a
194    /// subsequent `SQPK AddFile` writes into).
195    ///
196    /// # Errors
197    ///
198    /// Stops at the first parse or apply error and returns it immediately.
199    /// Any filesystem changes already applied by earlier chunks are **not**
200    /// rolled back — the format does not provide transactional semantics.
201    ///
202    /// Possible error variants:
203    /// - [`ZiPatchError::Io`] — underlying I/O failure (read or write).
204    /// - [`ZiPatchError::InvalidMagic`] — caught at construction, not here.
205    /// - [`ZiPatchError::UnknownChunkTag`] — an unrecognised 4-byte tag was
206    ///   encountered.
207    /// - [`ZiPatchError::ChecksumMismatch`] — a chunk's CRC32 did not match.
208    /// - [`ZiPatchError::TruncatedPatch`] — the stream ended before `EOF_`.
209    /// - [`ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk carried a
210    ///   negative offset.
211    /// - [`ZiPatchError::Decompress`] — a compressed block could not be
212    ///   inflated.
213    /// - [`ZiPatchError::UnsupportedPlatform`] — a `SqpkTargetInfo` chunk
214    ///   declared a `platform_id` outside `0`/`1`/`2`, and a subsequent SQPK
215    ///   data chunk requested `SqPack` `.dat`/`.index` path resolution.
216    /// - [`ZiPatchError::Cancelled`] — an installed
217    ///   [`ApplyObserver`](crate::ApplyObserver) requested cancellation.
218    ///
219    /// # Panics
220    ///
221    /// Never panics under normal operation. The internal
222    /// [`ZiPatchReader::last_tag`] is unwrapped after a successful
223    /// [`Iterator::next`] — this is an internal invariant of the iterator
224    /// (every `Some(Ok(_))` updates the tag) and would only fail on a bug
225    /// in this crate.
226    ///
227    /// # Example
228    ///
229    /// ```no_run
230    /// use std::fs::File;
231    /// use zipatch_rs::{ApplyContext, ZiPatchReader};
232    ///
233    /// let mut ctx = ApplyContext::new("/opt/ffxiv/game");
234    /// ZiPatchReader::new(File::open("update.patch").unwrap())
235    ///     .unwrap()
236    ///     .apply_to(&mut ctx)
237    ///     .unwrap();
238    /// ```
239    pub fn apply_to(mut self, ctx: &mut apply::ApplyContext) -> Result<()> {
240        // Run the chunk loop in an IIFE so the outer function can flush the
241        // file-handle cache on the way out — both on success (to make the
242        // durability guarantee meaningful: returning `Ok` implies the writes
243        // reached the OS) and on error (so partial progress, e.g. mid-stream
244        // cancellation, is observable in the filesystem). A flush failure
245        // only escapes when there was no primary error to begin with;
246        // otherwise the primary error takes precedence.
247        let result: Result<()> = (|| {
248            use apply::Apply;
249            use std::ops::ControlFlow;
250            let mut index: usize = 0;
251            // Hand-rolled loop (instead of `for chunk in self`) so we can read
252            // `self.bytes_read()` and `self.last_tag()` after each successful
253            // `next()` without giving up ownership of the iterator.
254            while let Some(chunk) = self.next() {
255                let chunk = chunk?;
256                chunk.apply(ctx)?;
257                // Snapshot the byte counter and tag *after* the apply completes —
258                // `bytes_read` is monotonic relative to the patch stream, not the
259                // apply progress, but for byte-driven UI progress that is exactly
260                // what we want: the consumer sees how far through the patch file
261                // they are once a chunk's effects have landed on disk.
262                let bytes_read = self.bytes_read();
263                let tag = self
264                    .last_tag()
265                    .expect("last_tag is set whenever next() yielded Some(Ok(_))");
266                let event = apply::ChunkEvent {
267                    index,
268                    kind: tag,
269                    bytes_read,
270                };
271                if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
272                    return Err(ZiPatchError::Cancelled);
273                }
274                index += 1;
275            }
276            Ok(())
277        })();
278        match ctx.flush() {
279            Err(e) if result.is_ok() => Err(ZiPatchError::Io(e)),
280            _ => result,
281        }
282    }
283}
284
285/// Target platform for `SqPack` file path resolution.
286///
287/// FFXIV's `SqPack` archive files live in platform-specific subdirectories
288/// under the game install root. For example, a data file for the Windows
289/// client lives at `sqpack/ffxiv/000000.win32.dat0`, while the PS4 equivalent
290/// is `sqpack/ffxiv/000000.ps4.dat0`. The [`Platform`] value stored in an
291/// [`ApplyContext`] selects which suffix is used when resolving chunk targets
292/// to filesystem paths.
293///
294/// # Default
295///
296/// An [`ApplyContext`] defaults to [`Platform::Win32`]. Override this at
297/// construction time with [`ApplyContext::with_platform`].
298///
299/// # Runtime override via `SqpkTargetInfo`
300///
301/// In practice, real FFXIV patch files begin with an `SQPK T` chunk
302/// ([`chunk::SqpkTargetInfo`]) that declares the target platform. When
303/// [`Apply::apply`] is called on that chunk (see `src/apply/sqpk.rs`,
304/// `apply_target_info`), it overwrites [`ApplyContext::platform`] with the
305/// decoded [`Platform`] value. This means the default is only relevant for
306/// synthetic patches or when you know the target in advance and want to assert
307/// it before the stream starts.
308///
309/// # Forward compatibility
310///
311/// The enum is `#[non_exhaustive]`. The [`Platform::Unknown`] variant
312/// preserves unrecognised platform IDs so that newer patch files do not fail
313/// parsing when a new platform is introduced. Path resolution for `SqPack`
314/// `.dat`/`.index` files refuses to guess and returns
315/// [`ZiPatchError::UnsupportedPlatform`] carrying the raw `platform_id` —
316/// silently substituting a default layout would risk writing platform-specific
317/// data to the wrong file.
318///
319/// # Display
320///
321/// Implements [`std::fmt::Display`]: `"Win32"`, `"PS3"`, `"PS4"`, or
322/// `"Unknown(N)"` where `N` is the raw platform ID.
323///
324/// # Example
325///
326/// ```rust
327/// use zipatch_rs::{ApplyContext, Platform};
328///
329/// let ctx = ApplyContext::new("/opt/ffxiv/game")
330///     .with_platform(Platform::Win32);
331///
332/// assert_eq!(ctx.platform(), Platform::Win32);
333/// assert_eq!(format!("{}", Platform::Unknown(99)), "Unknown(99)");
334/// ```
335///
336/// [`chunk::SqpkTargetInfo`]: crate::chunk::SqpkTargetInfo
337#[non_exhaustive]
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum Platform {
340    /// Windows / PC client (`win32` path suffix).
341    ///
342    /// This is the platform used by all current PC releases of FFXIV and is
343    /// the default for [`ApplyContext`].
344    Win32,
345    /// `PlayStation` 3 client (`ps3` path suffix).
346    ///
347    /// PS3 support was discontinued after FFXIV: A Realm Reborn. Patches
348    /// targeting this platform are no longer issued by Square Enix, but the
349    /// variant is retained for completeness.
350    Ps3,
351    /// `PlayStation` 4 client (`ps4` path suffix).
352    ///
353    /// Active platform alongside Windows. PS4 patches share the same chunk
354    /// structure as Windows patches but target different file paths.
355    Ps4,
356    /// Unrecognised platform ID preserved from a `SqpkTargetInfo` chunk.
357    ///
358    /// When `apply_target_info` in `src/apply/sqpk.rs` encounters a
359    /// `platform_id` it does not recognise, it stores the raw `u16` value
360    /// here and emits a `warn!` tracing event. Subsequent `SqPack` path
361    /// resolution returns [`ZiPatchError::UnsupportedPlatform`] carrying
362    /// the same `u16` rather than silently routing writes to a default
363    /// layout — quietly substituting `win32` paths for an unknown platform
364    /// would corrupt the on-disk install with platform-specific data
365    /// written to the wrong files. Non-SqPack chunks (e.g. `ADIR`, `DELD`,
366    /// or `SqpkFile` operations resolved via `generic_path`) continue to
367    /// apply, so an unknown platform only aborts at the first `.dat` or
368    /// `.index` lookup.
369    Unknown(u16),
370}
371
372impl std::fmt::Display for Platform {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        match self {
375            Platform::Win32 => f.write_str("Win32"),
376            Platform::Ps3 => f.write_str("PS3"),
377            Platform::Ps4 => f.write_str("PS4"),
378            Platform::Unknown(id) => write!(f, "Unknown({id})"),
379        }
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::test_utils::{MAGIC, make_chunk};
387    use std::io::Cursor;
388    use std::ops::ControlFlow;
389    use std::sync::Arc;
390    use std::sync::atomic::{AtomicUsize, Ordering};
391
392    /// One uncompressed block carrying 8 bytes of payload, framed as a
393    /// `SqpkCompressedBlock` would appear inside an `SqpkFile` `AddFile` body.
394    ///
395    /// Block layout: 16-byte header + 8 data bytes + 104 alignment-pad bytes
396    /// (rounded up to the 128-byte boundary via `(8 + 143) & !127 = 128`).
397    fn make_sqpk_file_block(byte: u8) -> Vec<u8> {
398        let mut out = Vec::new();
399        out.extend_from_slice(&16i32.to_le_bytes()); // header_size
400        out.extend_from_slice(&0u32.to_le_bytes()); // pad
401        out.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
402        out.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
403        out.extend_from_slice(&[byte; 8]); // data
404        out.extend_from_slice(&[0u8; 104]); // 128-byte alignment padding
405        out
406    }
407
408    /// Build an SQPK `F`(`AddFile`) chunk that targets `path` and contains
409    /// `block_count` uncompressed blocks of 8 bytes each.
410    fn make_sqpk_addfile_chunk(path: &str, block_count: usize) -> Vec<u8> {
411        // SQPK `F` command body layout — see `chunk/sqpk/file.rs` docs.
412        let path_bytes: Vec<u8> = {
413            let mut p = path.as_bytes().to_vec();
414            p.push(0); // NUL terminator
415            p
416        };
417
418        let mut cmd_body = Vec::new();
419        cmd_body.push(b'A'); // operation = AddFile
420        cmd_body.extend_from_slice(&[0u8; 2]); // alignment
421        cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_offset = 0
422        cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_size
423        cmd_body.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
424        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // expansion_id
425        cmd_body.extend_from_slice(&[0u8; 2]); // padding
426        cmd_body.extend_from_slice(&path_bytes);
427        for i in 0..block_count {
428            cmd_body.extend_from_slice(&make_sqpk_file_block(0xA0 + (i as u8)));
429        }
430
431        // SQPK chunk body: i32 BE inner_size + 'F' command byte + cmd_body
432        let inner_size = 5 + cmd_body.len();
433        let mut sqpk_body = Vec::new();
434        sqpk_body.extend_from_slice(&(inner_size as i32).to_be_bytes());
435        sqpk_body.push(b'F');
436        sqpk_body.extend_from_slice(&cmd_body);
437
438        make_chunk(b"SQPK", &sqpk_body)
439    }
440
441    // --- Platform Display ---
442
443    #[test]
444    fn platform_display_all_variants() {
445        assert_eq!(format!("{}", Platform::Win32), "Win32");
446        assert_eq!(format!("{}", Platform::Ps3), "PS3");
447        assert_eq!(format!("{}", Platform::Ps4), "PS4");
448        assert_eq!(format!("{}", Platform::Unknown(42)), "Unknown(42)");
449        // Zero unknown ID is distinct from Win32.
450        assert_eq!(format!("{}", Platform::Unknown(0)), "Unknown(0)");
451    }
452
453    // --- apply_to: basic end-to-end ---
454
455    #[test]
456    fn apply_to_applies_adir_chunk_to_filesystem() {
457        // Verify that a well-formed ADIR + EOF_ patch creates the directory.
458        let mut adir_body = Vec::new();
459        adir_body.extend_from_slice(&7u32.to_be_bytes());
460        adir_body.extend_from_slice(b"created");
461
462        let mut patch = Vec::new();
463        patch.extend_from_slice(&MAGIC);
464        patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
465        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
466
467        let tmp = tempfile::tempdir().unwrap();
468        let mut ctx = ApplyContext::new(tmp.path());
469        ZiPatchReader::new(Cursor::new(patch))
470            .unwrap()
471            .apply_to(&mut ctx)
472            .unwrap();
473
474        assert!(
475            tmp.path().join("created").is_dir(),
476            "ADIR must have created the directory"
477        );
478    }
479
480    #[test]
481    fn apply_to_empty_patch_succeeds_without_side_effects() {
482        // MAGIC + EOF_ only: apply_to must return Ok(()) with no filesystem changes.
483        let mut patch = Vec::new();
484        patch.extend_from_slice(&MAGIC);
485        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
486
487        let tmp = tempfile::tempdir().unwrap();
488        let mut ctx = ApplyContext::new(tmp.path());
489        ZiPatchReader::new(Cursor::new(patch))
490            .unwrap()
491            .apply_to(&mut ctx)
492            .unwrap();
493        // No new entries should appear in the temp dir.
494        let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
495        assert!(
496            entries.is_empty(),
497            "empty patch must not create any files/dirs"
498        );
499    }
500
501    // --- apply_to: error propagation ---
502
503    #[test]
504    fn apply_to_propagates_parse_error_as_unknown_chunk_tag() {
505        // ZZZZ is not a known tag; apply_to must surface UnknownChunkTag.
506        let mut patch = Vec::new();
507        patch.extend_from_slice(&MAGIC);
508        patch.extend_from_slice(&make_chunk(b"ZZZZ", &[]));
509
510        let tmp = tempfile::tempdir().unwrap();
511        let mut ctx = ApplyContext::new(tmp.path());
512        let err = ZiPatchReader::new(Cursor::new(patch))
513            .unwrap()
514            .apply_to(&mut ctx)
515            .unwrap_err();
516        assert!(
517            matches!(err, ZiPatchError::UnknownChunkTag(_)),
518            "expected UnknownChunkTag, got {err:?}"
519        );
520    }
521
522    #[test]
523    fn apply_to_propagates_apply_error_from_delete_directory() {
524        // DELD on a non-existent directory without ignore_missing must return Io.
525        let mut deld_body = Vec::new();
526        deld_body.extend_from_slice(&14u32.to_be_bytes());
527        deld_body.extend_from_slice(b"does_not_exist");
528
529        let mut patch = Vec::new();
530        patch.extend_from_slice(&MAGIC);
531        patch.extend_from_slice(&make_chunk(b"DELD", &deld_body));
532        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
533
534        let tmp = tempfile::tempdir().unwrap();
535        let mut ctx = ApplyContext::new(tmp.path());
536        let err = ZiPatchReader::new(Cursor::new(patch))
537            .unwrap()
538            .apply_to(&mut ctx)
539            .unwrap_err();
540        assert!(
541            matches!(err, ZiPatchError::Io(_)),
542            "expected ZiPatchError::Io for missing dir without ignore_missing, got {err:?}"
543        );
544    }
545
546    // --- Progress / observer / cancellation tests ---
547
548    /// Observer that returns `should_cancel() == true` after `cancel_after` calls.
549    struct CancelAfter {
550        calls: usize,
551        cancel_after: usize,
552    }
553
554    impl ApplyObserver for CancelAfter {
555        fn should_cancel(&mut self) -> bool {
556            let now = self.calls;
557            self.calls += 1;
558            now >= self.cancel_after
559        }
560    }
561
562    #[test]
563    fn observer_fires_for_each_non_eof_chunk_with_correct_fields() {
564        // Two ADIR chunks — observer must receive exactly two events, in order,
565        // with 0-based index, correct tag, and a monotonically increasing
566        // bytes_read that matches the exact wire-frame sizes.
567        let log: Arc<std::sync::Mutex<Vec<ChunkEvent>>> =
568            Arc::new(std::sync::Mutex::new(Vec::new()));
569        let log_clone = log.clone();
570
571        let mut a = Vec::new();
572        a.extend_from_slice(&1u32.to_be_bytes());
573        a.extend_from_slice(b"a");
574        let mut b = Vec::new();
575        b.extend_from_slice(&1u32.to_be_bytes());
576        b.extend_from_slice(b"b");
577
578        let mut patch = Vec::new();
579        patch.extend_from_slice(&MAGIC);
580        patch.extend_from_slice(&make_chunk(b"ADIR", &a));
581        patch.extend_from_slice(&make_chunk(b"ADIR", &b));
582        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
583
584        let tmp = tempfile::tempdir().unwrap();
585        let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |ev| {
586            log_clone.lock().unwrap().push(ev);
587            ControlFlow::Continue(())
588        });
589        ZiPatchReader::new(Cursor::new(patch))
590            .unwrap()
591            .apply_to(&mut ctx)
592            .unwrap();
593
594        let events = log.lock().unwrap();
595        assert_eq!(
596            events.len(),
597            2,
598            "two non-EOF chunks must fire exactly two events"
599        );
600        // Index must be 0-based and monotonically increasing.
601        assert_eq!(events[0].index, 0, "first event index must be 0");
602        assert_eq!(events[1].index, 1, "second event index must be 1");
603        // Tag must reflect the chunk wire tag.
604        assert_eq!(events[0].kind, *b"ADIR");
605        assert_eq!(events[1].kind, *b"ADIR");
606        // ADIR body for name "a": 4 (name_len) + 1 (byte) = 5
607        // Frame: 4(size) + 4(tag) + 5(body) + 4(crc) = 17
608        assert_eq!(
609            events[0].bytes_read,
610            12 + 17,
611            "bytes_read after first ADIR must be magic + one 17-byte frame"
612        );
613        assert_eq!(
614            events[1].bytes_read,
615            12 + 17 + 17,
616            "bytes_read after second ADIR must be magic + two 17-byte frames"
617        );
618        // Strict monotonicity.
619        assert!(
620            events[0].bytes_read < events[1].bytes_read,
621            "bytes_read must strictly increase between events"
622        );
623    }
624
625    #[test]
626    fn observer_break_on_first_chunk_aborts_immediately_leaving_first_applied() {
627        // Observer that always breaks: only the first chunk's apply runs, then
628        // apply_to returns Cancelled. Second and third chunks are never reached.
629        let mut a = Vec::new();
630        a.extend_from_slice(&1u32.to_be_bytes());
631        a.extend_from_slice(b"a");
632        let mut b_body = Vec::new();
633        b_body.extend_from_slice(&1u32.to_be_bytes());
634        b_body.extend_from_slice(b"b");
635        let mut c = Vec::new();
636        c.extend_from_slice(&1u32.to_be_bytes());
637        c.extend_from_slice(b"c");
638
639        let mut patch = Vec::new();
640        patch.extend_from_slice(&MAGIC);
641        patch.extend_from_slice(&make_chunk(b"ADIR", &a));
642        patch.extend_from_slice(&make_chunk(b"ADIR", &b_body));
643        patch.extend_from_slice(&make_chunk(b"ADIR", &c));
644        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
645
646        let count = Arc::new(AtomicUsize::new(0));
647        let count_clone = count.clone();
648
649        let tmp = tempfile::tempdir().unwrap();
650        let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |_| {
651            count_clone.fetch_add(1, Ordering::Relaxed);
652            ControlFlow::Break(())
653        });
654        let err = ZiPatchReader::new(Cursor::new(patch))
655            .unwrap()
656            .apply_to(&mut ctx)
657            .unwrap_err();
658
659        assert!(
660            matches!(err, ZiPatchError::Cancelled),
661            "observer Break must produce ZiPatchError::Cancelled, got {err:?}"
662        );
663        assert_eq!(
664            count.load(Ordering::Relaxed),
665            1,
666            "exactly one on_chunk_applied call fires before the abort takes effect"
667        );
668        // The first ADIR's apply completed before the event fired.
669        assert!(
670            tmp.path().join("a").is_dir(),
671            "first ADIR must have been applied before Cancelled was returned"
672        );
673        // Second and third ADIRs were never reached.
674        assert!(
675            !tmp.path().join("b").exists(),
676            "second ADIR must NOT have been applied after Cancelled"
677        );
678        assert!(
679            !tmp.path().join("c").exists(),
680            "third ADIR must NOT have been applied after Cancelled"
681        );
682    }
683
684    #[test]
685    fn observer_break_on_last_chunk_before_eof_leaves_all_earlier_applied() {
686        // Three ADIRs: observer continues for the first two, breaks on the third.
687        // After Cancelled, a/ and b/ must exist; c/ was the breaker's chunk
688        // (its apply ran before the event fired) and d/ (hypothetical fourth) never runs.
689        let make_adir_chunk = |name: &[u8]| -> Vec<u8> {
690            let mut body = Vec::new();
691            body.extend_from_slice(&(name.len() as u32).to_be_bytes());
692            body.extend_from_slice(name);
693            make_chunk(b"ADIR", &body)
694        };
695
696        let mut patch = Vec::new();
697        patch.extend_from_slice(&MAGIC);
698        patch.extend_from_slice(&make_adir_chunk(b"a"));
699        patch.extend_from_slice(&make_adir_chunk(b"b"));
700        patch.extend_from_slice(&make_adir_chunk(b"c"));
701        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
702
703        let call_count = Arc::new(AtomicUsize::new(0));
704        let cc = call_count.clone();
705        let tmp = tempfile::tempdir().unwrap();
706        let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |_| {
707            let n = cc.fetch_add(1, Ordering::Relaxed) + 1;
708            if n >= 3 {
709                ControlFlow::Break(())
710            } else {
711                ControlFlow::Continue(())
712            }
713        });
714
715        let err = ZiPatchReader::new(Cursor::new(patch))
716            .unwrap()
717            .apply_to(&mut ctx)
718            .unwrap_err();
719
720        assert!(
721            matches!(err, ZiPatchError::Cancelled),
722            "expected Cancelled, got {err:?}"
723        );
724        // First two ADIRs fully applied.
725        assert!(tmp.path().join("a").is_dir(), "a/ must exist");
726        assert!(tmp.path().join("b").is_dir(), "b/ must exist");
727        // Third ADIR's apply ran before the event — c/ exists.
728        assert!(
729            tmp.path().join("c").is_dir(),
730            "c/ must exist (apply ran before event fired)"
731        );
732    }
733
734    #[test]
735    fn sqpk_file_cancellation_mid_block_loop_returns_aborted() {
736        // Three blocks of 8 bytes each. Observer cancels after 2 should_cancel
737        // polls, so at most 2 blocks are written before abort.
738        let chunk = make_sqpk_addfile_chunk("created/test.dat", 3);
739
740        let mut patch = Vec::new();
741        patch.extend_from_slice(&MAGIC);
742        patch.extend_from_slice(&chunk);
743        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
744
745        let tmp = tempfile::tempdir().unwrap();
746        let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
747            calls: 0,
748            cancel_after: 2,
749        });
750
751        let err = ZiPatchReader::new(Cursor::new(patch))
752            .unwrap()
753            .apply_to(&mut ctx)
754            .unwrap_err();
755
756        assert!(
757            matches!(err, ZiPatchError::Cancelled),
758            "mid-block cancellation must return Cancelled, got {err:?}"
759        );
760
761        // File exists (create=true opened it) but the third block must not have
762        // been written.  With `cancel_after = 2`, `should_cancel` returns true
763        // on the third poll (the one that gates block 3), so exactly the first
764        // two 8-byte blocks (= 16 bytes) reach disk.  Pin this exactly so an
765        // off-by-one in where `should_cancel` is polled inside the block loop
766        // would surface as a failing test rather than passing by inequality.
767        let target = tmp.path().join("created").join("test.dat");
768        assert!(
769            target.is_file(),
770            "target file must exist (was created before cancel)"
771        );
772        let len = std::fs::metadata(&target).unwrap().len();
773        assert_eq!(
774            len, 16,
775            "partial write: exactly 2 of 3 blocks (= 16 bytes) must have \
776             been written before cancellation"
777        );
778    }
779
780    #[test]
781    fn sqpk_file_single_block_no_mid_loop_cancel_opportunity() {
782        // A single-block AddFile provides no between-block cancellation
783        // opportunity. An observer that cancels only on the second call to
784        // should_cancel must NOT abort — the loop executes exactly one block
785        // and then the chunk completes normally. The chunk-boundary event fires
786        // next, and a Continue there lets apply_to succeed.
787        let chunk = make_sqpk_addfile_chunk("created/single.dat", 1);
788
789        let mut patch = Vec::new();
790        patch.extend_from_slice(&MAGIC);
791        patch.extend_from_slice(&chunk);
792        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
793
794        let tmp = tempfile::tempdir().unwrap();
795        let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
796            calls: 0,
797            cancel_after: 2, // never reaches 2nd call within a single block
798        });
799
800        // should succeed: only 1 should_cancel call (call 0 < 2 = cancel_after)
801        ZiPatchReader::new(Cursor::new(patch))
802            .unwrap()
803            .apply_to(&mut ctx)
804            .unwrap();
805
806        let target = tmp.path().join("created").join("single.dat");
807        assert!(
808            target.is_file(),
809            "single-block AddFile must complete and create the file"
810        );
811        assert_eq!(
812            std::fs::metadata(&target).unwrap().len(),
813            8,
814            "single block of 8 bytes must be fully written"
815        );
816    }
817
818    #[test]
819    fn sqpk_file_cancel_on_very_first_block_writes_zero_blocks() {
820        // Observer cancels immediately (cancel_after = 0).  The first
821        // should_cancel poll inside the block loop fires before any block data
822        // is written, so the file must be empty (truncated by set_len(0) but
823        // no block data written).
824        let chunk = make_sqpk_addfile_chunk("created/zero.dat", 3);
825
826        let mut patch = Vec::new();
827        patch.extend_from_slice(&MAGIC);
828        patch.extend_from_slice(&chunk);
829        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
830
831        let tmp = tempfile::tempdir().unwrap();
832        let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
833            calls: 0,
834            cancel_after: 0, // cancel on very first check
835        });
836
837        let err = ZiPatchReader::new(Cursor::new(patch))
838            .unwrap()
839            .apply_to(&mut ctx)
840            .unwrap_err();
841
842        assert!(
843            matches!(err, ZiPatchError::Cancelled),
844            "immediate cancel must return Cancelled, got {err:?}"
845        );
846
847        let target = tmp.path().join("created").join("zero.dat");
848        let len = std::fs::metadata(&target).unwrap().len();
849        assert_eq!(
850            len, 0,
851            "cancel before first block: file must be empty, got {len} bytes"
852        );
853    }
854
855    #[test]
856    fn closure_observer_composes_ergonomically_with_with_observer() {
857        // Verify the intended ergonomic usage path: a closure recording state,
858        // passed directly to with_observer via the blanket impl on FnMut.
859        let events: Arc<std::sync::Mutex<Vec<(usize, [u8; 4])>>> =
860            Arc::new(std::sync::Mutex::new(Vec::new()));
861        let ev_clone = events.clone();
862
863        let make_adir = |name: &[u8]| -> Vec<u8> {
864            let mut body = Vec::new();
865            body.extend_from_slice(&(name.len() as u32).to_be_bytes());
866            body.extend_from_slice(name);
867            make_chunk(b"ADIR", &body)
868        };
869
870        let mut patch = Vec::new();
871        patch.extend_from_slice(&MAGIC);
872        patch.extend_from_slice(&make_adir(b"d1"));
873        patch.extend_from_slice(&make_adir(b"d2"));
874        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
875
876        let tmp = tempfile::tempdir().unwrap();
877        let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |ev: ChunkEvent| {
878            ev_clone.lock().unwrap().push((ev.index, ev.kind));
879            ControlFlow::Continue(())
880        });
881
882        ZiPatchReader::new(Cursor::new(patch))
883            .unwrap()
884            .apply_to(&mut ctx)
885            .unwrap();
886
887        let recorded = events.lock().unwrap();
888        assert_eq!(recorded.len(), 2);
889        assert_eq!(recorded[0], (0, *b"ADIR"));
890        assert_eq!(recorded[1], (1, *b"ADIR"));
891    }
892
893    #[test]
894    fn default_no_observer_apply_succeeds_as_before() {
895        // Regression: without with_observer the apply must succeed exactly as
896        // it did before the observer API was introduced.
897        let mut adir_body = Vec::new();
898        adir_body.extend_from_slice(&7u32.to_be_bytes());
899        adir_body.extend_from_slice(b"created");
900
901        let mut patch = Vec::new();
902        patch.extend_from_slice(&MAGIC);
903        patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
904        patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
905
906        let tmp = tempfile::tempdir().unwrap();
907        let mut ctx = ApplyContext::new(tmp.path()); // no with_observer call
908        ZiPatchReader::new(Cursor::new(patch))
909            .unwrap()
910            .apply_to(&mut ctx)
911            .unwrap();
912        assert!(
913            tmp.path().join("created").is_dir(),
914            "ADIR must be applied when no observer is set"
915        );
916    }
917}