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