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}