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}