Skip to main content

zipatch_rs/
lib.rs

1//! Parser and applier for FFXIV `ZiPatch` (`.patch`) binary files.
2//!
3//! `zipatch-rs` decodes the binary patch format that Square Enix ships for
4//! Final Fantasy XIV and writes the decoded changes to a local game installation.
5//! The library never touches the network — it operates entirely on byte streams
6//! you supply.
7//!
8//! # Architecture
9//!
10//! The crate is split into three layers that share types but are otherwise
11//! independent:
12//!
13//! ## Layer 1 — I/O primitives (`reader`)
14//!
15//! `reader::ReadExt` is a crate-internal extension trait that adds typed
16//! big- and little-endian reads on top of [`std::io::Read`]. It is not part
17//! of the public API; the parsing layer uses it exclusively.
18//!
19//! ## Layer 2 — Parsing ([`chunk`])
20//!
21//! [`ZiPatchReader`] is an [`Iterator`] over [`Chunk`] values. Construct it
22//! from any [`std::io::Read`] source (a [`std::fs::File`], a
23//! [`std::io::Cursor<Vec<u8>>`], a network stream, …). It validates the
24//! 12-byte file magic on construction, then yields one [`Chunk`] per
25//! [`Iterator::next`] call until it sees the `EOF_` terminator or hits an
26//! error.
27//!
28//! Nothing in the parsing layer allocates file handles, stats paths, or
29//! performs I/O against the install tree. Parse-only users can consume
30//! [`ZiPatchReader`] without ever importing [`apply`].
31//!
32//! ## Layer 3 — Applying ([`apply`])
33//!
34//! The [`Apply`] trait bridges parsing and application: every [`Chunk`]
35//! variant implements it, and each implementation writes the patch change to
36//! disk via an [`ApplyContext`]. [`ApplyContext`] holds the install root, the
37//! target [`Platform`], behavioural flags, and an internal file-handle cache
38//! that avoids re-opening the same `.dat` file for every chunk.
39//!
40//! # Quick start
41//!
42//! The most common usage: open a patch file, build a context, apply every
43//! chunk in stream order.
44//!
45//! ```no_run
46//! use std::fs::File;
47//! use zipatch_rs::{ApplyContext, ZiPatchReader};
48//!
49//! let patch_file = File::open("H2017.07.11.0000.0000a.patch").unwrap();
50//! let mut ctx = ApplyContext::new("/opt/ffxiv/game");
51//!
52//! ZiPatchReader::new(patch_file)
53//!     .unwrap()
54//!     .apply_to(&mut ctx)
55//!     .unwrap();
56//! ```
57//!
58//! # Inspecting a patch without applying it
59//!
60//! Iterate the reader directly to inspect chunks without touching the
61//! filesystem:
62//!
63//! ```no_run
64//! use zipatch_rs::{Chunk, ZiPatchReader};
65//! use std::fs::File;
66//!
67//! let reader = ZiPatchReader::new(File::open("patch.patch").unwrap()).unwrap();
68//! for chunk in reader {
69//!     match chunk.unwrap() {
70//!         Chunk::FileHeader(h) => println!("patch version: {:?}", h),
71//!         Chunk::AddDirectory(d) => println!("mkdir {}", d.name),
72//!         Chunk::Sqpk(cmd) => println!("sqpk: {cmd:?}"),
73//!         _ => {}
74//!     }
75//! }
76//! ```
77//!
78//! # In-memory doctest
79//!
80//! The following example builds a minimal well-formed patch in memory — magic
81//! header, one `ADIR` chunk (which creates a directory), and an `EOF_`
82//! terminator — then applies it to a temporary directory. This mirrors the
83//! technique used in the crate's own unit tests.
84//!
85//! ```rust
86//! use std::io::Cursor;
87//! use zipatch_rs::{ApplyContext, Chunk, ZiPatchReader};
88//!
89//! // ZiPatch file magic: \x91ZIPATCH\r\n\x1a\n
90//! const MAGIC: [u8; 12] = [
91//!     0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48,
92//!     0x0D, 0x0A, 0x1A, 0x0A,
93//! ];
94//!
95//! /// Wrap `tag + body` into a length-prefixed, CRC32-verified chunk frame.
96//! fn make_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
97//!     // CRC is computed over tag ++ body (NOT including the leading body_len).
98//!     let mut crc_input = Vec::new();
99//!     crc_input.extend_from_slice(tag);
100//!     crc_input.extend_from_slice(body);
101//!     let crc = crc32fast::hash(&crc_input);
102//!
103//!     let mut out = Vec::new();
104//!     out.extend_from_slice(&(body.len() as u32).to_be_bytes()); // body_len: u32 BE
105//!     out.extend_from_slice(tag);                                // tag: 4 bytes
106//!     out.extend_from_slice(body);                               // body: body_len bytes
107//!     out.extend_from_slice(&crc.to_be_bytes());                 // crc32: u32 BE
108//!     out
109//! }
110//!
111//! // ADIR body: big-endian u32 name length followed by the name bytes.
112//! let mut adir_body = Vec::new();
113//! adir_body.extend_from_slice(&7u32.to_be_bytes()); // name_len
114//! adir_body.extend_from_slice(b"created");          // name
115//!
116//! // Assemble the full patch stream.
117//! let mut patch = Vec::new();
118//! patch.extend_from_slice(&MAGIC);
119//! patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
120//! patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
121//!
122//! // Apply to a temporary directory.
123//! let tmp = tempfile::tempdir().unwrap();
124//! let mut ctx = ApplyContext::new(tmp.path());
125//! ZiPatchReader::new(Cursor::new(patch))
126//!     .unwrap()
127//!     .apply_to(&mut ctx)
128//!     .unwrap();
129//!
130//! assert!(tmp.path().join("created").is_dir());
131//! ```
132//!
133//! # Error handling
134//!
135//! Every fallible operation returns [`Result<T>`], which is an alias for
136//! `std::result::Result<T, `[`ZiPatchError`]`>`. Parse errors and apply
137//! errors share the same type so callers need only one error arm.
138//!
139//! # Progress and cancellation
140//!
141//! [`ApplyContext::with_observer`] installs an [`ApplyObserver`] that is
142//! called after each chunk applies (with the chunk index, tag, and running
143//! byte count from [`ZiPatchReader::bytes_read`]) and polled inside long-
144//! running chunks for cancellation. Returning
145//! [`std::ops::ControlFlow::Break`] from a per-chunk callback, or `true`
146//! from [`ApplyObserver::should_cancel`], aborts the apply call with
147//! [`ZiPatchError::Cancelled`]. Parsing-only consumers and existing
148//! [`apply_to`](ZiPatchReader::apply_to) callers that never install an
149//! observer pay nothing — the default is a no-op.
150//!
151//! # Tracing
152//!
153//! The library emits structured [`tracing`] events 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}