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::{
200 Apply, ApplyContext, ApplyObserver, Checkpoint, CheckpointPolicy, CheckpointSink, ChunkEvent,
201 InFlightAddFile, IndexedCheckpoint, NoopCheckpointSink, NoopObserver, SequentialCheckpoint,
202};
203pub use chunk::{Chunk, ZiPatchReader};
204pub use error::ZiPatchError;
205pub use index::{IndexApplier, Plan, PlanBuilder, Verifier};
206
207#[cfg(any(test, feature = "test-utils"))]
208pub use index::MemoryPatchSource;
209
210/// Crate-wide `Result` alias parameterised over [`ZiPatchError`].
211pub type Result<T> = std::result::Result<T, ZiPatchError>;
212
213/// Run the apply loop from `start_index` to `EOF_`, emitting a chunk-boundary
214/// checkpoint after each successful apply.
215///
216/// Factored out of [`ZiPatchReader::apply_to`] so [`ZiPatchReader::resume_apply_to`]
217/// can drive the same loop after fast-forwarding. The caller is responsible
218/// for any pre-loop work (parsing the magic, fast-forwarding past completed
219/// chunks, resuming an in-flight `AddFile`).
220///
221/// Returns the number of chunks applied **by this call** (not including any
222/// chunks skipped by the fast-forward).
223fn run_apply_loop<R: std::io::Read>(
224 reader: &mut chunk::ZiPatchReader<R>,
225 ctx: &mut apply::ApplyContext,
226 start_index: u64,
227) -> Result<u64> {
228 use apply::Apply;
229 use std::ops::ControlFlow;
230 let mut index = start_index;
231 while let Some(chunk) = reader.next() {
232 let chunk = chunk?;
233 ctx.current_chunk_index = index;
234 ctx.current_chunk_bytes_read = reader.bytes_read();
235 chunk.apply(ctx)?;
236 let bytes_read = reader.bytes_read();
237 let tag = reader
238 .last_tag()
239 .expect("last_tag is set whenever next() yielded Some(Ok(_))");
240 let next_chunk_index = index + 1;
241 let checkpoint = apply::Checkpoint::Sequential(apply::SequentialCheckpoint {
242 schema_version: apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
243 next_chunk_index,
244 bytes_read,
245 patch_name: ctx.patch_name.clone(),
246 patch_size: ctx.patch_size,
247 in_flight: None,
248 });
249 tracing::debug!(
250 next_chunk_index,
251 bytes_read,
252 in_flight = false,
253 "apply_to: checkpoint recorded"
254 );
255 ctx.record_checkpoint(&checkpoint)?;
256 let event = apply::ChunkEvent {
257 index: index as usize,
258 kind: tag,
259 bytes_read,
260 };
261 if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
262 return Err(ZiPatchError::Cancelled);
263 }
264 index += 1;
265 }
266 Ok(index - start_index)
267}
268
269impl<R: std::io::Read> chunk::ZiPatchReader<R> {
270 /// Iterate every chunk in the patch stream and apply each one to `ctx`.
271 ///
272 /// This is the primary high-level entry point for applying a patch. It
273 /// drives the [`ZiPatchReader`] iterator to completion, calling
274 /// [`Apply::apply`] on each yielded [`Chunk`] in stream order.
275 ///
276 /// Chunks **must** be applied in order — the `ZiPatch` format is a
277 /// sequential log and later chunks may depend on filesystem state produced
278 /// by earlier ones (e.g. a directory created by an `ADIR` chunk that a
279 /// subsequent `SQPK AddFile` writes into).
280 ///
281 /// # Errors
282 ///
283 /// Stops at the first parse or apply error and returns it immediately.
284 /// Any filesystem changes already applied by earlier chunks are **not**
285 /// rolled back — the format does not provide transactional semantics.
286 ///
287 /// Possible error variants:
288 /// - [`ZiPatchError::Io`] — underlying I/O failure (read or write).
289 /// - [`ZiPatchError::InvalidMagic`] — caught at construction, not here.
290 /// - [`ZiPatchError::UnknownChunkTag`] — an unrecognised 4-byte tag was
291 /// encountered.
292 /// - [`ZiPatchError::ChecksumMismatch`] — a chunk's CRC32 did not match.
293 /// - [`ZiPatchError::TruncatedPatch`] — the stream ended before `EOF_`.
294 /// - [`ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk carried a
295 /// negative offset.
296 /// - [`ZiPatchError::Decompress`] — a compressed block could not be
297 /// inflated.
298 /// - [`ZiPatchError::UnsupportedPlatform`] — a `SqpkTargetInfo` chunk
299 /// declared a `platform_id` outside `0`/`1`/`2`, and a subsequent SQPK
300 /// data chunk requested `SqPack` `.dat`/`.index` path resolution.
301 /// - [`ZiPatchError::Cancelled`] — an installed
302 /// [`ApplyObserver`] requested cancellation.
303 ///
304 /// # Panics
305 ///
306 /// Never panics under normal operation. The internal
307 /// [`ZiPatchReader::last_tag`] is unwrapped after a successful
308 /// [`Iterator::next`] — this is an internal invariant of the iterator
309 /// (every `Some(Ok(_))` updates the tag) and would only fail on a bug
310 /// in this crate.
311 ///
312 /// # Example
313 ///
314 /// ```no_run
315 /// use std::fs::File;
316 /// use zipatch_rs::{ApplyContext, ZiPatchReader};
317 ///
318 /// let mut ctx = ApplyContext::new("/opt/ffxiv/game");
319 /// ZiPatchReader::new(File::open("update.patch").unwrap())
320 /// .unwrap()
321 /// .apply_to(&mut ctx)
322 /// .unwrap();
323 /// ```
324 pub fn apply_to(mut self, ctx: &mut apply::ApplyContext) -> Result<()> {
325 let span = tracing::info_span!("apply_patch");
326 let _enter = span.enter();
327 let started = std::time::Instant::now();
328 ctx.patch_name = self.patch_name().map(str::to_owned);
329 ctx.patch_size = None;
330 // Run the chunk loop in an IIFE so the outer function can flush the
331 // file-handle cache on the way out — both on success (to make the
332 // durability guarantee meaningful: returning `Ok` implies the writes
333 // reached the OS) and on error (so partial progress, e.g. mid-stream
334 // cancellation, is observable in the filesystem). A flush failure
335 // only escapes when there was no primary error to begin with;
336 // otherwise the primary error takes precedence.
337 let result = run_apply_loop(&mut self, ctx, 0);
338 let flush_result = ctx.flush();
339 let (final_result, chunks_applied) = match (result, flush_result) {
340 (Ok(n), Ok(())) => (Ok(()), n),
341 (Ok(_), Err(e)) => (Err(ZiPatchError::Io(e)), 0),
342 (Err(e), _) => (Err(e), 0),
343 };
344 if final_result.is_ok() {
345 tracing::info!(
346 chunks = chunks_applied,
347 bytes_read = self.bytes_read(),
348 resumed_from_chunk = tracing::field::Empty,
349 elapsed_ms = started.elapsed().as_millis() as u64,
350 "apply_to: patch applied"
351 );
352 }
353 final_result
354 }
355}
356
357impl<R: std::io::Read + std::io::Seek> chunk::ZiPatchReader<R> {
358 /// Resume a previously interrupted apply from a [`SequentialCheckpoint`].
359 ///
360 /// When `from` is `None`, behaves identically to
361 /// [`Self::apply_to`] except for the return type: a successful run
362 /// returns the final [`SequentialCheckpoint`] (with
363 /// `next_chunk_index` equal to the total number of chunks consumed,
364 /// including the `EOF_` terminator-driven loop exit).
365 ///
366 /// When `from` is `Some`, the driver fast-forwards the parser past
367 /// `from.next_chunk_index` chunks **without applying them**, then resumes
368 /// the apply loop. If `from.in_flight` is also `Some`, the next chunk
369 /// (which must be the in-flight `SqpkFile::AddFile`) is resumed
370 /// mid-stream: the target file's existing partial content is preserved
371 /// and the chunk's remaining blocks are streamed in starting at
372 /// `in_flight.block_idx`.
373 ///
374 /// # Stale-checkpoint detection
375 ///
376 /// If `from.patch_name` does not match the value supplied via
377 /// [`Self::with_patch_name`], or `from.patch_size` does not match the
378 /// total byte length of the underlying reader, the driver emits a
379 /// `warn!` and starts a clean apply from chunk 0 — the same precedent
380 /// as the stale-manifest path in indexed apply. The returned
381 /// checkpoint in that case carries the new run's identity.
382 ///
383 /// # `ignore_missing` and chunk-N safety
384 ///
385 /// The apply loop never re-applies a chunk whose effects already
386 /// landed: chunk-boundary checkpoints are recorded **after** the
387 /// chunk's apply call has returned, so `next_chunk_index = N` means
388 /// chunks `[0, N)` are confirmed done. There is therefore no need to
389 /// flip [`ApplyContext::ignore_missing`] just for the resume boundary
390 /// — a `DeleteFile` or `DeleteDirectory` at chunk `N` is still a
391 /// first attempt and follows the caller's pre-existing flag setting.
392 ///
393 /// # In-flight `AddFile` safety check
394 ///
395 /// When resuming an in-flight `AddFile`, the driver stats the target
396 /// file. If the file is missing, or its on-disk length is less than
397 /// the checkpoint's `bytes_into_target` (e.g. the partial file was
398 /// truncated, deleted, or replaced since the crash), the in-flight
399 /// state is discarded with a `warn!` and the chunk is re-applied
400 /// from block 0. This is
401 /// the only failure mode where a `from.in_flight` is silently
402 /// ignored; a target-path or file-offset mismatch on the resumed
403 /// chunk produces the same behaviour.
404 ///
405 /// # Completion checkpoints
406 ///
407 /// A successful run returns a final checkpoint whose
408 /// `next_chunk_index` is one past the last chunk in the patch — i.e.
409 /// the index the apply loop *would* read next if there were more
410 /// chunks. Replaying that final checkpoint through this method
411 /// returns [`ZiPatchError::TruncatedPatch`]: the fast-forward will
412 /// run out of chunks before reaching `next_chunk_index`. Consumers
413 /// should detect "patch fully applied" from the `Ok(_)` return of
414 /// the first call (or whatever marker their persistence layer
415 /// records alongside the checkpoint) rather than by passing the
416 /// completion checkpoint back here.
417 ///
418 /// # Errors
419 ///
420 /// Same vocabulary as [`Self::apply_to`], plus
421 /// [`ZiPatchError::SchemaVersionMismatch`] when `from.schema_version`
422 /// does not equal [`apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION`].
423 /// The driver refuses to interpret a checkpoint whose layout this
424 /// build cannot represent.
425 ///
426 /// # Panics
427 ///
428 /// Never panics under normal operation. Internal invariants are the
429 /// same as [`Self::apply_to`].
430 pub fn resume_apply_to(
431 mut self,
432 ctx: &mut apply::ApplyContext,
433 from: Option<&apply::SequentialCheckpoint>,
434 ) -> Result<apply::SequentialCheckpoint> {
435 let span = tracing::info_span!("resume_apply_to");
436 let _enter = span.enter();
437 let started = std::time::Instant::now();
438
439 if let Some(cp) = from {
440 if cp.schema_version != apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION {
441 return Err(ZiPatchError::SchemaVersionMismatch {
442 kind: "sequential-checkpoint",
443 found: cp.schema_version,
444 expected: apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
445 });
446 }
447 }
448
449 let reader_name = self.patch_name().map(str::to_owned);
450 let total_size = stream_total_size(&mut self)?;
451 ctx.patch_name.clone_from(&reader_name);
452 ctx.patch_size = Some(total_size);
453
454 let effective_from = from.and_then(|cp| {
455 let name_match = cp.patch_name == reader_name;
456 // `None` on the checkpoint means the recording driver did not
457 // know the size (the `apply_to` Read-only path). Only declare a
458 // mismatch when both sides carry a value and they disagree.
459 let size_match = match cp.patch_size {
460 Some(sz) => sz == total_size,
461 None => true,
462 };
463 if name_match && size_match {
464 Some(cp)
465 } else {
466 tracing::warn!(
467 expected_patch_name = ?reader_name,
468 expected_patch_size = total_size,
469 checkpoint_patch_name = ?cp.patch_name,
470 checkpoint_patch_size = ?cp.patch_size,
471 "resume_apply_to: stale checkpoint, restarting from chunk 0"
472 );
473 None
474 }
475 });
476
477 let resumed_from_chunk = effective_from.map(|cp| cp.next_chunk_index);
478 let skipped_bytes_at_start = effective_from.map_or(0, |cp| cp.bytes_read);
479 let has_in_flight = effective_from
480 .and_then(|cp| cp.in_flight.as_ref())
481 .is_some();
482
483 if let Some(cp) = effective_from {
484 tracing::info!(
485 patch_name = ?reader_name,
486 skipped_chunks = cp.next_chunk_index,
487 skipped_bytes = cp.bytes_read,
488 has_in_flight,
489 "resume_apply_to: resuming patch"
490 );
491 fast_forward(&mut self, cp.next_chunk_index, cp.bytes_read)?;
492 }
493
494 let start_index = effective_from.map_or(0, |cp| cp.next_chunk_index);
495 let in_flight = effective_from.and_then(|cp| cp.in_flight.clone());
496
497 let result: Result<u64> = (|| {
498 if let Some(in_flight) = in_flight {
499 resume_in_flight_chunk(&mut self, ctx, start_index, &in_flight)?;
500 run_apply_loop(&mut self, ctx, start_index + 1).map(|n| n + 1)
501 } else {
502 run_apply_loop(&mut self, ctx, start_index)
503 }
504 })();
505
506 let flush_result = ctx.flush();
507 let (final_result, chunks_applied) = match (result, flush_result) {
508 (Ok(n), Ok(())) => (Ok(()), n),
509 (Ok(_), Err(e)) => (Err(ZiPatchError::Io(e)), 0),
510 (Err(e), _) => (Err(e), 0),
511 };
512
513 match final_result {
514 Ok(()) => {
515 let bytes_read = self.bytes_read();
516 if let Some(from_chunk) = resumed_from_chunk {
517 tracing::info!(
518 chunks = chunks_applied,
519 bytes_read,
520 resumed_from_chunk = from_chunk,
521 skipped_bytes = skipped_bytes_at_start,
522 elapsed_ms = started.elapsed().as_millis() as u64,
523 "resume_apply_to: patch applied"
524 );
525 } else {
526 tracing::info!(
527 chunks = chunks_applied,
528 bytes_read,
529 resumed_from_chunk = tracing::field::Empty,
530 elapsed_ms = started.elapsed().as_millis() as u64,
531 "resume_apply_to: patch applied"
532 );
533 }
534 Ok(apply::SequentialCheckpoint {
535 schema_version: apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
536 next_chunk_index: start_index + chunks_applied,
537 bytes_read,
538 patch_name: reader_name,
539 patch_size: Some(total_size),
540 in_flight: None,
541 })
542 }
543 Err(e) => Err(e),
544 }
545 }
546}
547
548/// Total byte length of the patch stream, measured by seeking the underlying
549/// source. Returns the cursor to its original position.
550fn stream_total_size<R: std::io::Read + std::io::Seek>(
551 reader: &mut chunk::ZiPatchReader<R>,
552) -> Result<u64> {
553 use std::io::Seek;
554 let inner = reader.inner_mut();
555 let current = inner.stream_position()?;
556 let end = inner.seek(std::io::SeekFrom::End(0))?;
557 inner.seek(std::io::SeekFrom::Start(current))?;
558 Ok(end)
559}
560
561/// Discard-parse chunks until the iterator has yielded `target_chunks`
562/// chunks. Each parse still validates CRCs but the resulting [`Chunk`] is
563/// dropped without applying.
564fn fast_forward<R: std::io::Read>(
565 reader: &mut chunk::ZiPatchReader<R>,
566 target_chunks: u64,
567 expected_bytes_read: u64,
568) -> Result<()> {
569 let mut consumed: u64 = 0;
570 while consumed < target_chunks {
571 match reader.next() {
572 Some(Ok(_)) => consumed += 1,
573 Some(Err(e)) => return Err(e),
574 None => {
575 return Err(ZiPatchError::TruncatedPatch);
576 }
577 }
578 }
579 if reader.bytes_read() != expected_bytes_read {
580 // Drift is informational, not a hard error: the fast-forward is
581 // positional (re-parse `target_chunks` chunks), and
582 // `bytes_read` is metadata the recording driver carried for
583 // diagnostics. A future reader-format tweak that adjusts chunk
584 // framing could legitimately produce a different byte total
585 // for the same chunk count; the resume contract is positional
586 // chunk index, so we surface the discrepancy and continue.
587 tracing::warn!(
588 actual_bytes_read = reader.bytes_read(),
589 expected_bytes_read,
590 target_chunks,
591 "resume_apply_to: bytes_read drift during fast-forward"
592 );
593 }
594 tracing::debug!(
595 skipped_chunks = target_chunks,
596 bytes_read = reader.bytes_read(),
597 "resume_apply_to: fast-forward complete"
598 );
599 Ok(())
600}
601
602/// Apply the in-flight chunk at `start_index`, starting from
603/// `in_flight.block_idx`.
604///
605/// Parses the next chunk via the normal reader; on a target/path/offset
606/// mismatch or a partial-file safety failure, falls back to applying the
607/// chunk fresh from block 0 (with a `warn!`).
608fn resume_in_flight_chunk<R: std::io::Read>(
609 reader: &mut chunk::ZiPatchReader<R>,
610 ctx: &mut apply::ApplyContext,
611 chunk_index: u64,
612 in_flight: &apply::InFlightAddFile,
613) -> Result<()> {
614 use apply::Apply;
615 use std::ops::ControlFlow;
616
617 let chunk = match reader.next() {
618 Some(Ok(c)) => c,
619 Some(Err(e)) => return Err(e),
620 None => return Err(ZiPatchError::TruncatedPatch),
621 };
622
623 ctx.current_chunk_index = chunk_index;
624 ctx.current_chunk_bytes_read = reader.bytes_read();
625
626 let (start_block, start_bytes) = match resolve_in_flight_resume(&chunk, ctx, in_flight) {
627 InFlightResume::Resume {
628 start_block,
629 start_bytes,
630 } => (start_block, start_bytes),
631 InFlightResume::Restart => (0, 0),
632 };
633
634 match &chunk {
635 chunk::Chunk::Sqpk(chunk::SqpkCommand::File(file))
636 if matches!(
637 file.operation,
638 crate::chunk::sqpk::SqpkFileOperation::AddFile
639 ) =>
640 {
641 apply::sqpk::apply_file_add_from(file, ctx, start_block, start_bytes)?;
642 }
643 // The matching guard above already passed in the resolve step or
644 // we're on the Restart branch — re-apply the chunk fresh.
645 _ => chunk.apply(ctx)?,
646 }
647
648 let bytes_read = reader.bytes_read();
649 let tag = reader
650 .last_tag()
651 .expect("last_tag is set whenever next() yielded Some(Ok(_))");
652 let next_chunk_index = chunk_index + 1;
653 let checkpoint = apply::Checkpoint::Sequential(apply::SequentialCheckpoint {
654 schema_version: apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
655 next_chunk_index,
656 bytes_read,
657 patch_name: ctx.patch_name.clone(),
658 patch_size: ctx.patch_size,
659 in_flight: None,
660 });
661 ctx.record_checkpoint(&checkpoint)?;
662 let event = apply::ChunkEvent {
663 index: chunk_index as usize,
664 kind: tag,
665 bytes_read,
666 };
667 if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
668 return Err(ZiPatchError::Cancelled);
669 }
670 Ok(())
671}
672
673enum InFlightResume {
674 Resume {
675 start_block: usize,
676 start_bytes: u64,
677 },
678 Restart,
679}
680
681fn resolve_in_flight_resume(
682 chunk: &chunk::Chunk,
683 ctx: &apply::ApplyContext,
684 in_flight: &apply::InFlightAddFile,
685) -> InFlightResume {
686 let chunk::Chunk::Sqpk(chunk::SqpkCommand::File(file)) = chunk else {
687 tracing::warn!(
688 "resume_apply_to: in-flight chunk is not an SqpkFile; discarding in-flight state"
689 );
690 return InFlightResume::Restart;
691 };
692 if !matches!(
693 file.operation,
694 crate::chunk::sqpk::SqpkFileOperation::AddFile
695 ) {
696 tracing::warn!(
697 "resume_apply_to: in-flight chunk is not an AddFile; discarding in-flight state"
698 );
699 return InFlightResume::Restart;
700 }
701
702 let expected_path = apply::path::generic_path(ctx, &file.path);
703 if expected_path != in_flight.target_path {
704 tracing::warn!(
705 chunk_path = %expected_path.display(),
706 in_flight_path = %in_flight.target_path.display(),
707 "resume_apply_to: in-flight target path does not match chunk; discarding"
708 );
709 return InFlightResume::Restart;
710 }
711 let Ok(chunk_offset) = u64::try_from(file.file_offset) else {
712 tracing::warn!(
713 file_offset = file.file_offset,
714 "resume_apply_to: negative file_offset on in-flight chunk; discarding"
715 );
716 return InFlightResume::Restart;
717 };
718 if chunk_offset != in_flight.file_offset {
719 tracing::warn!(
720 chunk_offset,
721 in_flight_offset = in_flight.file_offset,
722 "resume_apply_to: in-flight file_offset does not match chunk; discarding"
723 );
724 return InFlightResume::Restart;
725 }
726 if in_flight.block_idx as usize > file.blocks.len() {
727 tracing::warn!(
728 block_idx = in_flight.block_idx,
729 block_count = file.blocks.len(),
730 "resume_apply_to: in-flight block_idx out of range; discarding"
731 );
732 return InFlightResume::Restart;
733 }
734 // AddFile @ offset 0 safety: the target's current on-disk length must
735 // cover the bytes the checkpoint says have already been written. A
736 // shorter file (or a missing file) means the partial content was
737 // truncated, deleted, or replaced between the crash and the resume, and
738 // re-running the block loop from `block_idx` would leave a hole.
739 if chunk_offset == 0 && in_flight.bytes_into_target > 0 {
740 let on_disk_len = std::fs::metadata(&in_flight.target_path).map_or(0, |m| m.len());
741 if on_disk_len < in_flight.bytes_into_target {
742 tracing::warn!(
743 target = %in_flight.target_path.display(),
744 on_disk_len,
745 bytes_into_target = in_flight.bytes_into_target,
746 "resume_apply_to: target file truncated or missing since checkpoint; restarting AddFile"
747 );
748 return InFlightResume::Restart;
749 }
750 }
751
752 InFlightResume::Resume {
753 start_block: in_flight.block_idx as usize,
754 start_bytes: in_flight.bytes_into_target,
755 }
756}
757
758/// Target platform for `SqPack` file path resolution.
759///
760/// FFXIV's `SqPack` archive files live in platform-specific subdirectories
761/// under the game install root. For example, a data file for the Windows
762/// client lives at `sqpack/ffxiv/000000.win32.dat0`, while the PS4 equivalent
763/// is `sqpack/ffxiv/000000.ps4.dat0`. The [`Platform`] value stored in an
764/// [`ApplyContext`] selects which suffix is used when resolving chunk targets
765/// to filesystem paths.
766///
767/// # Default
768///
769/// An [`ApplyContext`] defaults to [`Platform::Win32`]. Override this at
770/// construction time with [`ApplyContext::with_platform`].
771///
772/// # Runtime override via `SqpkTargetInfo`
773///
774/// In practice, real FFXIV patch files begin with an `SQPK T` chunk
775/// ([`chunk::SqpkTargetInfo`]) that declares the target platform. When
776/// [`Apply::apply`] is called on that chunk (see `src/apply/sqpk.rs`,
777/// `apply_target_info`), it overwrites [`ApplyContext::platform`] with the
778/// decoded [`Platform`] value. This means the default is only relevant for
779/// synthetic patches or when you know the target in advance and want to assert
780/// it before the stream starts.
781///
782/// # Forward compatibility
783///
784/// The enum is `#[non_exhaustive]`. The [`Platform::Unknown`] variant
785/// preserves unrecognised platform IDs so that newer patch files do not fail
786/// parsing when a new platform is introduced. Path resolution for `SqPack`
787/// `.dat`/`.index` files refuses to guess and returns
788/// [`ZiPatchError::UnsupportedPlatform`] carrying the raw `platform_id` —
789/// silently substituting a default layout would risk writing platform-specific
790/// data to the wrong file.
791///
792/// # Display
793///
794/// Implements [`std::fmt::Display`]: `"Win32"`, `"PS3"`, `"PS4"`, or
795/// `"Unknown(N)"` where `N` is the raw platform ID.
796///
797/// # Example
798///
799/// ```rust
800/// use zipatch_rs::{ApplyContext, Platform};
801///
802/// let ctx = ApplyContext::new("/opt/ffxiv/game")
803/// .with_platform(Platform::Win32);
804///
805/// assert_eq!(ctx.platform(), Platform::Win32);
806/// assert_eq!(format!("{}", Platform::Unknown(99)), "Unknown(99)");
807/// ```
808///
809/// [`chunk::SqpkTargetInfo`]: crate::chunk::SqpkTargetInfo
810#[non_exhaustive]
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
813pub enum Platform {
814 /// Windows / PC client (`win32` path suffix).
815 ///
816 /// This is the platform used by all current PC releases of FFXIV and is
817 /// the default for [`ApplyContext`].
818 Win32,
819 /// `PlayStation` 3 client (`ps3` path suffix).
820 ///
821 /// PS3 support was discontinued after FFXIV: A Realm Reborn. Patches
822 /// targeting this platform are no longer issued by Square Enix, but the
823 /// variant is retained for completeness.
824 Ps3,
825 /// `PlayStation` 4 client (`ps4` path suffix).
826 ///
827 /// Active platform alongside Windows. PS4 patches share the same chunk
828 /// structure as Windows patches but target different file paths.
829 Ps4,
830 /// Unrecognised platform ID preserved from a `SqpkTargetInfo` chunk.
831 ///
832 /// When `apply_target_info` in `src/apply/sqpk.rs` encounters a
833 /// `platform_id` it does not recognise, it stores the raw `u16` value
834 /// here and emits a `warn!` tracing event. Subsequent `SqPack` path
835 /// resolution returns [`ZiPatchError::UnsupportedPlatform`] carrying
836 /// the same `u16` rather than silently routing writes to a default
837 /// layout — quietly substituting `win32` paths for an unknown platform
838 /// would corrupt the on-disk install with platform-specific data
839 /// written to the wrong files. Non-SqPack chunks (e.g. `ADIR`, `DELD`,
840 /// or `SqpkFile` operations resolved via `generic_path`) continue to
841 /// apply, so an unknown platform only aborts at the first `.dat` or
842 /// `.index` lookup.
843 Unknown(u16),
844}
845
846impl std::fmt::Display for Platform {
847 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848 match self {
849 Platform::Win32 => f.write_str("Win32"),
850 Platform::Ps3 => f.write_str("PS3"),
851 Platform::Ps4 => f.write_str("PS4"),
852 Platform::Unknown(id) => write!(f, "Unknown({id})"),
853 }
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860 use crate::test_utils::{MAGIC, make_chunk};
861 use std::io::Cursor;
862 use std::ops::ControlFlow;
863 use std::sync::Arc;
864 use std::sync::atomic::{AtomicUsize, Ordering};
865
866 /// One uncompressed block carrying 8 bytes of payload, framed as a
867 /// `SqpkCompressedBlock` would appear inside an `SqpkFile` `AddFile` body.
868 ///
869 /// Block layout: 16-byte header + 8 data bytes + 104 alignment-pad bytes
870 /// (rounded up to the 128-byte boundary via `(8 + 143) & !127 = 128`).
871 fn make_sqpk_file_block(byte: u8) -> Vec<u8> {
872 let mut out = Vec::new();
873 out.extend_from_slice(&16i32.to_le_bytes()); // header_size
874 out.extend_from_slice(&0u32.to_le_bytes()); // pad
875 out.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
876 out.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
877 out.extend_from_slice(&[byte; 8]); // data
878 out.extend_from_slice(&[0u8; 104]); // 128-byte alignment padding
879 out
880 }
881
882 /// Build an SQPK `F`(`AddFile`) chunk that targets `path` and contains
883 /// `block_count` uncompressed blocks of 8 bytes each.
884 fn make_sqpk_addfile_chunk(path: &str, block_count: usize) -> Vec<u8> {
885 // SQPK `F` command body layout — see `chunk/sqpk/file.rs` docs.
886 let path_bytes: Vec<u8> = {
887 let mut p = path.as_bytes().to_vec();
888 p.push(0); // NUL terminator
889 p
890 };
891
892 let mut cmd_body = Vec::new();
893 cmd_body.push(b'A'); // operation = AddFile
894 cmd_body.extend_from_slice(&[0u8; 2]); // alignment
895 cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_offset = 0
896 cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_size
897 cmd_body.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
898 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // expansion_id
899 cmd_body.extend_from_slice(&[0u8; 2]); // padding
900 cmd_body.extend_from_slice(&path_bytes);
901 for i in 0..block_count {
902 cmd_body.extend_from_slice(&make_sqpk_file_block(0xA0 + (i as u8)));
903 }
904
905 // SQPK chunk body: i32 BE inner_size + 'F' command byte + cmd_body
906 let inner_size = 5 + cmd_body.len();
907 let mut sqpk_body = Vec::new();
908 sqpk_body.extend_from_slice(&(inner_size as i32).to_be_bytes());
909 sqpk_body.push(b'F');
910 sqpk_body.extend_from_slice(&cmd_body);
911
912 make_chunk(b"SQPK", &sqpk_body)
913 }
914
915 // --- Platform Display ---
916
917 #[test]
918 fn platform_display_all_variants() {
919 assert_eq!(format!("{}", Platform::Win32), "Win32");
920 assert_eq!(format!("{}", Platform::Ps3), "PS3");
921 assert_eq!(format!("{}", Platform::Ps4), "PS4");
922 assert_eq!(format!("{}", Platform::Unknown(42)), "Unknown(42)");
923 // Zero unknown ID is distinct from Win32.
924 assert_eq!(format!("{}", Platform::Unknown(0)), "Unknown(0)");
925 }
926
927 // --- apply_to: basic end-to-end ---
928
929 #[test]
930 fn apply_to_applies_adir_chunk_to_filesystem() {
931 // Verify that a well-formed ADIR + EOF_ patch creates the directory.
932 let mut adir_body = Vec::new();
933 adir_body.extend_from_slice(&7u32.to_be_bytes());
934 adir_body.extend_from_slice(b"created");
935
936 let mut patch = Vec::new();
937 patch.extend_from_slice(&MAGIC);
938 patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
939 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
940
941 let tmp = tempfile::tempdir().unwrap();
942 let mut ctx = ApplyContext::new(tmp.path());
943 ZiPatchReader::new(Cursor::new(patch))
944 .unwrap()
945 .apply_to(&mut ctx)
946 .unwrap();
947
948 assert!(
949 tmp.path().join("created").is_dir(),
950 "ADIR must have created the directory"
951 );
952 }
953
954 #[test]
955 fn apply_to_empty_patch_succeeds_without_side_effects() {
956 // MAGIC + EOF_ only: apply_to must return Ok(()) with no filesystem changes.
957 let mut patch = Vec::new();
958 patch.extend_from_slice(&MAGIC);
959 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
960
961 let tmp = tempfile::tempdir().unwrap();
962 let mut ctx = ApplyContext::new(tmp.path());
963 ZiPatchReader::new(Cursor::new(patch))
964 .unwrap()
965 .apply_to(&mut ctx)
966 .unwrap();
967 // No new entries should appear in the temp dir.
968 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
969 assert!(
970 entries.is_empty(),
971 "empty patch must not create any files/dirs"
972 );
973 }
974
975 // --- apply_to: error propagation ---
976
977 #[test]
978 fn apply_to_propagates_parse_error_as_unknown_chunk_tag() {
979 // ZZZZ is not a known tag; apply_to must surface UnknownChunkTag.
980 let mut patch = Vec::new();
981 patch.extend_from_slice(&MAGIC);
982 patch.extend_from_slice(&make_chunk(b"ZZZZ", &[]));
983
984 let tmp = tempfile::tempdir().unwrap();
985 let mut ctx = ApplyContext::new(tmp.path());
986 let err = ZiPatchReader::new(Cursor::new(patch))
987 .unwrap()
988 .apply_to(&mut ctx)
989 .unwrap_err();
990 assert!(
991 matches!(err, ZiPatchError::UnknownChunkTag(_)),
992 "expected UnknownChunkTag, got {err:?}"
993 );
994 }
995
996 #[test]
997 fn apply_to_propagates_apply_error_from_delete_directory() {
998 // DELD on a non-existent directory without ignore_missing must return Io.
999 let mut deld_body = Vec::new();
1000 deld_body.extend_from_slice(&14u32.to_be_bytes());
1001 deld_body.extend_from_slice(b"does_not_exist");
1002
1003 let mut patch = Vec::new();
1004 patch.extend_from_slice(&MAGIC);
1005 patch.extend_from_slice(&make_chunk(b"DELD", &deld_body));
1006 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1007
1008 let tmp = tempfile::tempdir().unwrap();
1009 let mut ctx = ApplyContext::new(tmp.path());
1010 let err = ZiPatchReader::new(Cursor::new(patch))
1011 .unwrap()
1012 .apply_to(&mut ctx)
1013 .unwrap_err();
1014 assert!(
1015 matches!(err, ZiPatchError::Io(_)),
1016 "expected ZiPatchError::Io for missing dir without ignore_missing, got {err:?}"
1017 );
1018 }
1019
1020 // --- Progress / observer / cancellation tests ---
1021
1022 /// Observer that returns `should_cancel() == true` after `cancel_after` calls.
1023 struct CancelAfter {
1024 calls: usize,
1025 cancel_after: usize,
1026 }
1027
1028 impl ApplyObserver for CancelAfter {
1029 fn should_cancel(&mut self) -> bool {
1030 let now = self.calls;
1031 self.calls += 1;
1032 now >= self.cancel_after
1033 }
1034 }
1035
1036 #[test]
1037 fn observer_fires_for_each_non_eof_chunk_with_correct_fields() {
1038 // Two ADIR chunks — observer must receive exactly two events, in order,
1039 // with 0-based index, correct tag, and a monotonically increasing
1040 // bytes_read that matches the exact wire-frame sizes.
1041 let log: Arc<std::sync::Mutex<Vec<ChunkEvent>>> =
1042 Arc::new(std::sync::Mutex::new(Vec::new()));
1043 let log_clone = log.clone();
1044
1045 let mut a = Vec::new();
1046 a.extend_from_slice(&1u32.to_be_bytes());
1047 a.extend_from_slice(b"a");
1048 let mut b = Vec::new();
1049 b.extend_from_slice(&1u32.to_be_bytes());
1050 b.extend_from_slice(b"b");
1051
1052 let mut patch = Vec::new();
1053 patch.extend_from_slice(&MAGIC);
1054 patch.extend_from_slice(&make_chunk(b"ADIR", &a));
1055 patch.extend_from_slice(&make_chunk(b"ADIR", &b));
1056 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1057
1058 let tmp = tempfile::tempdir().unwrap();
1059 let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |ev| {
1060 log_clone.lock().unwrap().push(ev);
1061 ControlFlow::Continue(())
1062 });
1063 ZiPatchReader::new(Cursor::new(patch))
1064 .unwrap()
1065 .apply_to(&mut ctx)
1066 .unwrap();
1067
1068 let events = log.lock().unwrap();
1069 assert_eq!(
1070 events.len(),
1071 2,
1072 "two non-EOF chunks must fire exactly two events"
1073 );
1074 // Index must be 0-based and monotonically increasing.
1075 assert_eq!(events[0].index, 0, "first event index must be 0");
1076 assert_eq!(events[1].index, 1, "second event index must be 1");
1077 // Tag must reflect the chunk wire tag.
1078 assert_eq!(events[0].kind, *b"ADIR");
1079 assert_eq!(events[1].kind, *b"ADIR");
1080 // ADIR body for name "a": 4 (name_len) + 1 (byte) = 5
1081 // Frame: 4(size) + 4(tag) + 5(body) + 4(crc) = 17
1082 assert_eq!(
1083 events[0].bytes_read,
1084 12 + 17,
1085 "bytes_read after first ADIR must be magic + one 17-byte frame"
1086 );
1087 assert_eq!(
1088 events[1].bytes_read,
1089 12 + 17 + 17,
1090 "bytes_read after second ADIR must be magic + two 17-byte frames"
1091 );
1092 // Strict monotonicity.
1093 assert!(
1094 events[0].bytes_read < events[1].bytes_read,
1095 "bytes_read must strictly increase between events"
1096 );
1097 }
1098
1099 #[test]
1100 fn observer_break_on_first_chunk_aborts_immediately_leaving_first_applied() {
1101 // Observer that always breaks: only the first chunk's apply runs, then
1102 // apply_to returns Cancelled. Second and third chunks are never reached.
1103 let mut a = Vec::new();
1104 a.extend_from_slice(&1u32.to_be_bytes());
1105 a.extend_from_slice(b"a");
1106 let mut b_body = Vec::new();
1107 b_body.extend_from_slice(&1u32.to_be_bytes());
1108 b_body.extend_from_slice(b"b");
1109 let mut c = Vec::new();
1110 c.extend_from_slice(&1u32.to_be_bytes());
1111 c.extend_from_slice(b"c");
1112
1113 let mut patch = Vec::new();
1114 patch.extend_from_slice(&MAGIC);
1115 patch.extend_from_slice(&make_chunk(b"ADIR", &a));
1116 patch.extend_from_slice(&make_chunk(b"ADIR", &b_body));
1117 patch.extend_from_slice(&make_chunk(b"ADIR", &c));
1118 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1119
1120 let count = Arc::new(AtomicUsize::new(0));
1121 let count_clone = count.clone();
1122
1123 let tmp = tempfile::tempdir().unwrap();
1124 let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |_| {
1125 count_clone.fetch_add(1, Ordering::Relaxed);
1126 ControlFlow::Break(())
1127 });
1128 let err = ZiPatchReader::new(Cursor::new(patch))
1129 .unwrap()
1130 .apply_to(&mut ctx)
1131 .unwrap_err();
1132
1133 assert!(
1134 matches!(err, ZiPatchError::Cancelled),
1135 "observer Break must produce ZiPatchError::Cancelled, got {err:?}"
1136 );
1137 assert_eq!(
1138 count.load(Ordering::Relaxed),
1139 1,
1140 "exactly one on_chunk_applied call fires before the abort takes effect"
1141 );
1142 // The first ADIR's apply completed before the event fired.
1143 assert!(
1144 tmp.path().join("a").is_dir(),
1145 "first ADIR must have been applied before Cancelled was returned"
1146 );
1147 // Second and third ADIRs were never reached.
1148 assert!(
1149 !tmp.path().join("b").exists(),
1150 "second ADIR must NOT have been applied after Cancelled"
1151 );
1152 assert!(
1153 !tmp.path().join("c").exists(),
1154 "third ADIR must NOT have been applied after Cancelled"
1155 );
1156 }
1157
1158 #[test]
1159 fn observer_break_on_last_chunk_before_eof_leaves_all_earlier_applied() {
1160 // Three ADIRs: observer continues for the first two, breaks on the third.
1161 // After Cancelled, a/ and b/ must exist; c/ was the breaker's chunk
1162 // (its apply ran before the event fired) and d/ (hypothetical fourth) never runs.
1163 let make_adir_chunk = |name: &[u8]| -> Vec<u8> {
1164 let mut body = Vec::new();
1165 body.extend_from_slice(&(name.len() as u32).to_be_bytes());
1166 body.extend_from_slice(name);
1167 make_chunk(b"ADIR", &body)
1168 };
1169
1170 let mut patch = Vec::new();
1171 patch.extend_from_slice(&MAGIC);
1172 patch.extend_from_slice(&make_adir_chunk(b"a"));
1173 patch.extend_from_slice(&make_adir_chunk(b"b"));
1174 patch.extend_from_slice(&make_adir_chunk(b"c"));
1175 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1176
1177 let call_count = Arc::new(AtomicUsize::new(0));
1178 let cc = call_count.clone();
1179 let tmp = tempfile::tempdir().unwrap();
1180 let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |_| {
1181 let n = cc.fetch_add(1, Ordering::Relaxed) + 1;
1182 if n >= 3 {
1183 ControlFlow::Break(())
1184 } else {
1185 ControlFlow::Continue(())
1186 }
1187 });
1188
1189 let err = ZiPatchReader::new(Cursor::new(patch))
1190 .unwrap()
1191 .apply_to(&mut ctx)
1192 .unwrap_err();
1193
1194 assert!(
1195 matches!(err, ZiPatchError::Cancelled),
1196 "expected Cancelled, got {err:?}"
1197 );
1198 // First two ADIRs fully applied.
1199 assert!(tmp.path().join("a").is_dir(), "a/ must exist");
1200 assert!(tmp.path().join("b").is_dir(), "b/ must exist");
1201 // Third ADIR's apply ran before the event — c/ exists.
1202 assert!(
1203 tmp.path().join("c").is_dir(),
1204 "c/ must exist (apply ran before event fired)"
1205 );
1206 }
1207
1208 #[test]
1209 fn sqpk_file_cancellation_mid_block_loop_returns_aborted() {
1210 // Three blocks of 8 bytes each. Observer cancels after 2 should_cancel
1211 // polls, so at most 2 blocks are written before abort.
1212 let chunk = make_sqpk_addfile_chunk("created/test.dat", 3);
1213
1214 let mut patch = Vec::new();
1215 patch.extend_from_slice(&MAGIC);
1216 patch.extend_from_slice(&chunk);
1217 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1218
1219 let tmp = tempfile::tempdir().unwrap();
1220 let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
1221 calls: 0,
1222 cancel_after: 2,
1223 });
1224
1225 let err = ZiPatchReader::new(Cursor::new(patch))
1226 .unwrap()
1227 .apply_to(&mut ctx)
1228 .unwrap_err();
1229
1230 assert!(
1231 matches!(err, ZiPatchError::Cancelled),
1232 "mid-block cancellation must return Cancelled, got {err:?}"
1233 );
1234
1235 // File exists (create=true opened it) but the third block must not have
1236 // been written. With `cancel_after = 2`, `should_cancel` returns true
1237 // on the third poll (the one that gates block 3), so exactly the first
1238 // two 8-byte blocks (= 16 bytes) reach disk. Pin this exactly so an
1239 // off-by-one in where `should_cancel` is polled inside the block loop
1240 // would surface as a failing test rather than passing by inequality.
1241 let target = tmp.path().join("created").join("test.dat");
1242 assert!(
1243 target.is_file(),
1244 "target file must exist (was created before cancel)"
1245 );
1246 let len = std::fs::metadata(&target).unwrap().len();
1247 assert_eq!(
1248 len, 16,
1249 "partial write: exactly 2 of 3 blocks (= 16 bytes) must have \
1250 been written before cancellation"
1251 );
1252 }
1253
1254 #[test]
1255 fn sqpk_file_single_block_no_mid_loop_cancel_opportunity() {
1256 // A single-block AddFile provides no between-block cancellation
1257 // opportunity. An observer that cancels only on the second call to
1258 // should_cancel must NOT abort — the loop executes exactly one block
1259 // and then the chunk completes normally. The chunk-boundary event fires
1260 // next, and a Continue there lets apply_to succeed.
1261 let chunk = make_sqpk_addfile_chunk("created/single.dat", 1);
1262
1263 let mut patch = Vec::new();
1264 patch.extend_from_slice(&MAGIC);
1265 patch.extend_from_slice(&chunk);
1266 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1267
1268 let tmp = tempfile::tempdir().unwrap();
1269 let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
1270 calls: 0,
1271 cancel_after: 2, // never reaches 2nd call within a single block
1272 });
1273
1274 // should succeed: only 1 should_cancel call (call 0 < 2 = cancel_after)
1275 ZiPatchReader::new(Cursor::new(patch))
1276 .unwrap()
1277 .apply_to(&mut ctx)
1278 .unwrap();
1279
1280 let target = tmp.path().join("created").join("single.dat");
1281 assert!(
1282 target.is_file(),
1283 "single-block AddFile must complete and create the file"
1284 );
1285 assert_eq!(
1286 std::fs::metadata(&target).unwrap().len(),
1287 8,
1288 "single block of 8 bytes must be fully written"
1289 );
1290 }
1291
1292 #[test]
1293 fn sqpk_file_cancel_on_very_first_block_writes_zero_blocks() {
1294 // Observer cancels immediately (cancel_after = 0). The first
1295 // should_cancel poll inside the block loop fires before any block data
1296 // is written, so the file must be empty (truncated by set_len(0) but
1297 // no block data written).
1298 let chunk = make_sqpk_addfile_chunk("created/zero.dat", 3);
1299
1300 let mut patch = Vec::new();
1301 patch.extend_from_slice(&MAGIC);
1302 patch.extend_from_slice(&chunk);
1303 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1304
1305 let tmp = tempfile::tempdir().unwrap();
1306 let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
1307 calls: 0,
1308 cancel_after: 0, // cancel on very first check
1309 });
1310
1311 let err = ZiPatchReader::new(Cursor::new(patch))
1312 .unwrap()
1313 .apply_to(&mut ctx)
1314 .unwrap_err();
1315
1316 assert!(
1317 matches!(err, ZiPatchError::Cancelled),
1318 "immediate cancel must return Cancelled, got {err:?}"
1319 );
1320
1321 let target = tmp.path().join("created").join("zero.dat");
1322 let len = std::fs::metadata(&target).unwrap().len();
1323 assert_eq!(
1324 len, 0,
1325 "cancel before first block: file must be empty, got {len} bytes"
1326 );
1327 }
1328
1329 #[test]
1330 fn closure_observer_composes_ergonomically_with_with_observer() {
1331 // Verify the intended ergonomic usage path: a closure recording state,
1332 // passed directly to with_observer via the blanket impl on FnMut.
1333 let events = Arc::new(std::sync::Mutex::new(Vec::<(usize, [u8; 4])>::new()));
1334 let ev_clone = events.clone();
1335
1336 let make_adir = |name: &[u8]| -> Vec<u8> {
1337 let mut body = Vec::new();
1338 body.extend_from_slice(&(name.len() as u32).to_be_bytes());
1339 body.extend_from_slice(name);
1340 make_chunk(b"ADIR", &body)
1341 };
1342
1343 let mut patch = Vec::new();
1344 patch.extend_from_slice(&MAGIC);
1345 patch.extend_from_slice(&make_adir(b"d1"));
1346 patch.extend_from_slice(&make_adir(b"d2"));
1347 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1348
1349 let tmp = tempfile::tempdir().unwrap();
1350 let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |ev: ChunkEvent| {
1351 ev_clone.lock().unwrap().push((ev.index, ev.kind));
1352 ControlFlow::Continue(())
1353 });
1354
1355 ZiPatchReader::new(Cursor::new(patch))
1356 .unwrap()
1357 .apply_to(&mut ctx)
1358 .unwrap();
1359
1360 let recorded = events.lock().unwrap();
1361 assert_eq!(recorded.len(), 2);
1362 assert_eq!(recorded[0], (0, *b"ADIR"));
1363 assert_eq!(recorded[1], (1, *b"ADIR"));
1364 }
1365
1366 #[test]
1367 fn default_no_observer_apply_succeeds_as_before() {
1368 // Regression: without with_observer the apply must succeed exactly as
1369 // it did before the observer API was introduced.
1370 let mut adir_body = Vec::new();
1371 adir_body.extend_from_slice(&7u32.to_be_bytes());
1372 adir_body.extend_from_slice(b"created");
1373
1374 let mut patch = Vec::new();
1375 patch.extend_from_slice(&MAGIC);
1376 patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
1377 patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
1378
1379 let tmp = tempfile::tempdir().unwrap();
1380 let mut ctx = ApplyContext::new(tmp.path()); // no with_observer call
1381 ZiPatchReader::new(Cursor::new(patch))
1382 .unwrap()
1383 .apply_to(&mut ctx)
1384 .unwrap();
1385 assert!(
1386 tmp.path().join("created").is_dir(),
1387 "ADIR must be applied when no observer is set"
1388 );
1389 }
1390}