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(crate) mod observer;
125pub(crate) mod path;
126pub(crate) mod sqpk;
127
128pub use observer::{ApplyObserver, ChunkEvent, NoopObserver};
129
130use crate::Platform;
131use crate::Result;
132use crate::chunk::Chunk;
133use crate::chunk::adir::AddDirectory;
134use crate::chunk::aply::{ApplyOption, ApplyOptionKind};
135use crate::chunk::ddir::DeleteDirectory;
136use std::collections::{HashMap, HashSet};
137use std::fs::{File, OpenOptions};
138use std::io::{BufWriter, Write};
139use std::path::{Path, PathBuf};
140use tracing::{trace, warn};
141
142/// Discriminator for the `path_cache` key: which `SqPack` file kind is being
143/// resolved. The combination `(main_id, sub_id, file_id, kind)` is the full
144/// cache key, alongside the current `platform` (handled via cache
145/// invalidation rather than as a key component, since `platform` changes
146/// only at `apply_target_info` boundaries).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148pub(crate) enum PathKind {
149    Dat,
150    Index,
151}
152
153/// Cache key for resolved `SqPack` `.dat`/`.index` paths.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
155pub(crate) struct PathCacheKey {
156    pub(crate) main_id: u16,
157    pub(crate) sub_id: u16,
158    pub(crate) file_id: u32,
159    pub(crate) kind: PathKind,
160}
161
162const MAX_CACHED_FDS: usize = 256;
163
164// 64 KiB chosen to comfortably absorb the largest single writes the SQPK
165// pipeline emits (1 KiB header chunks, DEFLATE block outputs up to ~32 KiB,
166// zero-fill runs from `write_zeros`) without splitting them. Memory ceiling
167// at the FD cap is 256 * 64 KiB = 16 MiB, which is trivial for a desktop
168// launcher and the only realistic consumer of this library.
169const WRITE_BUFFER_CAPACITY: usize = 64 * 1024;
170
171/// Apply-time state: install root, target platform, flag toggles, and the
172/// internal file-handle cache used by SQPK writers.
173///
174/// # Construction
175///
176/// Build with [`ApplyContext::new`], then chain the `with_*` builder methods
177/// to override defaults:
178///
179/// ```
180/// use zipatch_rs::{ApplyContext, Platform};
181///
182/// let ctx = ApplyContext::new("/opt/ffxiv/game")
183///     .with_platform(Platform::Win32)
184///     .with_ignore_missing(true);
185///
186/// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
187/// assert_eq!(ctx.platform(), Platform::Win32);
188/// assert!(ctx.ignore_missing());
189/// ```
190///
191/// # Platform mutation
192///
193/// The platform defaults to [`Platform::Win32`]. If the patch stream contains
194/// a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk, applying it overwrites
195/// [`ApplyContext::platform`] with the platform declared in the chunk. This is
196/// the normal case: real FFXIV patches begin with a `TargetInfo` chunk that
197/// pins the platform, so the default is rarely used in practice.
198///
199/// Set the platform explicitly with [`ApplyContext::with_platform`] when you
200/// know the target in advance or are processing a synthetic patch.
201///
202/// # Flag mutation
203///
204/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
205/// can also be overwritten mid-stream by `ApplyOption` chunks embedded in the
206/// patch file. Set initial values with the `with_ignore_*` builder methods.
207///
208/// # File-handle cache
209///
210/// Internally, `ApplyContext` maintains a bounded map of open file handles
211/// keyed by absolute path. The cache is an optimisation: a patch that writes
212/// many chunks into the same `.dat` file re-uses a single handle rather than
213/// opening and closing the file for every write.
214///
215/// The cache is capped at 256 entries. When that limit is reached and a new
216/// path is needed, **all** entries are evicted at once. Handles are also
217/// evicted explicitly before deleting a file (see `DeleteFile`) and before a
218/// `RemoveAll` bulk operation.
219pub struct ApplyContext {
220    pub(crate) game_path: PathBuf,
221    /// The target platform. Defaults to `Win32`. Note: `SqpkTargetInfo` chunks
222    /// in the patch stream will override this value when applied.
223    pub(crate) platform: Platform,
224    pub(crate) ignore_missing: bool,
225    pub(crate) ignore_old_mismatch: bool,
226    // Capped at MAX_CACHED_FDS entries; cleared wholesale when full to bound open FD count.
227    // Each handle is wrapped in a `BufWriter` to coalesce the many small
228    // writes the SQPK pipeline emits (block headers, zero-fill runs) into a
229    // smaller number of `write(2)` syscalls. `BufWriter` implements both
230    // `Write` and `Seek`, so apply functions interact with it transparently;
231    // operations that need the raw `File` (currently only `set_len`) call
232    // `get_mut()` after an explicit `flush()`.
233    //
234    // `pub(crate)` so the SQPK `AddFile` apply loop can split-borrow it next
235    // to `observer` for between-block cancellation polling.
236    pub(crate) file_cache: HashMap<PathBuf, BufWriter<File>>,
237    // Memoised set of directories already created via `ensure_dir_all`.
238    // Avoids reissuing `mkdir -p` syscalls for shared parent directories
239    // across long chains of `AddFile` / `MakeDirTree` / `ADIR` chunks. Keys
240    // are the exact `PathBuf` handed to `create_dir_all`; no canonicalisation
241    // is performed (which would itself cost a syscall and was never done by
242    // the previous unconditional path). Destructive ops that remove
243    // directories (`SqpkFile::RemoveAll`, `DeleteDirectory`) clear the entire
244    // set — simpler than tracking exact entries, and correct because a
245    // subsequent `create_dir_all` will re-establish whichever directories
246    // are still needed at the next syscall cost.
247    pub(crate) dirs_created: HashSet<PathBuf>,
248    // Memoised SqPack `.dat`/`.index` path resolutions. A typical patch
249    // dispatches thousands of `AddData`/`DeleteData`/`ExpandData`/`Header`
250    // chunks targeting a small set of files, each of which would otherwise
251    // recompute `expansion_folder_id` (one `String` alloc), the filename
252    // (one `format!` alloc), and three chained `PathBuf::join` calls. The
253    // cache short-circuits repeat lookups for the same
254    // `(main_id, sub_id, file_id, kind)` tuple to a single `PathBuf` clone.
255    // Invalidated by `apply_target_info` when the platform changes, since
256    // the platform string is baked into the cached path.
257    pub(crate) path_cache: HashMap<PathCacheKey, PathBuf>,
258    // Reusable DEFLATE state shared across all `SqpkCompressedBlock` payloads
259    // in a single `SqpkFile::AddFile` chunk (and across chunks). Constructing
260    // a fresh decoder per block allocates ~100 KiB of internal zlib state;
261    // a multi-block `AddFile` chunk can contain hundreds of blocks. Reset
262    // between blocks via `Decompress::reset(false)` so the underlying
263    // buffers are reused. `false` = raw DEFLATE, no zlib wrapper, matching
264    // the SqPack on-the-wire layout.
265    pub(crate) decompressor: flate2::Decompress,
266    // Observer for progress/cancellation. Defaults to a no-op; replaced by
267    // `with_observer`. Stored as a boxed trait object so that the public
268    // `ApplyContext` type stays non-generic and remains nameable in downstream
269    // signatures (`gaveloc-patcher` passes contexts across module boundaries).
270    pub(crate) observer: Box<dyn ApplyObserver>,
271}
272
273// Hand-written so we don't need `ApplyObserver: Debug` as a supertrait —
274// adding supertraits is a SemVer break, and forcing every user observer to
275// implement `Debug` would be a needless ergonomic tax. The observer field
276// is summarised as an opaque placeholder.
277impl std::fmt::Debug for ApplyContext {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.debug_struct("ApplyContext")
280            .field("game_path", &self.game_path)
281            .field("platform", &self.platform)
282            .field("ignore_missing", &self.ignore_missing)
283            .field("ignore_old_mismatch", &self.ignore_old_mismatch)
284            .field("file_cache_len", &self.file_cache.len())
285            .field("dirs_created_len", &self.dirs_created.len())
286            .field("path_cache_len", &self.path_cache.len())
287            .field("decompressor", &"<flate2::Decompress>")
288            .field("observer", &"<dyn ApplyObserver>")
289            .finish()
290    }
291}
292
293impl ApplyContext {
294    /// Create a context targeting the given game install directory.
295    ///
296    /// Defaults: platform is [`Platform::Win32`], both ignore-flags are off.
297    ///
298    /// Use the `with_*` builder methods to change these defaults before
299    /// applying the first chunk.
300    ///
301    /// # Example
302    ///
303    /// ```
304    /// use zipatch_rs::ApplyContext;
305    ///
306    /// let ctx = ApplyContext::new("/opt/ffxiv/game");
307    /// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
308    /// ```
309    pub fn new(game_path: impl Into<PathBuf>) -> Self {
310        Self {
311            game_path: game_path.into(),
312            platform: Platform::Win32,
313            ignore_missing: false,
314            ignore_old_mismatch: false,
315            file_cache: HashMap::new(),
316            dirs_created: HashSet::new(),
317            path_cache: HashMap::new(),
318            // `false` = raw DEFLATE (no zlib header). SqPack `AddFile` blocks
319            // store an RFC 1951 raw deflate stream with no wrapper.
320            decompressor: flate2::Decompress::new(false),
321            observer: Box::new(NoopObserver),
322        }
323    }
324
325    /// Returns the game installation directory.
326    ///
327    /// All file paths produced during apply are relative to this root.
328    #[must_use]
329    pub fn game_path(&self) -> &std::path::Path {
330        &self.game_path
331    }
332
333    /// Returns the current target platform.
334    ///
335    /// This value may change during apply if the patch stream contains a
336    /// [`crate::chunk::sqpk::SqpkTargetInfo`] chunk.
337    #[must_use]
338    pub fn platform(&self) -> Platform {
339        self.platform
340    }
341
342    /// Returns whether missing files are silently ignored during apply.
343    ///
344    /// When `true`, operations that target a file that does not exist log a
345    /// warning instead of returning an error. This flag may be overwritten
346    /// mid-stream by an `ApplyOption` chunk.
347    #[must_use]
348    pub fn ignore_missing(&self) -> bool {
349        self.ignore_missing
350    }
351
352    /// Returns whether old-data mismatches are silently ignored during apply.
353    ///
354    /// When `true`, apply operations that detect a checksum or data mismatch
355    /// against the existing on-disk content proceed without error. This flag
356    /// may be overwritten mid-stream by an `ApplyOption` chunk.
357    #[must_use]
358    pub fn ignore_old_mismatch(&self) -> bool {
359        self.ignore_old_mismatch
360    }
361
362    /// Sets the target platform. Defaults to [`Platform::Win32`].
363    ///
364    /// The platform determines the directory suffix used when resolving `SqPack`
365    /// file paths (`win32`, `ps3`, or `ps4`).
366    ///
367    /// Note: a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk encountered during
368    /// apply will override this value.
369    #[must_use]
370    pub fn with_platform(mut self, platform: Platform) -> Self {
371        self.platform = platform;
372        self
373    }
374
375    /// Silently ignore missing files instead of returning an error during apply.
376    ///
377    /// When `false` (the default), any apply operation that cannot find its
378    /// target file returns [`crate::ZiPatchError::Io`] with kind
379    /// [`std::io::ErrorKind::NotFound`].
380    ///
381    /// When `true`, those failures are demoted to `warn!`-level tracing events.
382    #[must_use]
383    pub fn with_ignore_missing(mut self, v: bool) -> Self {
384        self.ignore_missing = v;
385        self
386    }
387
388    /// Silently ignore old-data mismatches instead of returning an error during apply.
389    ///
390    /// When `false` (the default), an apply operation that detects that the
391    /// on-disk data does not match the expected "before" state returns an error.
392    ///
393    /// When `true`, the mismatch is logged at `warn!` level and the operation
394    /// continues.
395    #[must_use]
396    pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
397        self.ignore_old_mismatch = v;
398        self
399    }
400
401    /// Install an [`ApplyObserver`] for progress reporting and cancellation.
402    ///
403    /// The observer's [`on_chunk_applied`](ApplyObserver::on_chunk_applied)
404    /// method is called after each top-level chunk; its
405    /// [`should_cancel`](ApplyObserver::should_cancel) method is polled
406    /// inside long-running chunks (currently the
407    /// [`SqpkFile`](crate::chunk::sqpk::SqpkFile) block-write loop) so that
408    /// cancellation is observable mid-chunk on multi-hundred-MB payloads.
409    ///
410    /// Returning [`ControlFlow::Break`](std::ops::ControlFlow::Break) from
411    /// `on_chunk_applied`, or `true` from `should_cancel`, aborts the apply
412    /// loop with [`crate::ZiPatchError::Cancelled`]. Filesystem changes already
413    /// applied are **not** rolled back.
414    ///
415    /// The default observer is a no-op: parsing-only consumers and existing
416    /// callers that never call `with_observer` pay nothing.
417    ///
418    /// # `'static` bound
419    ///
420    /// The `'static` bound follows from [`ApplyContext`] storing the
421    /// observer in a `Box<dyn ApplyObserver>` — a trait object whose
422    /// lifetime parameter defaults to `'static`. To pass an observer that
423    /// holds a channel sender or similar handle, wrap it in
424    /// `Arc<Mutex<...>>` (which is `'static`) or implement
425    /// [`ApplyObserver`] on a struct that owns the handle directly.
426    #[must_use]
427    pub fn with_observer(mut self, observer: impl ApplyObserver + 'static) -> Self {
428        self.observer = Box::new(observer);
429        self
430    }
431
432    /// Return a writable handle to `path`, opening it if not already cached.
433    ///
434    /// If the cache has reached 256 entries and `path` is not already present,
435    /// all cached handles are flushed and dropped before opening the new one.
436    /// The file is opened with `write=true, create=true, truncate=false` and
437    /// wrapped in a [`BufWriter`] with a 64 KiB buffer.
438    ///
439    /// # Errors
440    ///
441    /// Returns `std::io::Error` if the file cannot be opened, or if the
442    /// flush triggered by cache-full eviction fails on any of the dropped
443    /// handles (e.g. disk full while persisting buffered writes).
444    pub(crate) fn open_cached(&mut self, path: &Path) -> std::io::Result<&mut BufWriter<File>> {
445        // Cache-hit fast path: avoid cloning the path into a `PathBuf` on every
446        // call. The indexed apply path calls this once per region (often
447        // millions of regions per chain), so skipping the allocation on the
448        // common hit path is the win — at the cost of one extra HashMap lookup
449        // on the rare miss.
450        if self.file_cache.contains_key(path) {
451            return Ok(self
452                .file_cache
453                .get_mut(path)
454                .expect("contains_key returned true above"));
455        }
456        // Crude eviction: flush + clear all when full to bound open FD count.
457        // Flushing first surfaces write errors (disk full, quota) that would
458        // otherwise be silently swallowed by `BufWriter::drop`.
459        if self.file_cache.len() >= MAX_CACHED_FDS {
460            self.drain_and_flush()?;
461        }
462        let file = OpenOptions::new()
463            .write(true)
464            .create(true)
465            .truncate(false)
466            .open(path)?;
467        Ok(self
468            .file_cache
469            .entry(path.to_path_buf())
470            .or_insert_with(|| BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file)))
471    }
472
473    /// Flush and remove the cached handle for `path`, if any.
474    ///
475    /// Called before a file is deleted so that the OS handle is closed before
476    /// the unlink (no-op on Linux, required on Windows where an open handle
477    /// prevents deletion). The buffered writes are flushed first so that any
478    /// pending data lands on disk before the close.
479    ///
480    /// If no handle is cached for `path`, returns `Ok(())`.
481    ///
482    /// # Errors
483    ///
484    /// Returns `std::io::Error` if flushing the buffered writes fails.
485    pub(crate) fn evict_cached(&mut self, path: &Path) -> std::io::Result<()> {
486        if let Some(mut writer) = self.file_cache.remove(path) {
487            writer.flush()?;
488        }
489        Ok(())
490    }
491
492    /// Flush and drop every cached file handle.
493    ///
494    /// Called by `RemoveAll` before bulk-deleting an expansion folder's files
495    /// to ensure no lingering open handles survive into the deletion window,
496    /// and by `flush()` to provide a public durability checkpoint.
497    ///
498    /// # Errors
499    ///
500    /// Returns the first `std::io::Error` produced by any handle's flush.
501    /// Remaining handles are still flushed and cleared even if an earlier
502    /// one failed; the cache is always empty on return.
503    pub(crate) fn clear_file_cache(&mut self) -> std::io::Result<()> {
504        self.drain_and_flush()
505    }
506
507    /// Create `path` and every missing ancestor, memoising the call.
508    ///
509    /// A real patch issues thousands of `create_dir_all` calls against a
510    /// handful of shared parent directories (`sqpack/ffxiv`,
511    /// `sqpack/ex1/...`, etc.). The first call for a given path falls through
512    /// to [`std::fs::create_dir_all`] and inserts the path into an internal
513    /// set; later calls for the same path return `Ok(())` without issuing the
514    /// syscall.
515    ///
516    /// The cache is cleared by destructive ops that might remove a directory
517    /// it tracks (`SqpkFile::RemoveAll`, `DeleteDirectory`). Cache misses
518    /// after a clear cost exactly one `create_dir_all` syscall, the same as
519    /// before this optimisation.
520    ///
521    /// # Errors
522    ///
523    /// Returns `std::io::Error` if [`std::fs::create_dir_all`] fails. On
524    /// failure the path is **not** inserted into the cache, so a retry that
525    /// fixes the underlying problem (e.g. permissions) will re-attempt the
526    /// syscall.
527    pub(crate) fn ensure_dir_all(&mut self, path: &Path) -> std::io::Result<()> {
528        if self.dirs_created.contains(path) {
529            return Ok(());
530        }
531        std::fs::create_dir_all(path)?;
532        self.dirs_created.insert(path.to_path_buf());
533        Ok(())
534    }
535
536    /// Drop every memoised entry in the created-directories set.
537    ///
538    /// Called by destructive operations that may remove a directory the
539    /// cache claims still exists. Subsequent [`Self::ensure_dir_all`] calls
540    /// fall back to one real `create_dir_all` syscall per path until the set
541    /// repopulates.
542    pub(crate) fn invalidate_dirs_created(&mut self) {
543        self.dirs_created.clear();
544    }
545
546    /// Drop every memoised entry in the `SqPack` path cache.
547    ///
548    /// Called by `apply_target_info` when `ApplyContext::platform` changes,
549    /// since the cached `PathBuf`s embed the platform string. Cache misses
550    /// after a clear cost one full path resolution per `(main_id, sub_id,
551    /// file_id, kind)` until the set repopulates.
552    pub(crate) fn invalidate_path_cache(&mut self) {
553        self.path_cache.clear();
554    }
555
556    /// Flush every cached writer's buffer, then drain the cache.
557    ///
558    /// Used by both [`Self::clear_file_cache`] and the cache-full path inside
559    /// [`Self::open_cached`]. Distinct from [`Self::flush`], which flushes in
560    /// place without dropping the handles.
561    fn drain_and_flush(&mut self) -> std::io::Result<()> {
562        let mut first_err: Option<std::io::Error> = None;
563        for (_, mut writer) in self.file_cache.drain() {
564            if let Err(e) = writer.flush() {
565                first_err.get_or_insert(e);
566            }
567        }
568        match first_err {
569            Some(e) => Err(e),
570            None => Ok(()),
571        }
572    }
573
574    /// Flush every buffered write through to the operating system.
575    ///
576    /// Forces any data still sitting in [`BufWriter`] buffers (one per cached
577    /// `SqPack` file) to be written via `write(2)`. Open handles are retained
578    /// — this is a durability checkpoint, not a cache eviction. Subsequent
579    /// chunks targeting the same files reuse the existing handles.
580    ///
581    /// `apply_to` calls this automatically before returning so successful
582    /// completion of a patch implies all writes have reached the OS. Explicit
583    /// calls are useful when applying chunks one at a time via
584    /// [`Apply::apply`] and reading the resulting file state in between, or
585    /// when implementing a custom apply driver that wants intermediate
586    /// commit points.
587    ///
588    /// This is **not** `fsync`. Data is handed off to the OS but may still
589    /// reside in the page cache; survival across a crash requires
590    /// `File::sync_all` on the underlying handles, which this method does
591    /// not perform.
592    ///
593    /// # Errors
594    ///
595    /// Returns the first `std::io::Error` produced by any writer's flush.
596    /// Remaining writers are still attempted even if an earlier one failed.
597    pub fn flush(&mut self) -> std::io::Result<()> {
598        let mut first_err: Option<std::io::Error> = None;
599        for writer in self.file_cache.values_mut() {
600            if let Err(e) = writer.flush() {
601                first_err.get_or_insert(e);
602            }
603        }
604        match first_err {
605            Some(e) => Err(e),
606            None => Ok(()),
607        }
608    }
609}
610
611/// Applies a parsed chunk to the filesystem via an [`ApplyContext`].
612///
613/// Every top-level [`Chunk`] variant and every
614/// [`crate::chunk::sqpk::SqpkCommand`] variant implements this trait. The
615/// usual entry point is [`Chunk::apply`], which dispatches to the appropriate
616/// implementation.
617///
618/// # Ordering
619///
620/// Chunks must be applied in the order they appear in the patch stream.
621/// The format is a sequential log; later chunks may depend on state produced
622/// by earlier ones.
623///
624/// # Idempotency
625///
626/// Apply operations are **not idempotent** in general. Write operations are
627/// idempotent only if the data payload is identical to what is already on
628/// disk. Destructive operations (`RemoveAll`, `DeleteFile`, `DeleteDirectory`)
629/// are not repeatable without error unless `ignore_missing` is set.
630///
631/// # Errors
632///
633/// Returns [`crate::ZiPatchError`] on any filesystem or data error. The error
634/// is not recovered from; the caller should treat it as fatal for the current
635/// apply session.
636///
637/// # Panics
638///
639/// Implementations do not panic under normal operation. Panics would indicate
640/// a bug in the parsing layer (e.g. a chunk with fields that violate internal
641/// invariants established during parsing).
642pub trait Apply {
643    /// Apply this chunk to `ctx`.
644    ///
645    /// On success, any filesystem changes the chunk describes have been
646    /// written. On error, changes may be partial; the caller is responsible
647    /// for any recovery.
648    fn apply(&self, ctx: &mut ApplyContext) -> Result<()>;
649}
650
651/// Dispatch table for top-level chunk variants.
652///
653/// `FileHeader`, `ApplyFreeSpace`, and `EndOfFile` are metadata or structural
654/// chunks with no filesystem effect; they return `Ok(())` immediately.
655/// All other variants delegate to their specific `Apply` implementation.
656impl Apply for Chunk {
657    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
658        match self {
659            Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
660            Chunk::Sqpk(c) => c.apply(ctx),
661            Chunk::ApplyOption(c) => c.apply(ctx),
662            Chunk::AddDirectory(c) => c.apply(ctx),
663            Chunk::DeleteDirectory(c) => c.apply(ctx),
664        }
665    }
666}
667
668/// Updates [`ApplyContext`] ignore-flags from the chunk payload.
669///
670/// `ApplyOption` chunks are embedded in the patch stream to toggle
671/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
672/// at specific points during apply. Applying this chunk mutates `ctx` in
673/// place; no filesystem I/O is performed.
674impl Apply for ApplyOption {
675    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
676        trace!(kind = ?self.kind, value = self.value, "apply option");
677        match self.kind {
678            ApplyOptionKind::IgnoreMissing => ctx.ignore_missing = self.value,
679            ApplyOptionKind::IgnoreOldMismatch => ctx.ignore_old_mismatch = self.value,
680        }
681        Ok(())
682    }
683}
684
685/// Creates a directory under the game install root.
686///
687/// Equivalent to `fs::create_dir_all(game_path / name)`. Intermediate
688/// directories are created as needed; the call is idempotent if the directory
689/// already exists.
690///
691/// # Errors
692///
693/// Returns [`crate::ZiPatchError::Io`] if directory creation fails for any
694/// reason other than the directory already existing (e.g. a permission error
695/// or a non-directory file at the path).
696impl Apply for AddDirectory {
697    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
698        trace!(name = %self.name, "create directory");
699        let path = ctx.game_path.join(&self.name);
700        ctx.ensure_dir_all(&path)?;
701        Ok(())
702    }
703}
704
705/// Removes a directory from the game install root.
706///
707/// The directory must be **empty**; `remove_dir` (not `remove_dir_all`) is
708/// used intentionally so that stale files inside the directory cause a visible
709/// error rather than silent data loss.
710///
711/// If the directory does not exist and [`ApplyContext::ignore_missing`] is
712/// `true`, the missing directory is logged at `warn!` level and `Ok(())` is
713/// returned. If `ignore_missing` is `false`, the `NotFound` I/O error is
714/// propagated.
715///
716/// # Errors
717///
718/// Returns [`crate::ZiPatchError::Io`] if the removal fails for any reason
719/// other than a missing directory with `ignore_missing = true`.
720impl Apply for DeleteDirectory {
721    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
722        match std::fs::remove_dir(ctx.game_path.join(&self.name)) {
723            Ok(()) => {
724                trace!(name = %self.name, "delete directory");
725                // The just-removed directory (or an ancestor it co-occupies)
726                // may sit in the created-dirs cache; clear the whole set so a
727                // subsequent `ensure_dir_all` re-issues a real syscall.
728                ctx.invalidate_dirs_created();
729                Ok(())
730            }
731            Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
732                warn!(name = %self.name, "delete directory: not found, ignored");
733                Ok(())
734            }
735            Err(e) => Err(e.into()),
736        }
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    // --- Cache semantics ---
745
746    #[test]
747    fn cache_eviction_clears_all_entries_when_at_capacity() {
748        // Fill the cache to exactly MAX_CACHED_FDS, then request one new path.
749        // The eviction must drop all 256 entries and leave only the new one.
750        let tmp = tempfile::tempdir().unwrap();
751        let mut ctx = ApplyContext::new(tmp.path());
752
753        for i in 0..MAX_CACHED_FDS {
754            ctx.open_cached(&tmp.path().join(format!("{i}.dat")))
755                .unwrap();
756        }
757        assert_eq!(
758            ctx.file_cache.len(),
759            MAX_CACHED_FDS,
760            "cache should be full before triggering eviction"
761        );
762
763        ctx.open_cached(&tmp.path().join("new.dat")).unwrap();
764        assert_eq!(
765            ctx.file_cache.len(),
766            1,
767            "eviction must clear all entries and leave only the new handle"
768        );
769    }
770
771    #[test]
772    fn cache_hit_does_not_trigger_eviction_when_full() {
773        // With a full cache, requesting an *already-cached* path must NOT evict
774        // — the size stays at MAX_CACHED_FDS.
775        let tmp = tempfile::tempdir().unwrap();
776        let mut ctx = ApplyContext::new(tmp.path());
777
778        for i in 0..MAX_CACHED_FDS {
779            ctx.open_cached(&tmp.path().join(format!("{i}.dat")))
780                .unwrap();
781        }
782        // Re-request the first path — it is already in the cache.
783        ctx.open_cached(&tmp.path().join("0.dat")).unwrap();
784        assert_eq!(
785            ctx.file_cache.len(),
786            MAX_CACHED_FDS,
787            "cache hit on a full cache must not evict anything"
788        );
789    }
790
791    #[test]
792    fn evict_cached_removes_only_target_path() {
793        let tmp = tempfile::tempdir().unwrap();
794        let mut ctx = ApplyContext::new(tmp.path());
795        let a = tmp.path().join("a.dat");
796        let b = tmp.path().join("b.dat");
797        ctx.open_cached(&a).unwrap();
798        ctx.open_cached(&b).unwrap();
799        assert_eq!(ctx.file_cache.len(), 2);
800
801        ctx.evict_cached(&a).unwrap();
802        assert_eq!(
803            ctx.file_cache.len(),
804            1,
805            "evict_cached must remove exactly the targeted path"
806        );
807        assert!(
808            ctx.file_cache.contains_key(&b),
809            "evict_cached must not remove the other path"
810        );
811    }
812
813    #[test]
814    fn evict_cached_is_noop_for_absent_path() {
815        let tmp = tempfile::tempdir().unwrap();
816        let mut ctx = ApplyContext::new(tmp.path());
817        ctx.open_cached(&tmp.path().join("a.dat")).unwrap();
818        // Evicting a path that was never inserted must not panic or change the cache.
819        ctx.evict_cached(&tmp.path().join("nonexistent.dat"))
820            .unwrap();
821        assert_eq!(ctx.file_cache.len(), 1);
822    }
823
824    #[test]
825    fn clear_file_cache_removes_all_handles() {
826        let tmp = tempfile::tempdir().unwrap();
827        let mut ctx = ApplyContext::new(tmp.path());
828        ctx.open_cached(&tmp.path().join("a.dat")).unwrap();
829        ctx.open_cached(&tmp.path().join("b.dat")).unwrap();
830        assert_eq!(ctx.file_cache.len(), 2);
831        ctx.clear_file_cache().unwrap();
832        assert_eq!(
833            ctx.file_cache.len(),
834            0,
835            "clear_file_cache must empty the cache"
836        );
837    }
838
839    // --- Builder accessors ---
840
841    #[test]
842    fn game_path_returns_install_root_unchanged() {
843        let tmp = tempfile::tempdir().unwrap();
844        let ctx = ApplyContext::new(tmp.path());
845        assert_eq!(
846            ctx.game_path(),
847            tmp.path(),
848            "game_path() must return exactly the path passed to new()"
849        );
850    }
851
852    #[test]
853    fn default_platform_is_win32() {
854        let ctx = ApplyContext::new("/irrelevant");
855        assert_eq!(
856            ctx.platform(),
857            Platform::Win32,
858            "default platform must be Win32"
859        );
860    }
861
862    #[test]
863    fn with_platform_overrides_default() {
864        let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
865        assert_eq!(
866            ctx.platform(),
867            Platform::Ps4,
868            "with_platform must override the Win32 default"
869        );
870    }
871
872    #[test]
873    fn default_ignore_missing_is_false() {
874        let ctx = ApplyContext::new("/irrelevant");
875        assert!(
876            !ctx.ignore_missing(),
877            "ignore_missing must default to false"
878        );
879    }
880
881    #[test]
882    fn with_ignore_missing_toggles_flag_both_ways() {
883        let ctx = ApplyContext::new("/irrelevant").with_ignore_missing(true);
884        assert!(
885            ctx.ignore_missing(),
886            "with_ignore_missing(true) must set the flag"
887        );
888        let ctx = ctx.with_ignore_missing(false);
889        assert!(
890            !ctx.ignore_missing(),
891            "with_ignore_missing(false) must clear the flag"
892        );
893    }
894
895    #[test]
896    fn default_ignore_old_mismatch_is_false() {
897        let ctx = ApplyContext::new("/irrelevant");
898        assert!(
899            !ctx.ignore_old_mismatch(),
900            "ignore_old_mismatch must default to false"
901        );
902    }
903
904    #[test]
905    fn with_ignore_old_mismatch_toggles_flag_both_ways() {
906        let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
907        assert!(
908            ctx.ignore_old_mismatch(),
909            "with_ignore_old_mismatch(true) must set the flag"
910        );
911        let ctx = ctx.with_ignore_old_mismatch(false);
912        assert!(
913            !ctx.ignore_old_mismatch(),
914            "with_ignore_old_mismatch(false) must clear the flag"
915        );
916    }
917
918    // --- with_observer ---
919    //
920    // The end-to-end "default context uses NoopObserver" check lives in
921    // `src/lib.rs` as `default_no_observer_apply_succeeds_as_before`, which
922    // exercises the full `apply_to` driver path; duplicating it here would
923    // only re-test the same behaviour through a slightly different lens.
924
925    // --- BufWriter cache ---
926
927    #[test]
928    fn buffered_writes_are_invisible_before_flush() {
929        // The whole point of wrapping cached handles in a BufWriter is to
930        // hold small writes in user-space memory until enough have queued
931        // up. Lock that down: a 1-byte write must not be visible on disk
932        // until flush() is called.
933        use std::io::Write;
934
935        let tmp = tempfile::tempdir().unwrap();
936        let mut ctx = ApplyContext::new(tmp.path());
937        let path = tmp.path().join("buffered.dat");
938
939        let writer = ctx.open_cached(&path).unwrap();
940        writer.write_all(&[0xAB]).unwrap();
941
942        // File exists (open_cached opened it with create=true) but is empty
943        // — the byte is sitting in the BufWriter, not on disk.
944        assert_eq!(
945            std::fs::metadata(&path).unwrap().len(),
946            0,
947            "buffered write must not reach disk before flush"
948        );
949
950        ctx.flush().unwrap();
951        assert_eq!(
952            std::fs::read(&path).unwrap(),
953            vec![0xAB],
954            "flush must drain the buffer to disk"
955        );
956    }
957
958    #[test]
959    fn flush_keeps_handles_in_cache() {
960        // flush() is a durability checkpoint, not an eviction — handles
961        // must survive so subsequent chunks targeting the same file reuse
962        // them rather than reopening.
963        let tmp = tempfile::tempdir().unwrap();
964        let mut ctx = ApplyContext::new(tmp.path());
965        ctx.open_cached(&tmp.path().join("a.dat")).unwrap();
966        ctx.open_cached(&tmp.path().join("b.dat")).unwrap();
967        assert_eq!(ctx.file_cache.len(), 2);
968
969        ctx.flush().unwrap();
970        assert_eq!(
971            ctx.file_cache.len(),
972            2,
973            "flush must not drop cached handles"
974        );
975    }
976
977    #[test]
978    fn evict_cached_flushes_pending_writes_to_disk() {
979        // evict_cached must flush before dropping — otherwise buffered
980        // writes against the about-to-be-closed handle would be silently
981        // discarded by BufWriter::drop's error-swallowing flush.
982        use std::io::Write;
983
984        let tmp = tempfile::tempdir().unwrap();
985        let mut ctx = ApplyContext::new(tmp.path());
986        let path = tmp.path().join("evict.dat");
987
988        let writer = ctx.open_cached(&path).unwrap();
989        writer.write_all(b"queued").unwrap();
990        assert_eq!(
991            std::fs::metadata(&path).unwrap().len(),
992            0,
993            "pre-condition: write is buffered, not on disk"
994        );
995
996        ctx.evict_cached(&path).unwrap();
997        assert_eq!(
998            std::fs::read(&path).unwrap(),
999            b"queued",
1000            "evict_cached must flush before closing the handle"
1001        );
1002        assert!(
1003            !ctx.file_cache.contains_key(&path),
1004            "evict_cached must also remove the entry"
1005        );
1006    }
1007
1008    #[test]
1009    fn clear_file_cache_flushes_every_pending_write() {
1010        // clear_file_cache must flush every buffered writer before dropping
1011        // — RemoveAll relies on this to avoid losing pending data against
1012        // the about-to-be-unlinked files.
1013        use std::io::Write;
1014
1015        let tmp = tempfile::tempdir().unwrap();
1016        let mut ctx = ApplyContext::new(tmp.path());
1017        let a = tmp.path().join("a.dat");
1018        let b = tmp.path().join("b.dat");
1019
1020        ctx.open_cached(&a).unwrap().write_all(b"AA").unwrap();
1021        ctx.open_cached(&b).unwrap().write_all(b"BB").unwrap();
1022
1023        ctx.clear_file_cache().unwrap();
1024
1025        assert_eq!(std::fs::read(&a).unwrap(), b"AA");
1026        assert_eq!(std::fs::read(&b).unwrap(), b"BB");
1027        assert!(ctx.file_cache.is_empty(), "cache must be empty after clear");
1028    }
1029
1030    // --- Debug impl ---
1031
1032    #[test]
1033    fn apply_context_debug_renders_all_fields() {
1034        // ApplyContext can't derive Debug because Box<dyn ApplyObserver> doesn't
1035        // implement it; the hand-written impl substitutes a placeholder for the
1036        // observer. Lock down the rendered shape so future refactors don't
1037        // accidentally drop fields (and so the impl itself is exercised by tests).
1038        let tmp = tempfile::tempdir().unwrap();
1039        let ctx = ApplyContext::new(tmp.path())
1040            .with_platform(Platform::Ps4)
1041            .with_ignore_missing(true);
1042
1043        let rendered = format!("{ctx:?}");
1044        for needle in [
1045            "ApplyContext",
1046            "game_path",
1047            "platform",
1048            "Ps4",
1049            "ignore_missing",
1050            "true",
1051            "ignore_old_mismatch",
1052            "file_cache_len",
1053            "path_cache_len",
1054            "decompressor",
1055            "<flate2::Decompress>",
1056            "observer",
1057            "<dyn ApplyObserver>",
1058        ] {
1059            assert!(
1060                rendered.contains(needle),
1061                "Debug output must mention {needle:?}; got: {rendered}"
1062            );
1063        }
1064    }
1065
1066    // --- DeleteDirectory happy path ---
1067
1068    #[test]
1069    fn delete_directory_success_removes_existing_dir() {
1070        // Exercises the Ok(()) trace+return arm of DeleteDirectory::apply
1071        // (previously only the ignore_missing and propagate-error arms were
1072        // covered).
1073        let tmp = tempfile::tempdir().unwrap();
1074        let target = tmp.path().join("to_remove");
1075        std::fs::create_dir(&target).unwrap();
1076        assert!(target.is_dir(), "pre-condition: directory must exist");
1077
1078        let mut ctx = ApplyContext::new(tmp.path());
1079        DeleteDirectory {
1080            name: "to_remove".into(),
1081        }
1082        .apply(&mut ctx)
1083        .expect("delete on an existing directory must succeed");
1084
1085        assert!(!target.exists(), "directory must be removed");
1086    }
1087
1088    // --- ensure_dir_all cache-hit branch ---
1089
1090    #[test]
1091    fn ensure_dir_all_cache_hit_returns_early_without_syscall() {
1092        // The second call for the same path must take the early-return branch
1093        // at line 521 (`return Ok(())`).  We confirm this is hit by pre-seeding
1094        // `dirs_created` and then calling `ensure_dir_all` for a path that does
1095        // NOT actually exist on disk — if the cache-miss branch ran it would
1096        // call `create_dir_all` and either succeed (masking the bug) or fail.
1097        let tmp = tempfile::tempdir().unwrap();
1098        let mut ctx = ApplyContext::new(tmp.path());
1099
1100        let path = tmp.path().join("cached_dir");
1101        // First call: misses the cache, creates the directory on disk, inserts.
1102        ctx.ensure_dir_all(&path).unwrap();
1103        assert!(path.is_dir(), "first call must create the directory");
1104        assert_eq!(
1105            ctx.dirs_created.len(),
1106            1,
1107            "path must be cached after first call"
1108        );
1109
1110        // Remove the directory so a second real `create_dir_all` would see it
1111        // gone — if the cache-hit branch is NOT taken, the syscall would still
1112        // succeed (create_dir_all is idempotent for missing dirs), so instead
1113        // we verify the set length stays at 1, not 2.
1114        let p2 = tmp.path().join("cached_dir");
1115        ctx.ensure_dir_all(&p2).unwrap();
1116        assert_eq!(
1117            ctx.dirs_created.len(),
1118            1,
1119            "cache hit must not re-insert the path (set length must stay 1)"
1120        );
1121    }
1122
1123    // --- drain_and_flush error branch ---
1124
1125    #[test]
1126    fn drain_and_flush_error_propagates_first_io_error() {
1127        // Trigger the `Some(e) => Err(e)` arm in `drain_and_flush`.
1128        //
1129        // `/dev/full` always returns ENOSPC on write — use it as the backing
1130        // file so the BufWriter's flush (which actually calls write(2)) fails.
1131        // We open `/dev/full` directly and inject the handle into `file_cache`
1132        // via the `pub(crate)` field, bypassing `open_cached` which uses
1133        // `create=true` (incompatible with a character device).
1134        use std::io::Write;
1135
1136        let dev_full = std::path::PathBuf::from("/dev/full");
1137        if !dev_full.exists() {
1138            // /dev/full is Linux-specific; skip on platforms that lack it.
1139            return;
1140        }
1141
1142        let file = OpenOptions::new()
1143            .write(true)
1144            .open(&dev_full)
1145            .expect("/dev/full must be openable for writing");
1146
1147        let mut ctx = ApplyContext::new("/irrelevant");
1148        let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
1149        // Write into the BufWriter's user-space buffer — this succeeds because
1150        // BufWriter holds the data in RAM.  The write only reaches /dev/full
1151        // (and fails with ENOSPC) when the buffer is flushed.
1152        writer.write_all(&[0xAB; 128]).unwrap();
1153        ctx.file_cache.insert(dev_full.clone(), writer);
1154
1155        let result = ctx.clear_file_cache();
1156
1157        assert!(
1158            result.is_err(),
1159            "drain_and_flush must propagate the ENOSPC error from /dev/full"
1160        );
1161        assert!(
1162            ctx.file_cache.is_empty(),
1163            "cache must be drained even when flush fails"
1164        );
1165    }
1166
1167    // --- flush error branch ---
1168
1169    #[test]
1170    fn flush_error_propagates_first_io_error() {
1171        // Trigger the `Some(e) => Err(e)` arm in `flush` using the same
1172        // `/dev/full` trick as `drain_and_flush_error_propagates_first_io_error`.
1173        // `flush` keeps handles in the cache (unlike `drain_and_flush`), so we
1174        // assert the cache still contains the entry after the failed flush.
1175        use std::io::Write;
1176
1177        let dev_full = std::path::PathBuf::from("/dev/full");
1178        if !dev_full.exists() {
1179            return;
1180        }
1181
1182        let file = OpenOptions::new()
1183            .write(true)
1184            .open(&dev_full)
1185            .expect("/dev/full must be openable for writing");
1186
1187        let mut ctx = ApplyContext::new("/irrelevant");
1188        let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
1189        writer.write_all(&[0xCD; 128]).unwrap();
1190        ctx.file_cache.insert(dev_full.clone(), writer);
1191
1192        let result = ctx.flush();
1193
1194        assert!(
1195            result.is_err(),
1196            "flush must propagate the ENOSPC error from /dev/full"
1197        );
1198        assert_eq!(
1199            ctx.file_cache.len(),
1200            1,
1201            "flush must NOT evict handles — only drain_and_flush does that"
1202        );
1203    }
1204}