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