Skip to main content

zipatch_rs/apply/
mod.rs

1//! Filesystem application of parsed `ZiPatch` chunks.
2//!
3//! # Parse / apply separation
4//!
5//! The crate is intentionally split into two independent layers:
6//!
7//! - **Parsing** (`src/chunk/`) — reads the binary wire format and produces
8//!   [`Chunk`] values. Nothing in the parser allocates file handles, stats
9//!   paths, or performs I/O against the install tree.
10//! - **Applying** (this module) — takes a stream of [`Chunk`] values and
11//!   writes the patch changes to disk.
12//!
13//! The only bridge between the two layers is the [`Apply`] trait, which every
14//! chunk type implements. Callers that only need to inspect patch contents can
15//! use the parser without ever touching this module.
16//!
17//! # `ApplyContext`
18//!
19//! [`ApplyContext`] holds all mutable apply-time state:
20//!
21//! - **Install root** — the absolute path to the game installation directory.
22//!   All `SqPack` paths (`sqpack/<expansion>/...`) are resolved relative to
23//!   this root by the internal path submodule.
24//! - **Target platform** — selects the `win32`/`ps3`/`ps4` subfolder suffix
25//!   used in `SqPack` file paths. Defaults to [`Platform::Win32`] and can be
26//!   overridden either at construction time with [`ApplyContext::with_platform`]
27//!   or at apply time when a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk is
28//!   encountered.
29//! - **Ignore flags** — control whether missing files and old-data mismatches
30//!   produce errors or logged warnings. `SqpkTargetInfo` chunks set these via
31//!   the stream; callers can also pre-configure them.
32//! - **File-handle cache** — a bounded map of open file handles. Because a
33//!   typical patch applies dozens of chunks to the same `.dat` file,
34//!   re-opening that file for every chunk would be wasteful. The cache avoids
35//!   this while bounding the number of simultaneously open file descriptors.
36//!   See the [cache section](#file-handle-cache) below.
37//!
38//! # File-handle cache
39//!
40//! Every `Apply` impl that writes to a `SqPack` file calls an internal
41//! `open_cached` method on `ApplyContext` rather than opening the file
42//! directly. The cache transparently returns an existing writable handle or
43//! opens a new one (with `write=true, create=true, truncate=false`).
44//!
45//! Cached handles are wrapped in a [`std::io::BufWriter`] with a 64 KiB
46//! buffer to coalesce the many small writes the SQPK pipeline emits — block
47//! headers, zero-fill runs, decompressed DEFLATE block output — into a
48//! smaller number of `write(2)` syscalls. Apply functions interact with the
49//! buffered writer transparently because `BufWriter` implements both `Write`
50//! and `Seek`. Call [`ApplyContext::flush`] to force buffered data through
51//! to the operating system at a checkpoint of your choosing;
52//! [`ZiPatchReader::apply_to`](crate::ZiPatchReader::apply_to) calls it
53//! automatically before returning.
54//!
55//! The cache is capped at 256 entries. When it is full and a new, uncached
56//! path is requested, **all** cached handles are flushed and closed at once
57//! before the new one is inserted. This is a simple eviction strategy — it
58//! trades some re-open overhead at eviction boundaries for bounded
59//! file-descriptor usage. Memory cost at the cap is 256 × 64 KiB = 16 MiB.
60//!
61//! Callers should not rely on cached handles persisting across arbitrary
62//! chunks. In particular, [`crate::chunk::sqpk::SqpkFile`]'s `RemoveAll`
63//! operation flushes all cached handles before bulk-deleting files to ensure
64//! no open handles survive into the deletion window (which matters on
65//! Windows). Similarly, `DeleteFile` evicts the cached handle for the
66//! specific path being removed.
67//!
68//! # Ordering and idempotency
69//!
70//! Chunks **must** be applied in stream order. The `ZiPatch` format is a
71//! sequential log, not a random-access manifest: later chunks may depend on
72//! filesystem state produced by earlier ones (e.g. an `AddFile` that writes
73//! blocks into a file created by an earlier `MakeDirTree` or `AddDirectory`).
74//!
75//! Apply operations are **not idempotent** in general. Seeking to an offset
76//! and writing data is idempotent if the same data is written, but
77//! `RemoveAll` is destructive and `DeleteFile` can fail if the file is
78//! already gone (unless `ignore_missing` is set). Partial application
79//! followed by a retry requires careful state tracking at a higher level;
80//! this crate does not provide transactional semantics.
81//!
82//! # Errors
83//!
84//! Every [`Apply::apply`] call returns [`crate::Result`], which is
85//! `Result<(), `[`crate::ZiPatchError`]`>`. Errors propagate from:
86//!
87//! - `std::io::Error` — filesystem failures (permissions, missing parent
88//!   directories, disk full, etc.) wrapped as [`crate::ZiPatchError::Io`].
89//! - [`crate::ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk
90//!   carried a negative `file_offset` that cannot be converted to a seek
91//!   position.
92//!
93//! On error, the apply operation aborts at the failing chunk. Any changes
94//! already applied to the filesystem are **not** rolled back.
95//!
96//! # Progress and cancellation
97//!
98//! Install an [`ApplyObserver`] via [`ApplyContext::with_observer`] to be
99//! notified after each top-level chunk applies and to signal cancellation
100//! mid-stream. The observer's
101//! [`on_chunk_applied`](ApplyObserver::on_chunk_applied) method receives a
102//! [`ChunkEvent`] with the chunk index, 4-byte tag, and running byte count;
103//! its [`should_cancel`](ApplyObserver::should_cancel) predicate is polled
104//! between blocks inside long-running chunks so that aborting a multi-
105//! hundred-MB `SqpkFile` `AddFile` does not have to wait for the whole
106//! chunk to finish. Without an explicit observer, [`ApplyContext`] uses
107//! [`NoopObserver`] and the existing apply path pays nothing.
108//!
109//! # Example
110//!
111//! ```no_run
112//! use std::fs::File;
113//! use zipatch_rs::{ApplyContext, ZiPatchReader};
114//!
115//! let patch_file = File::open("game.patch").unwrap();
116//! let mut ctx = ApplyContext::new("/opt/ffxiv/game");
117//!
118//! ZiPatchReader::new(patch_file)
119//!     .unwrap()
120//!     .apply_to(&mut ctx)
121//!     .unwrap();
122//! ```
123
124pub mod checkpoint;
125pub(crate) mod observer;
126pub(crate) mod path;
127pub(crate) mod sqpk;
128
129pub use checkpoint::{
130    Checkpoint, CheckpointPolicy, CheckpointSink, InFlightAddFile, IndexedCheckpoint,
131    NoopCheckpointSink, SequentialCheckpoint,
132};
133pub use observer::{ApplyObserver, ChunkEvent, NoopObserver};
134
135use crate::Platform;
136use crate::Result;
137use crate::chunk::Chunk;
138use crate::chunk::adir::AddDirectory;
139use crate::chunk::aply::{ApplyOption, ApplyOptionKind};
140use crate::chunk::ddir::DeleteDirectory;
141use std::collections::{HashMap, HashSet};
142use std::fs::{File, OpenOptions};
143use std::io::{BufWriter, Write};
144use std::path::{Path, PathBuf};
145use tracing::{trace, warn};
146
147/// Panics if `policy` is `FsyncEveryN(0)`. Called from both
148/// [`ApplyContext::with_checkpoint_sink`] and
149/// [`crate::IndexApplier::with_checkpoint_sink`] so the two install points
150/// surface the same diagnostic.
151pub(crate) fn validate_checkpoint_policy(policy: CheckpointPolicy) {
152    assert!(
153        !matches!(policy, CheckpointPolicy::FsyncEveryN(0)),
154        "CheckpointPolicy::FsyncEveryN(0) is invalid; use CheckpointPolicy::Fsync \
155         for an every-record fsync cadence"
156    );
157}
158
159/// Discriminator for the `path_cache` key: which `SqPack` file kind is being
160/// resolved. The combination `(main_id, sub_id, file_id, kind)` is the full
161/// cache key, alongside the current `platform` (handled via cache
162/// invalidation rather than as a key component, since `platform` changes
163/// only at `apply_target_info` boundaries).
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
165pub(crate) enum PathKind {
166    Dat,
167    Index,
168}
169
170/// Cache key for resolved `SqPack` `.dat`/`.index` paths.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
172pub(crate) struct PathCacheKey {
173    pub(crate) main_id: u16,
174    pub(crate) sub_id: u16,
175    pub(crate) file_id: u32,
176    pub(crate) kind: PathKind,
177}
178
179const MAX_CACHED_FDS: usize = 256;
180
181// 64 KiB chosen to comfortably absorb the largest single writes the SQPK
182// pipeline emits (1 KiB header chunks, DEFLATE block outputs up to ~32 KiB,
183// zero-fill runs from `write_zeros`) without splitting them. Memory ceiling
184// at the FD cap is 256 * 64 KiB = 16 MiB, which is trivial for a desktop
185// launcher and the only realistic consumer of this library.
186const WRITE_BUFFER_CAPACITY: usize = 64 * 1024;
187
188/// Apply-time state: install root, target platform, flag toggles, and the
189/// internal file-handle cache used by SQPK writers.
190///
191/// # Construction
192///
193/// Build with [`ApplyContext::new`], then chain the `with_*` builder methods
194/// to override defaults:
195///
196/// ```
197/// use zipatch_rs::{ApplyContext, Platform};
198///
199/// let ctx = ApplyContext::new("/opt/ffxiv/game")
200///     .with_platform(Platform::Win32)
201///     .with_ignore_missing(true);
202///
203/// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
204/// assert_eq!(ctx.platform(), Platform::Win32);
205/// assert!(ctx.ignore_missing());
206/// ```
207///
208/// # Platform mutation
209///
210/// The platform defaults to [`Platform::Win32`]. If the patch stream contains
211/// a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk, applying it overwrites
212/// [`ApplyContext::platform`] with the platform declared in the chunk. This is
213/// the normal case: real FFXIV patches begin with a `TargetInfo` chunk that
214/// pins the platform, so the default is rarely used in practice.
215///
216/// Set the platform explicitly with [`ApplyContext::with_platform`] when you
217/// know the target in advance or are processing a synthetic patch.
218///
219/// # Flag mutation
220///
221/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
222/// can also be overwritten mid-stream by `ApplyOption` chunks embedded in the
223/// patch file. Set initial values with the `with_ignore_*` builder methods.
224///
225/// # File-handle cache
226///
227/// Internally, `ApplyContext` maintains a bounded map of open file handles
228/// keyed by absolute path. The cache is an optimisation: a patch that writes
229/// many chunks into the same `.dat` file re-uses a single handle rather than
230/// opening and closing the file for every write.
231///
232/// The cache is capped at 256 entries. When that limit is reached and a new
233/// path is needed, **all** entries are evicted at once. Handles are also
234/// evicted explicitly before deleting a file (see `DeleteFile`) and before a
235/// `RemoveAll` bulk operation.
236pub struct ApplyContext {
237    pub(crate) game_path: PathBuf,
238    /// The target platform. Defaults to `Win32`. Note: `SqpkTargetInfo` chunks
239    /// in the patch stream will override this value when applied.
240    pub(crate) platform: Platform,
241    pub(crate) ignore_missing: bool,
242    pub(crate) ignore_old_mismatch: bool,
243    // Capped at MAX_CACHED_FDS entries; cleared wholesale when full to bound open FD count.
244    // Each handle is wrapped in a `BufWriter` to coalesce the many small
245    // writes the SQPK pipeline emits (block headers, zero-fill runs) into a
246    // smaller number of `write(2)` syscalls. `BufWriter` implements both
247    // `Write` and `Seek`, so apply functions interact with it transparently;
248    // operations that need the raw `File` (currently only `set_len`) call
249    // `get_mut()` after an explicit `flush()`.
250    //
251    // `pub(crate)` so the SQPK `AddFile` apply loop can split-borrow it next
252    // to `observer` for between-block cancellation polling.
253    pub(crate) file_cache: HashMap<PathBuf, BufWriter<File>>,
254    // Memoised set of directories already created via `ensure_dir_all`.
255    // Avoids reissuing `mkdir -p` syscalls for shared parent directories
256    // across long chains of `AddFile` / `MakeDirTree` / `ADIR` chunks. Keys
257    // are the exact `PathBuf` handed to `create_dir_all`; no canonicalisation
258    // is performed (which would itself cost a syscall and was never done by
259    // the previous unconditional path). Destructive ops that remove
260    // directories (`SqpkFile::RemoveAll`, `DeleteDirectory`) clear the entire
261    // set — simpler than tracking exact entries, and correct because a
262    // subsequent `create_dir_all` will re-establish whichever directories
263    // are still needed at the next syscall cost.
264    pub(crate) dirs_created: HashSet<PathBuf>,
265    // Memoised SqPack `.dat`/`.index` path resolutions. A typical patch
266    // dispatches thousands of `AddData`/`DeleteData`/`ExpandData`/`Header`
267    // chunks targeting a small set of files, each of which would otherwise
268    // recompute `expansion_folder_id` (one `String` alloc), the filename
269    // (one `format!` alloc), and three chained `PathBuf::join` calls. The
270    // cache short-circuits repeat lookups for the same
271    // `(main_id, sub_id, file_id, kind)` tuple to a single `PathBuf` clone.
272    // Invalidated by `apply_target_info` when the platform changes, since
273    // the platform string is baked into the cached path.
274    pub(crate) path_cache: HashMap<PathCacheKey, PathBuf>,
275    // Reusable DEFLATE state shared across all `SqpkCompressedBlock` payloads
276    // in a single `SqpkFile::AddFile` chunk (and across chunks). Constructing
277    // a fresh decoder per block allocates ~100 KiB of internal zlib state;
278    // a multi-block `AddFile` chunk can contain hundreds of blocks. Reset
279    // between blocks via `Decompress::reset(false)` so the underlying
280    // buffers are reused. `false` = raw DEFLATE, no zlib wrapper, matching
281    // the SqPack on-the-wire layout.
282    pub(crate) decompressor: flate2::Decompress,
283    // Observer for progress/cancellation. Defaults to a no-op; replaced by
284    // `with_observer`. Stored as a boxed trait object so that the public
285    // `ApplyContext` type stays non-generic and remains nameable in downstream
286    // signatures (`gaveloc-patcher` passes contexts across module boundaries).
287    pub(crate) observer: Box<dyn ApplyObserver>,
288    // Checkpoint sink installed via `with_checkpoint_sink`. Defaults to
289    // `NoopCheckpointSink` so consumers that never opt in pay nothing (one
290    // virtual call per emission site that immediately returns `Ok(())`).
291    pub(crate) checkpoint_sink: Box<dyn CheckpointSink>,
292    // Records-since-last-fsync counter used by `FsyncEveryN`. Reset to zero
293    // whenever an fsync actually runs (either because the cadence fired or
294    // because the policy was `Fsync`).
295    pub(crate) checkpoints_since_fsync: u32,
296    /// Test-only flush counter. Incremented each time [`Self::flush`] is called.
297    ///
298    /// Present only under the `test-utils` feature so integration tests can
299    /// assert the exact flush cadence under each [`CheckpointPolicy`] without
300    /// patching production code. Not part of the stable public API.
301    #[cfg(any(test, feature = "test-utils"))]
302    pub test_flush_count: usize,
303    /// Test-only `sync_all` counter. Incremented each time [`Self::sync_all`] is called.
304    ///
305    /// Present only under the `test-utils` feature. See [`Self::test_flush_count`].
306    #[cfg(any(test, feature = "test-utils"))]
307    pub test_sync_count: usize,
308    // Sequential apply driver's per-chunk progress, set by `apply_to` before
309    // dispatching each `Chunk::apply` call so deep emission sites (e.g. the
310    // per-DEFLATE-block emission inside `SqpkFile::AddFile`) can carry the
311    // stream-relative chunk index and byte position without threading them
312    // through every trait signature.
313    pub(crate) current_chunk_index: u64,
314    pub(crate) current_chunk_bytes_read: u64,
315    // Identifying metadata for the patch stream currently being applied.
316    // Populated by the sequential driver (`apply_to` / `resume_apply_to`)
317    // before dispatching the first chunk so deep emission sites can stamp
318    // each `SequentialCheckpoint` with the same identity the consumer
319    // persisted. `patch_size` is only set when the driver has a `Seek`
320    // source (i.e. `resume_apply_to`); the plain `apply_to` path leaves it
321    // as `None`.
322    pub(crate) patch_name: Option<String>,
323    pub(crate) patch_size: Option<u64>,
324}
325
326// Hand-written so we don't need `ApplyObserver: Debug` as a supertrait —
327// adding supertraits is a SemVer break, and forcing every user observer to
328// implement `Debug` would be a needless ergonomic tax. The observer field
329// is summarised as an opaque placeholder.
330impl std::fmt::Debug for ApplyContext {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        let mut s = f.debug_struct("ApplyContext");
333        s.field("game_path", &self.game_path)
334            .field("platform", &self.platform)
335            .field("ignore_missing", &self.ignore_missing)
336            .field("ignore_old_mismatch", &self.ignore_old_mismatch)
337            .field("file_cache_len", &self.file_cache.len())
338            .field("dirs_created_len", &self.dirs_created.len())
339            .field("path_cache_len", &self.path_cache.len())
340            .field("decompressor", &"<flate2::Decompress>")
341            .field("observer", &"<dyn ApplyObserver>")
342            .field("checkpoint_sink", &"<dyn CheckpointSink>")
343            .field("checkpoints_since_fsync", &self.checkpoints_since_fsync)
344            .field("current_chunk_index", &self.current_chunk_index)
345            .field("current_chunk_bytes_read", &self.current_chunk_bytes_read)
346            .field("patch_name", &self.patch_name)
347            .field("patch_size", &self.patch_size);
348        #[cfg(any(test, feature = "test-utils"))]
349        s.field("test_flush_count", &self.test_flush_count)
350            .field("test_sync_count", &self.test_sync_count);
351        s.finish()
352    }
353}
354
355impl ApplyContext {
356    /// Create a context targeting the given game install directory.
357    ///
358    /// Defaults: platform is [`Platform::Win32`], both ignore-flags are off.
359    ///
360    /// Use the `with_*` builder methods to change these defaults before
361    /// applying the first chunk.
362    ///
363    /// # Example
364    ///
365    /// ```
366    /// use zipatch_rs::ApplyContext;
367    ///
368    /// let ctx = ApplyContext::new("/opt/ffxiv/game");
369    /// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
370    /// ```
371    pub fn new(game_path: impl Into<PathBuf>) -> Self {
372        Self {
373            game_path: game_path.into(),
374            platform: Platform::Win32,
375            ignore_missing: false,
376            ignore_old_mismatch: false,
377            file_cache: HashMap::new(),
378            dirs_created: HashSet::new(),
379            path_cache: HashMap::new(),
380            // `false` = raw DEFLATE (no zlib header). SqPack `AddFile` blocks
381            // store an RFC 1951 raw deflate stream with no wrapper.
382            decompressor: flate2::Decompress::new(false),
383            observer: Box::new(NoopObserver),
384            checkpoint_sink: Box::new(NoopCheckpointSink),
385            checkpoints_since_fsync: 0,
386            #[cfg(any(test, feature = "test-utils"))]
387            test_flush_count: 0,
388            #[cfg(any(test, feature = "test-utils"))]
389            test_sync_count: 0,
390            current_chunk_index: 0,
391            current_chunk_bytes_read: 0,
392            patch_name: None,
393            patch_size: None,
394        }
395    }
396
397    /// Returns the game installation directory.
398    ///
399    /// All file paths produced during apply are relative to this root.
400    #[must_use]
401    pub fn game_path(&self) -> &std::path::Path {
402        &self.game_path
403    }
404
405    /// Returns the current target platform.
406    ///
407    /// This value may change during apply if the patch stream contains a
408    /// [`crate::chunk::sqpk::SqpkTargetInfo`] chunk.
409    #[must_use]
410    pub fn platform(&self) -> Platform {
411        self.platform
412    }
413
414    /// Returns whether missing files are silently ignored during apply.
415    ///
416    /// When `true`, operations that target a file that does not exist log a
417    /// warning instead of returning an error. This flag may be overwritten
418    /// mid-stream by an `ApplyOption` chunk.
419    #[must_use]
420    pub fn ignore_missing(&self) -> bool {
421        self.ignore_missing
422    }
423
424    /// Returns whether old-data mismatches are silently ignored during apply.
425    ///
426    /// When `true`, apply operations that detect a checksum or data mismatch
427    /// against the existing on-disk content proceed without error. This flag
428    /// may be overwritten mid-stream by an `ApplyOption` chunk.
429    #[must_use]
430    pub fn ignore_old_mismatch(&self) -> bool {
431        self.ignore_old_mismatch
432    }
433
434    /// Sets the target platform. Defaults to [`Platform::Win32`].
435    ///
436    /// The platform determines the directory suffix used when resolving `SqPack`
437    /// file paths (`win32`, `ps3`, or `ps4`).
438    ///
439    /// Note: a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk encountered during
440    /// apply will override this value.
441    #[must_use]
442    pub fn with_platform(mut self, platform: Platform) -> Self {
443        self.platform = platform;
444        self
445    }
446
447    /// Silently ignore missing files instead of returning an error during apply.
448    ///
449    /// When `false` (the default), any apply operation that cannot find its
450    /// target file returns [`crate::ZiPatchError::Io`] with kind
451    /// [`std::io::ErrorKind::NotFound`].
452    ///
453    /// When `true`, those failures are demoted to `warn!`-level tracing events.
454    #[must_use]
455    pub fn with_ignore_missing(mut self, v: bool) -> Self {
456        self.ignore_missing = v;
457        self
458    }
459
460    /// Silently ignore old-data mismatches instead of returning an error during apply.
461    ///
462    /// When `false` (the default), an apply operation that detects that the
463    /// on-disk data does not match the expected "before" state returns an error.
464    ///
465    /// When `true`, the mismatch is logged at `warn!` level and the operation
466    /// continues.
467    #[must_use]
468    pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
469        self.ignore_old_mismatch = v;
470        self
471    }
472
473    /// Install an [`ApplyObserver`] for progress reporting and cancellation.
474    ///
475    /// The observer's [`on_chunk_applied`](ApplyObserver::on_chunk_applied)
476    /// method is called after each top-level chunk; its
477    /// [`should_cancel`](ApplyObserver::should_cancel) method is polled
478    /// inside long-running chunks (currently the
479    /// [`SqpkFile`](crate::chunk::sqpk::SqpkFile) block-write loop) so that
480    /// cancellation is observable mid-chunk on multi-hundred-MB payloads.
481    ///
482    /// Returning [`ControlFlow::Break`](std::ops::ControlFlow::Break) from
483    /// `on_chunk_applied`, or `true` from `should_cancel`, aborts the apply
484    /// loop with [`crate::ZiPatchError::Cancelled`]. Filesystem changes already
485    /// applied are **not** rolled back.
486    ///
487    /// The default observer is a no-op: parsing-only consumers and existing
488    /// callers that never call `with_observer` pay nothing.
489    ///
490    /// # `'static` bound
491    ///
492    /// The `'static` bound follows from [`ApplyContext`] storing the
493    /// observer in a `Box<dyn ApplyObserver>` — a trait object whose
494    /// lifetime parameter defaults to `'static`. To pass an observer that
495    /// holds a channel sender or similar handle, wrap it in
496    /// `Arc<Mutex<...>>` (which is `'static`) or implement
497    /// [`ApplyObserver`] on a struct that owns the handle directly.
498    #[must_use]
499    pub fn with_observer(mut self, observer: impl ApplyObserver + 'static) -> Self {
500        self.observer = Box::new(observer);
501        self
502    }
503
504    /// Install a [`CheckpointSink`] to receive apply-time checkpoints.
505    ///
506    /// The driver hands the sink a [`Checkpoint`] at each natural recovery
507    /// boundary — after every top-level chunk in the sequential
508    /// [`apply_to`](crate::ZiPatchReader::apply_to) loop, after every DEFLATE
509    /// block inside a [`SqpkFile`](crate::chunk::sqpk::SqpkFile) `AddFile`,
510    /// and at the per-target / every-64-regions cadence used by
511    /// [`crate::index::IndexApplier::execute`]. The sink's
512    /// [`CheckpointPolicy`] then decides whether the driver also flushes the
513    /// file-handle cache or escalates to a full `fsync` via
514    /// [`Self::sync_all`].
515    ///
516    /// Default is [`NoopCheckpointSink`]: consumers that never call this
517    /// method pay nothing.
518    ///
519    /// # `'static` bound
520    ///
521    /// Mirrors [`Self::with_observer`]: the sink is boxed internally into a
522    /// `Box<dyn CheckpointSink>` whose lifetime parameter defaults to
523    /// `'static`. Wrap in `Arc<Mutex<...>>` or implement [`CheckpointSink`]
524    /// on a struct that owns the handle directly if you need to share the
525    /// sink across threads.
526    ///
527    /// # Panics
528    ///
529    /// Panics if the sink reports
530    /// [`CheckpointPolicy::FsyncEveryN`] with `n == 0`. A zero cadence is
531    /// programmer error — either the consumer meant
532    /// [`CheckpointPolicy::Fsync`] (fsync every record) or the constant was
533    /// computed from a runtime value that wasn't validated. Surfacing the
534    /// check at install time keeps the failure adjacent to the bug rather
535    /// than deep inside the apply loop where the cadence first matters.
536    #[must_use]
537    pub fn with_checkpoint_sink(mut self, sink: impl CheckpointSink + 'static) -> Self {
538        validate_checkpoint_policy(sink.policy());
539        self.checkpoint_sink = Box::new(sink);
540        self
541    }
542
543    /// Flush every cached `BufWriter`, then `fsync` the underlying file
544    /// handles.
545    ///
546    /// Stronger durability guarantee than [`Self::flush`]: a successful
547    /// return implies all writes have not only reached the OS but been
548    /// committed to durable storage (modulo what the underlying filesystem
549    /// guarantees about `fsync`). Used by the apply drivers when an
550    /// installed [`CheckpointSink`] requests
551    /// [`CheckpointPolicy::Fsync`] or hits the
552    /// [`CheckpointPolicy::FsyncEveryN`] cadence.
553    ///
554    /// Handles are retained in the cache after `sync_all`; subsequent
555    /// writes reuse the existing buffered writer. Surfacing the first
556    /// `std::io::Error` from any of the flushes or syncs aborts further
557    /// processing; the rest of the cache is still attempted so that any
558    /// other failing handle does not silently swallow its error.
559    ///
560    /// # Errors
561    ///
562    /// Returns the first `std::io::Error` produced by any writer's flush or
563    /// any underlying handle's `sync_all`.
564    pub fn sync_all(&mut self) -> std::io::Result<()> {
565        #[cfg(any(test, feature = "test-utils"))]
566        {
567            self.test_sync_count += 1;
568        }
569        let mut first_err: Option<std::io::Error> = None;
570        for writer in self.file_cache.values_mut() {
571            if let Err(e) = writer.flush() {
572                first_err.get_or_insert(e);
573                continue;
574            }
575            if let Err(e) = writer.get_ref().sync_all() {
576                first_err.get_or_insert(e);
577            }
578        }
579        match first_err {
580            Some(e) => Err(e),
581            None => Ok(()),
582        }
583    }
584
585    /// Record a chunk-boundary `checkpoint` to the installed sink, then
586    /// honour the sink's policy. Used by the apply drivers at every
587    /// per-chunk / per-target / every-64-regions emission site.
588    ///
589    /// Errors from the sink are wrapped as [`crate::ZiPatchError::Io`]; a
590    /// flush or fsync failure escalates with the same vocabulary the
591    /// sequential driver already uses for its post-loop flush.
592    ///
593    /// Mid-DEFLATE-block emissions inside `SqpkFile::AddFile` must use
594    /// [`Self::record_checkpoint_mid_block`] instead — those emissions are
595    /// too frequent to interleave with a sync syscall, and the driver
596    /// guarantees the next chunk-boundary checkpoint flushes the
597    /// `BufWriter` bytes the mid-block emissions accumulated.
598    pub(crate) fn record_checkpoint(&mut self, checkpoint: &Checkpoint) -> Result<()> {
599        self.checkpoint_sink.record(checkpoint)?;
600        match self.checkpoint_sink.policy() {
601            CheckpointPolicy::Flush => {
602                self.flush()?;
603            }
604            CheckpointPolicy::Fsync => {
605                self.sync_all()?;
606                self.checkpoints_since_fsync = 0;
607            }
608            CheckpointPolicy::FsyncEveryN(n) => {
609                // `with_checkpoint_sink` rejects `FsyncEveryN(0)` at install
610                // time, so `n` is always >= 1 by the time the cadence runs.
611                debug_assert!(n >= 1, "FsyncEveryN(0) must be rejected at install time");
612                self.checkpoints_since_fsync = self.checkpoints_since_fsync.saturating_add(1);
613                if self.checkpoints_since_fsync >= n {
614                    self.sync_all()?;
615                    self.checkpoints_since_fsync = 0;
616                } else {
617                    self.flush()?;
618                }
619            }
620        }
621        Ok(())
622    }
623
624    /// Record an in-flight mid-DEFLATE-block `checkpoint` to the installed
625    /// sink. No flush, no fsync regardless of policy: per-block emissions
626    /// inside an `AddFile` loop fire often enough that honouring the
627    /// `Fsync` / `FsyncEveryN` policy here would gut throughput on
628    /// multi-GB files. The next chunk-boundary
629    /// [`Self::record_checkpoint`] flushes everything the mid-block run
630    /// accumulated in the `BufWriter`.
631    pub(crate) fn record_checkpoint_mid_block(&mut self, checkpoint: &Checkpoint) -> Result<()> {
632        self.checkpoint_sink.record(checkpoint)?;
633        Ok(())
634    }
635
636    /// Return a writable handle to `path`, opening it if not already cached.
637    ///
638    /// If the cache has reached 256 entries and `path` is not already present,
639    /// all cached handles are flushed and dropped before opening the new one.
640    /// The file is opened with `write=true, create=true, truncate=false` and
641    /// wrapped in a [`BufWriter`] with a 64 KiB buffer.
642    ///
643    /// # Errors
644    ///
645    /// Returns `std::io::Error` if the file cannot be opened, or if the
646    /// flush triggered by cache-full eviction fails on any of the dropped
647    /// handles (e.g. disk full while persisting buffered writes).
648    pub(crate) fn open_cached(&mut self, path: &Path) -> std::io::Result<&mut BufWriter<File>> {
649        // Cache-hit fast path: avoid cloning the path into a `PathBuf` on every
650        // call. The indexed apply path calls this once per region (often
651        // millions of regions per chain), so skipping the allocation on the
652        // common hit path is the win — at the cost of one extra HashMap lookup
653        // on the rare miss.
654        if self.file_cache.contains_key(path) {
655            return Ok(self
656                .file_cache
657                .get_mut(path)
658                .expect("contains_key returned true above"));
659        }
660        // Crude eviction: flush + clear all when full to bound open FD count.
661        // Flushing first surfaces write errors (disk full, quota) that would
662        // otherwise be silently swallowed by `BufWriter::drop`.
663        if self.file_cache.len() >= MAX_CACHED_FDS {
664            self.drain_and_flush()?;
665        }
666        let file = OpenOptions::new()
667            .write(true)
668            .create(true)
669            .truncate(false)
670            .open(path)?;
671        Ok(self
672            .file_cache
673            .entry(path.to_path_buf())
674            .or_insert_with(|| BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file)))
675    }
676
677    /// Flush and remove the cached handle for `path`, if any.
678    ///
679    /// Called before a file is deleted so that the OS handle is closed before
680    /// the unlink (no-op on Linux, required on Windows where an open handle
681    /// prevents deletion). The buffered writes are flushed first so that any
682    /// pending data lands on disk before the close.
683    ///
684    /// If no handle is cached for `path`, returns `Ok(())`.
685    ///
686    /// # Errors
687    ///
688    /// Returns `std::io::Error` if flushing the buffered writes fails.
689    pub(crate) fn evict_cached(&mut self, path: &Path) -> std::io::Result<()> {
690        if let Some(mut writer) = self.file_cache.remove(path) {
691            writer.flush()?;
692        }
693        Ok(())
694    }
695
696    /// Flush and drop every cached file handle.
697    ///
698    /// Called by `RemoveAll` before bulk-deleting an expansion folder's files
699    /// to ensure no lingering open handles survive into the deletion window,
700    /// and by `flush()` to provide a public durability checkpoint.
701    ///
702    /// # Errors
703    ///
704    /// Returns the first `std::io::Error` produced by any handle's flush.
705    /// Remaining handles are still flushed and cleared even if an earlier
706    /// one failed; the cache is always empty on return.
707    pub(crate) fn clear_file_cache(&mut self) -> std::io::Result<()> {
708        self.drain_and_flush()
709    }
710
711    /// Create `path` and every missing ancestor, memoising the call.
712    ///
713    /// A real patch issues thousands of `create_dir_all` calls against a
714    /// handful of shared parent directories (`sqpack/ffxiv`,
715    /// `sqpack/ex1/...`, etc.). The first call for a given path falls through
716    /// to [`std::fs::create_dir_all`] and inserts the path into an internal
717    /// set; later calls for the same path return `Ok(())` without issuing the
718    /// syscall.
719    ///
720    /// The cache is cleared by destructive ops that might remove a directory
721    /// it tracks (`SqpkFile::RemoveAll`, `DeleteDirectory`). Cache misses
722    /// after a clear cost exactly one `create_dir_all` syscall, the same as
723    /// before this optimisation.
724    ///
725    /// # Errors
726    ///
727    /// Returns `std::io::Error` if [`std::fs::create_dir_all`] fails. On
728    /// failure the path is **not** inserted into the cache, so a retry that
729    /// fixes the underlying problem (e.g. permissions) will re-attempt the
730    /// syscall.
731    pub(crate) fn ensure_dir_all(&mut self, path: &Path) -> std::io::Result<()> {
732        if self.dirs_created.contains(path) {
733            return Ok(());
734        }
735        std::fs::create_dir_all(path)?;
736        self.dirs_created.insert(path.to_path_buf());
737        Ok(())
738    }
739
740    /// Drop every memoised entry in the created-directories set.
741    ///
742    /// Called by destructive operations that may remove a directory the
743    /// cache claims still exists. Subsequent [`Self::ensure_dir_all`] calls
744    /// fall back to one real `create_dir_all` syscall per path until the set
745    /// repopulates.
746    pub(crate) fn invalidate_dirs_created(&mut self) {
747        self.dirs_created.clear();
748    }
749
750    /// Drop every memoised entry in the `SqPack` path cache.
751    ///
752    /// Called by `apply_target_info` when `ApplyContext::platform` changes,
753    /// since the cached `PathBuf`s embed the platform string. Cache misses
754    /// after a clear cost one full path resolution per `(main_id, sub_id,
755    /// file_id, kind)` until the set repopulates.
756    pub(crate) fn invalidate_path_cache(&mut self) {
757        self.path_cache.clear();
758    }
759
760    /// Flush every cached writer's buffer, then drain the cache.
761    ///
762    /// Used by both [`Self::clear_file_cache`] and the cache-full path inside
763    /// [`Self::open_cached`]. Distinct from [`Self::flush`], which flushes in
764    /// place without dropping the handles.
765    fn drain_and_flush(&mut self) -> std::io::Result<()> {
766        let mut first_err: Option<std::io::Error> = None;
767        for (_, mut writer) in self.file_cache.drain() {
768            if let Err(e) = writer.flush() {
769                first_err.get_or_insert(e);
770            }
771        }
772        match first_err {
773            Some(e) => Err(e),
774            None => Ok(()),
775        }
776    }
777
778    /// Flush every buffered write through to the operating system.
779    ///
780    /// Forces any data still sitting in [`BufWriter`] buffers (one per cached
781    /// `SqPack` file) to be written via `write(2)`. Open handles are retained
782    /// — this is a durability checkpoint, not a cache eviction. Subsequent
783    /// chunks targeting the same files reuse the existing handles.
784    ///
785    /// `apply_to` calls this automatically before returning so successful
786    /// completion of a patch implies all writes have reached the OS. Explicit
787    /// calls are useful when applying chunks one at a time via
788    /// [`Apply::apply`] and reading the resulting file state in between, or
789    /// when implementing a custom apply driver that wants intermediate
790    /// commit points.
791    ///
792    /// This is **not** `fsync`. Data is handed off to the OS but may still
793    /// reside in the page cache; survival across a crash requires
794    /// `File::sync_all` on the underlying handles, which this method does
795    /// not perform.
796    ///
797    /// # Errors
798    ///
799    /// Returns the first `std::io::Error` produced by any writer's flush.
800    /// Remaining writers are still attempted even if an earlier one failed.
801    pub fn flush(&mut self) -> std::io::Result<()> {
802        #[cfg(any(test, feature = "test-utils"))]
803        {
804            self.test_flush_count += 1;
805        }
806        let mut first_err: Option<std::io::Error> = None;
807        for writer in self.file_cache.values_mut() {
808            if let Err(e) = writer.flush() {
809                first_err.get_or_insert(e);
810            }
811        }
812        match first_err {
813            Some(e) => Err(e),
814            None => Ok(()),
815        }
816    }
817}
818
819/// Applies a parsed chunk to the filesystem via an [`ApplyContext`].
820///
821/// Every top-level [`Chunk`] variant and every
822/// [`crate::chunk::sqpk::SqpkCommand`] variant implements this trait. The
823/// usual entry point is [`Chunk::apply`], which dispatches to the appropriate
824/// implementation.
825///
826/// # Ordering
827///
828/// Chunks must be applied in the order they appear in the patch stream.
829/// The format is a sequential log; later chunks may depend on state produced
830/// by earlier ones.
831///
832/// # Idempotency
833///
834/// Apply operations are **not idempotent** in general. Write operations are
835/// idempotent only if the data payload is identical to what is already on
836/// disk. Destructive operations (`RemoveAll`, `DeleteFile`, `DeleteDirectory`)
837/// are not repeatable without error unless `ignore_missing` is set.
838///
839/// # Errors
840///
841/// Returns [`crate::ZiPatchError`] on any filesystem or data error. The error
842/// is not recovered from; the caller should treat it as fatal for the current
843/// apply session.
844///
845/// # Panics
846///
847/// Implementations do not panic under normal operation. Panics would indicate
848/// a bug in the parsing layer (e.g. a chunk with fields that violate internal
849/// invariants established during parsing).
850pub trait Apply {
851    /// Apply this chunk to `ctx`.
852    ///
853    /// On success, any filesystem changes the chunk describes have been
854    /// written. On error, changes may be partial; the caller is responsible
855    /// for any recovery.
856    fn apply(&self, ctx: &mut ApplyContext) -> Result<()>;
857}
858
859/// Dispatch table for top-level chunk variants.
860///
861/// `FileHeader`, `ApplyFreeSpace`, and `EndOfFile` are metadata or structural
862/// chunks with no filesystem effect; they return `Ok(())` immediately.
863/// All other variants delegate to their specific `Apply` implementation.
864impl Apply for Chunk {
865    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
866        match self {
867            Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
868            Chunk::Sqpk(c) => c.apply(ctx),
869            Chunk::ApplyOption(c) => c.apply(ctx),
870            Chunk::AddDirectory(c) => c.apply(ctx),
871            Chunk::DeleteDirectory(c) => c.apply(ctx),
872        }
873    }
874}
875
876/// Updates [`ApplyContext`] ignore-flags from the chunk payload.
877///
878/// `ApplyOption` chunks are embedded in the patch stream to toggle
879/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
880/// at specific points during apply. Applying this chunk mutates `ctx` in
881/// place; no filesystem I/O is performed.
882impl Apply for ApplyOption {
883    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
884        trace!(kind = ?self.kind, value = self.value, "apply option");
885        match self.kind {
886            ApplyOptionKind::IgnoreMissing => ctx.ignore_missing = self.value,
887            ApplyOptionKind::IgnoreOldMismatch => ctx.ignore_old_mismatch = self.value,
888        }
889        Ok(())
890    }
891}
892
893/// Creates a directory under the game install root.
894///
895/// Equivalent to `fs::create_dir_all(game_path / name)`. Intermediate
896/// directories are created as needed; the call is idempotent if the directory
897/// already exists.
898///
899/// # Errors
900///
901/// Returns [`crate::ZiPatchError::Io`] if directory creation fails for any
902/// reason other than the directory already existing (e.g. a permission error
903/// or a non-directory file at the path).
904impl Apply for AddDirectory {
905    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
906        trace!(name = %self.name, "create directory");
907        let path = ctx.game_path.join(&self.name);
908        ctx.ensure_dir_all(&path)?;
909        Ok(())
910    }
911}
912
913/// Removes a directory from the game install root.
914///
915/// The directory must be **empty**; `remove_dir` (not `remove_dir_all`) is
916/// used intentionally so that stale files inside the directory cause a visible
917/// error rather than silent data loss.
918///
919/// If the directory does not exist and [`ApplyContext::ignore_missing`] is
920/// `true`, the missing directory is logged at `warn!` level and `Ok(())` is
921/// returned. If `ignore_missing` is `false`, the `NotFound` I/O error is
922/// propagated.
923///
924/// # Errors
925///
926/// Returns [`crate::ZiPatchError::Io`] if the removal fails for any reason
927/// other than a missing directory with `ignore_missing = true`.
928impl Apply for DeleteDirectory {
929    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
930        match std::fs::remove_dir(ctx.game_path.join(&self.name)) {
931            Ok(()) => {
932                trace!(name = %self.name, "delete directory");
933                // The just-removed directory (or an ancestor it co-occupies)
934                // may sit in the created-dirs cache; clear the whole set so a
935                // subsequent `ensure_dir_all` re-issues a real syscall.
936                ctx.invalidate_dirs_created();
937                Ok(())
938            }
939            Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
940                warn!(name = %self.name, "delete directory: not found, ignored");
941                Ok(())
942            }
943            Err(e) => Err(e.into()),
944        }
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    // --- Cache semantics ---
953
954    #[test]
955    fn cache_eviction_clears_all_entries_when_at_capacity() {
956        // Fill the cache to exactly MAX_CACHED_FDS, then request one new path.
957        // The eviction must drop all 256 entries and leave only the new one.
958        let tmp = tempfile::tempdir().unwrap();
959        let mut ctx = ApplyContext::new(tmp.path());
960
961        for i in 0..MAX_CACHED_FDS {
962            ctx.open_cached(&tmp.path().join(format!("{i}.dat")))
963                .unwrap();
964        }
965        assert_eq!(
966            ctx.file_cache.len(),
967            MAX_CACHED_FDS,
968            "cache should be full before triggering eviction"
969        );
970
971        ctx.open_cached(&tmp.path().join("new.dat")).unwrap();
972        assert_eq!(
973            ctx.file_cache.len(),
974            1,
975            "eviction must clear all entries and leave only the new handle"
976        );
977    }
978
979    #[test]
980    fn cache_hit_does_not_trigger_eviction_when_full() {
981        // With a full cache, requesting an *already-cached* path must NOT evict
982        // — the size stays at MAX_CACHED_FDS.
983        let tmp = tempfile::tempdir().unwrap();
984        let mut ctx = ApplyContext::new(tmp.path());
985
986        for i in 0..MAX_CACHED_FDS {
987            ctx.open_cached(&tmp.path().join(format!("{i}.dat")))
988                .unwrap();
989        }
990        // Re-request the first path — it is already in the cache.
991        ctx.open_cached(&tmp.path().join("0.dat")).unwrap();
992        assert_eq!(
993            ctx.file_cache.len(),
994            MAX_CACHED_FDS,
995            "cache hit on a full cache must not evict anything"
996        );
997    }
998
999    #[test]
1000    fn evict_cached_removes_only_target_path() {
1001        let tmp = tempfile::tempdir().unwrap();
1002        let mut ctx = ApplyContext::new(tmp.path());
1003        let a = tmp.path().join("a.dat");
1004        let b = tmp.path().join("b.dat");
1005        ctx.open_cached(&a).unwrap();
1006        ctx.open_cached(&b).unwrap();
1007        assert_eq!(ctx.file_cache.len(), 2);
1008
1009        ctx.evict_cached(&a).unwrap();
1010        assert_eq!(
1011            ctx.file_cache.len(),
1012            1,
1013            "evict_cached must remove exactly the targeted path"
1014        );
1015        assert!(
1016            ctx.file_cache.contains_key(&b),
1017            "evict_cached must not remove the other path"
1018        );
1019    }
1020
1021    #[test]
1022    fn evict_cached_is_noop_for_absent_path() {
1023        let tmp = tempfile::tempdir().unwrap();
1024        let mut ctx = ApplyContext::new(tmp.path());
1025        ctx.open_cached(&tmp.path().join("a.dat")).unwrap();
1026        // Evicting a path that was never inserted must not panic or change the cache.
1027        ctx.evict_cached(&tmp.path().join("nonexistent.dat"))
1028            .unwrap();
1029        assert_eq!(ctx.file_cache.len(), 1);
1030    }
1031
1032    #[test]
1033    fn clear_file_cache_removes_all_handles() {
1034        let tmp = tempfile::tempdir().unwrap();
1035        let mut ctx = ApplyContext::new(tmp.path());
1036        ctx.open_cached(&tmp.path().join("a.dat")).unwrap();
1037        ctx.open_cached(&tmp.path().join("b.dat")).unwrap();
1038        assert_eq!(ctx.file_cache.len(), 2);
1039        ctx.clear_file_cache().unwrap();
1040        assert_eq!(
1041            ctx.file_cache.len(),
1042            0,
1043            "clear_file_cache must empty the cache"
1044        );
1045    }
1046
1047    // --- Builder accessors ---
1048
1049    #[test]
1050    fn game_path_returns_install_root_unchanged() {
1051        let tmp = tempfile::tempdir().unwrap();
1052        let ctx = ApplyContext::new(tmp.path());
1053        assert_eq!(
1054            ctx.game_path(),
1055            tmp.path(),
1056            "game_path() must return exactly the path passed to new()"
1057        );
1058    }
1059
1060    #[test]
1061    fn default_platform_is_win32() {
1062        let ctx = ApplyContext::new("/irrelevant");
1063        assert_eq!(
1064            ctx.platform(),
1065            Platform::Win32,
1066            "default platform must be Win32"
1067        );
1068    }
1069
1070    #[test]
1071    fn with_platform_overrides_default() {
1072        let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
1073        assert_eq!(
1074            ctx.platform(),
1075            Platform::Ps4,
1076            "with_platform must override the Win32 default"
1077        );
1078    }
1079
1080    #[test]
1081    fn default_ignore_missing_is_false() {
1082        let ctx = ApplyContext::new("/irrelevant");
1083        assert!(
1084            !ctx.ignore_missing(),
1085            "ignore_missing must default to false"
1086        );
1087    }
1088
1089    #[test]
1090    fn with_ignore_missing_toggles_flag_both_ways() {
1091        let ctx = ApplyContext::new("/irrelevant").with_ignore_missing(true);
1092        assert!(
1093            ctx.ignore_missing(),
1094            "with_ignore_missing(true) must set the flag"
1095        );
1096        let ctx = ctx.with_ignore_missing(false);
1097        assert!(
1098            !ctx.ignore_missing(),
1099            "with_ignore_missing(false) must clear the flag"
1100        );
1101    }
1102
1103    #[test]
1104    fn default_ignore_old_mismatch_is_false() {
1105        let ctx = ApplyContext::new("/irrelevant");
1106        assert!(
1107            !ctx.ignore_old_mismatch(),
1108            "ignore_old_mismatch must default to false"
1109        );
1110    }
1111
1112    #[test]
1113    fn with_ignore_old_mismatch_toggles_flag_both_ways() {
1114        let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
1115        assert!(
1116            ctx.ignore_old_mismatch(),
1117            "with_ignore_old_mismatch(true) must set the flag"
1118        );
1119        let ctx = ctx.with_ignore_old_mismatch(false);
1120        assert!(
1121            !ctx.ignore_old_mismatch(),
1122            "with_ignore_old_mismatch(false) must clear the flag"
1123        );
1124    }
1125
1126    // --- with_observer ---
1127    //
1128    // The end-to-end "default context uses NoopObserver" check lives in
1129    // `src/lib.rs` as `default_no_observer_apply_succeeds_as_before`, which
1130    // exercises the full `apply_to` driver path; duplicating it here would
1131    // only re-test the same behaviour through a slightly different lens.
1132
1133    // --- BufWriter cache ---
1134
1135    #[test]
1136    fn buffered_writes_are_invisible_before_flush() {
1137        // The whole point of wrapping cached handles in a BufWriter is to
1138        // hold small writes in user-space memory until enough have queued
1139        // up. Lock that down: a 1-byte write must not be visible on disk
1140        // until flush() is called.
1141        use std::io::Write;
1142
1143        let tmp = tempfile::tempdir().unwrap();
1144        let mut ctx = ApplyContext::new(tmp.path());
1145        let path = tmp.path().join("buffered.dat");
1146
1147        let writer = ctx.open_cached(&path).unwrap();
1148        writer.write_all(&[0xAB]).unwrap();
1149
1150        // File exists (open_cached opened it with create=true) but is empty
1151        // — the byte is sitting in the BufWriter, not on disk.
1152        assert_eq!(
1153            std::fs::metadata(&path).unwrap().len(),
1154            0,
1155            "buffered write must not reach disk before flush"
1156        );
1157
1158        ctx.flush().unwrap();
1159        assert_eq!(
1160            std::fs::read(&path).unwrap(),
1161            vec![0xAB],
1162            "flush must drain the buffer to disk"
1163        );
1164    }
1165
1166    #[test]
1167    fn flush_keeps_handles_in_cache() {
1168        // flush() is a durability checkpoint, not an eviction — handles
1169        // must survive so subsequent chunks targeting the same file reuse
1170        // them rather than reopening.
1171        let tmp = tempfile::tempdir().unwrap();
1172        let mut ctx = ApplyContext::new(tmp.path());
1173        ctx.open_cached(&tmp.path().join("a.dat")).unwrap();
1174        ctx.open_cached(&tmp.path().join("b.dat")).unwrap();
1175        assert_eq!(ctx.file_cache.len(), 2);
1176
1177        ctx.flush().unwrap();
1178        assert_eq!(
1179            ctx.file_cache.len(),
1180            2,
1181            "flush must not drop cached handles"
1182        );
1183    }
1184
1185    #[test]
1186    fn evict_cached_flushes_pending_writes_to_disk() {
1187        // evict_cached must flush before dropping — otherwise buffered
1188        // writes against the about-to-be-closed handle would be silently
1189        // discarded by BufWriter::drop's error-swallowing flush.
1190        use std::io::Write;
1191
1192        let tmp = tempfile::tempdir().unwrap();
1193        let mut ctx = ApplyContext::new(tmp.path());
1194        let path = tmp.path().join("evict.dat");
1195
1196        let writer = ctx.open_cached(&path).unwrap();
1197        writer.write_all(b"queued").unwrap();
1198        assert_eq!(
1199            std::fs::metadata(&path).unwrap().len(),
1200            0,
1201            "pre-condition: write is buffered, not on disk"
1202        );
1203
1204        ctx.evict_cached(&path).unwrap();
1205        assert_eq!(
1206            std::fs::read(&path).unwrap(),
1207            b"queued",
1208            "evict_cached must flush before closing the handle"
1209        );
1210        assert!(
1211            !ctx.file_cache.contains_key(&path),
1212            "evict_cached must also remove the entry"
1213        );
1214    }
1215
1216    #[test]
1217    fn clear_file_cache_flushes_every_pending_write() {
1218        // clear_file_cache must flush every buffered writer before dropping
1219        // — RemoveAll relies on this to avoid losing pending data against
1220        // the about-to-be-unlinked files.
1221        use std::io::Write;
1222
1223        let tmp = tempfile::tempdir().unwrap();
1224        let mut ctx = ApplyContext::new(tmp.path());
1225        let a = tmp.path().join("a.dat");
1226        let b = tmp.path().join("b.dat");
1227
1228        ctx.open_cached(&a).unwrap().write_all(b"AA").unwrap();
1229        ctx.open_cached(&b).unwrap().write_all(b"BB").unwrap();
1230
1231        ctx.clear_file_cache().unwrap();
1232
1233        assert_eq!(std::fs::read(&a).unwrap(), b"AA");
1234        assert_eq!(std::fs::read(&b).unwrap(), b"BB");
1235        assert!(ctx.file_cache.is_empty(), "cache must be empty after clear");
1236    }
1237
1238    // --- Debug impl ---
1239
1240    #[test]
1241    fn apply_context_debug_renders_all_fields() {
1242        // ApplyContext can't derive Debug because Box<dyn ApplyObserver> doesn't
1243        // implement it; the hand-written impl substitutes a placeholder for the
1244        // observer. Lock down the rendered shape so future refactors don't
1245        // accidentally drop fields (and so the impl itself is exercised by tests).
1246        let tmp = tempfile::tempdir().unwrap();
1247        let ctx = ApplyContext::new(tmp.path())
1248            .with_platform(Platform::Ps4)
1249            .with_ignore_missing(true);
1250
1251        let rendered = format!("{ctx:?}");
1252        for needle in [
1253            "ApplyContext",
1254            "game_path",
1255            "platform",
1256            "Ps4",
1257            "ignore_missing",
1258            "true",
1259            "ignore_old_mismatch",
1260            "file_cache_len",
1261            "path_cache_len",
1262            "decompressor",
1263            "<flate2::Decompress>",
1264            "observer",
1265            "<dyn ApplyObserver>",
1266        ] {
1267            assert!(
1268                rendered.contains(needle),
1269                "Debug output must mention {needle:?}; got: {rendered}"
1270            );
1271        }
1272    }
1273
1274    // --- DeleteDirectory happy path ---
1275
1276    #[test]
1277    fn delete_directory_success_removes_existing_dir() {
1278        // Exercises the Ok(()) trace+return arm of DeleteDirectory::apply
1279        // (previously only the ignore_missing and propagate-error arms were
1280        // covered).
1281        let tmp = tempfile::tempdir().unwrap();
1282        let target = tmp.path().join("to_remove");
1283        std::fs::create_dir(&target).unwrap();
1284        assert!(target.is_dir(), "pre-condition: directory must exist");
1285
1286        let mut ctx = ApplyContext::new(tmp.path());
1287        DeleteDirectory {
1288            name: "to_remove".into(),
1289        }
1290        .apply(&mut ctx)
1291        .expect("delete on an existing directory must succeed");
1292
1293        assert!(!target.exists(), "directory must be removed");
1294    }
1295
1296    // --- ensure_dir_all cache-hit branch ---
1297
1298    #[test]
1299    fn ensure_dir_all_cache_hit_returns_early_without_syscall() {
1300        // The second call for the same path must take the early-return branch
1301        // at line 521 (`return Ok(())`).  We confirm this is hit by pre-seeding
1302        // `dirs_created` and then calling `ensure_dir_all` for a path that does
1303        // NOT actually exist on disk — if the cache-miss branch ran it would
1304        // call `create_dir_all` and either succeed (masking the bug) or fail.
1305        let tmp = tempfile::tempdir().unwrap();
1306        let mut ctx = ApplyContext::new(tmp.path());
1307
1308        let path = tmp.path().join("cached_dir");
1309        // First call: misses the cache, creates the directory on disk, inserts.
1310        ctx.ensure_dir_all(&path).unwrap();
1311        assert!(path.is_dir(), "first call must create the directory");
1312        assert_eq!(
1313            ctx.dirs_created.len(),
1314            1,
1315            "path must be cached after first call"
1316        );
1317
1318        // Remove the directory so a second real `create_dir_all` would see it
1319        // gone — if the cache-hit branch is NOT taken, the syscall would still
1320        // succeed (create_dir_all is idempotent for missing dirs), so instead
1321        // we verify the set length stays at 1, not 2.
1322        let p2 = tmp.path().join("cached_dir");
1323        ctx.ensure_dir_all(&p2).unwrap();
1324        assert_eq!(
1325            ctx.dirs_created.len(),
1326            1,
1327            "cache hit must not re-insert the path (set length must stay 1)"
1328        );
1329    }
1330
1331    // --- drain_and_flush error branch ---
1332
1333    #[test]
1334    fn drain_and_flush_error_propagates_first_io_error() {
1335        // Trigger the `Some(e) => Err(e)` arm in `drain_and_flush`.
1336        //
1337        // `/dev/full` always returns ENOSPC on write — use it as the backing
1338        // file so the BufWriter's flush (which actually calls write(2)) fails.
1339        // We open `/dev/full` directly and inject the handle into `file_cache`
1340        // via the `pub(crate)` field, bypassing `open_cached` which uses
1341        // `create=true` (incompatible with a character device).
1342        use std::io::Write;
1343
1344        let dev_full = std::path::PathBuf::from("/dev/full");
1345        if !dev_full.exists() {
1346            // /dev/full is Linux-specific; skip on platforms that lack it.
1347            return;
1348        }
1349
1350        let file = OpenOptions::new()
1351            .write(true)
1352            .open(&dev_full)
1353            .expect("/dev/full must be openable for writing");
1354
1355        let mut ctx = ApplyContext::new("/irrelevant");
1356        let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
1357        // Write into the BufWriter's user-space buffer — this succeeds because
1358        // BufWriter holds the data in RAM.  The write only reaches /dev/full
1359        // (and fails with ENOSPC) when the buffer is flushed.
1360        writer.write_all(&[0xAB; 128]).unwrap();
1361        ctx.file_cache.insert(dev_full.clone(), writer);
1362
1363        let result = ctx.clear_file_cache();
1364
1365        assert!(
1366            result.is_err(),
1367            "drain_and_flush must propagate the ENOSPC error from /dev/full"
1368        );
1369        assert!(
1370            ctx.file_cache.is_empty(),
1371            "cache must be drained even when flush fails"
1372        );
1373    }
1374
1375    // --- flush error branch ---
1376
1377    #[test]
1378    fn flush_error_propagates_first_io_error() {
1379        // Trigger the `Some(e) => Err(e)` arm in `flush` using the same
1380        // `/dev/full` trick as `drain_and_flush_error_propagates_first_io_error`.
1381        // `flush` keeps handles in the cache (unlike `drain_and_flush`), so we
1382        // assert the cache still contains the entry after the failed flush.
1383        use std::io::Write;
1384
1385        let dev_full = std::path::PathBuf::from("/dev/full");
1386        if !dev_full.exists() {
1387            return;
1388        }
1389
1390        let file = OpenOptions::new()
1391            .write(true)
1392            .open(&dev_full)
1393            .expect("/dev/full must be openable for writing");
1394
1395        let mut ctx = ApplyContext::new("/irrelevant");
1396        let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
1397        writer.write_all(&[0xCD; 128]).unwrap();
1398        ctx.file_cache.insert(dev_full.clone(), writer);
1399
1400        let result = ctx.flush();
1401
1402        assert!(
1403            result.is_err(),
1404            "flush must propagate the ENOSPC error from /dev/full"
1405        );
1406        assert_eq!(
1407            ctx.file_cache.len(),
1408            1,
1409            "flush must NOT evict handles — only drain_and_flush does that"
1410        );
1411    }
1412}