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