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;
137use std::fs::{File, OpenOptions};
138use std::io::{BufWriter, Write};
139use std::path::{Path, PathBuf};
140use tracing::{debug, trace, warn};
141
142const MAX_CACHED_FDS: usize = 256;
143
144// 64 KiB chosen to comfortably absorb the largest single writes the SQPK
145// pipeline emits (1 KiB header chunks, DEFLATE block outputs up to ~32 KiB,
146// zero-fill runs from `write_zeros`) without splitting them. Memory ceiling
147// at the FD cap is 256 * 64 KiB = 16 MiB, which is trivial for a desktop
148// launcher and the only realistic consumer of this library.
149const WRITE_BUFFER_CAPACITY: usize = 64 * 1024;
150
151/// Apply-time state: install root, target platform, flag toggles, and the
152/// internal file-handle cache used by SQPK writers.
153///
154/// # Construction
155///
156/// Build with [`ApplyContext::new`], then chain the `with_*` builder methods
157/// to override defaults:
158///
159/// ```
160/// use zipatch_rs::{ApplyContext, Platform};
161///
162/// let ctx = ApplyContext::new("/opt/ffxiv/game")
163/// .with_platform(Platform::Win32)
164/// .with_ignore_missing(true);
165///
166/// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
167/// assert_eq!(ctx.platform(), Platform::Win32);
168/// assert!(ctx.ignore_missing());
169/// ```
170///
171/// # Platform mutation
172///
173/// The platform defaults to [`Platform::Win32`]. If the patch stream contains
174/// a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk, applying it overwrites
175/// [`ApplyContext::platform`] with the platform declared in the chunk. This is
176/// the normal case: real FFXIV patches begin with a `TargetInfo` chunk that
177/// pins the platform, so the default is rarely used in practice.
178///
179/// Set the platform explicitly with [`ApplyContext::with_platform`] when you
180/// know the target in advance or are processing a synthetic patch.
181///
182/// # Flag mutation
183///
184/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
185/// can also be overwritten mid-stream by `ApplyOption` chunks embedded in the
186/// patch file. Set initial values with the `with_ignore_*` builder methods.
187///
188/// # File-handle cache
189///
190/// Internally, `ApplyContext` maintains a bounded map of open file handles
191/// keyed by absolute path. The cache is an optimisation: a patch that writes
192/// many chunks into the same `.dat` file re-uses a single handle rather than
193/// opening and closing the file for every write.
194///
195/// The cache is capped at 256 entries. When that limit is reached and a new
196/// path is needed, **all** entries are evicted at once. Handles are also
197/// evicted explicitly before deleting a file (see `DeleteFile`) and before a
198/// `RemoveAll` bulk operation.
199pub struct ApplyContext {
200 pub(crate) game_path: PathBuf,
201 /// The target platform. Defaults to `Win32`. Note: `SqpkTargetInfo` chunks
202 /// in the patch stream will override this value when applied.
203 pub(crate) platform: Platform,
204 pub(crate) ignore_missing: bool,
205 pub(crate) ignore_old_mismatch: bool,
206 // Capped at MAX_CACHED_FDS entries; cleared wholesale when full to bound open FD count.
207 // Each handle is wrapped in a `BufWriter` to coalesce the many small
208 // writes the SQPK pipeline emits (block headers, zero-fill runs) into a
209 // smaller number of `write(2)` syscalls. `BufWriter` implements both
210 // `Write` and `Seek`, so apply functions interact with it transparently;
211 // operations that need the raw `File` (currently only `set_len`) call
212 // `get_mut()` after an explicit `flush()`.
213 //
214 // `pub(crate)` so the SQPK `AddFile` apply loop can split-borrow it next
215 // to `observer` for between-block cancellation polling.
216 pub(crate) file_cache: HashMap<PathBuf, BufWriter<File>>,
217 // Observer for progress/cancellation. Defaults to a no-op; replaced by
218 // `with_observer`. Stored as a boxed trait object so that the public
219 // `ApplyContext` type stays non-generic and remains nameable in downstream
220 // signatures (`gaveloc-patcher` passes contexts across module boundaries).
221 pub(crate) observer: Box<dyn ApplyObserver>,
222}
223
224// Hand-written so we don't need `ApplyObserver: Debug` as a supertrait —
225// adding supertraits is a SemVer break, and forcing every user observer to
226// implement `Debug` would be a needless ergonomic tax. The observer field
227// is summarised as an opaque placeholder.
228impl std::fmt::Debug for ApplyContext {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 f.debug_struct("ApplyContext")
231 .field("game_path", &self.game_path)
232 .field("platform", &self.platform)
233 .field("ignore_missing", &self.ignore_missing)
234 .field("ignore_old_mismatch", &self.ignore_old_mismatch)
235 .field("file_cache_len", &self.file_cache.len())
236 .field("observer", &"<dyn ApplyObserver>")
237 .finish()
238 }
239}
240
241impl ApplyContext {
242 /// Create a context targeting the given game install directory.
243 ///
244 /// Defaults: platform is [`Platform::Win32`], both ignore-flags are off.
245 ///
246 /// Use the `with_*` builder methods to change these defaults before
247 /// applying the first chunk.
248 ///
249 /// # Example
250 ///
251 /// ```
252 /// use zipatch_rs::ApplyContext;
253 ///
254 /// let ctx = ApplyContext::new("/opt/ffxiv/game");
255 /// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
256 /// ```
257 pub fn new(game_path: impl Into<PathBuf>) -> Self {
258 Self {
259 game_path: game_path.into(),
260 platform: Platform::Win32,
261 ignore_missing: false,
262 ignore_old_mismatch: false,
263 file_cache: HashMap::new(),
264 observer: Box::new(NoopObserver),
265 }
266 }
267
268 /// Returns the game installation directory.
269 ///
270 /// All file paths produced during apply are relative to this root.
271 #[must_use]
272 pub fn game_path(&self) -> &std::path::Path {
273 &self.game_path
274 }
275
276 /// Returns the current target platform.
277 ///
278 /// This value may change during apply if the patch stream contains a
279 /// [`crate::chunk::sqpk::SqpkTargetInfo`] chunk.
280 #[must_use]
281 pub fn platform(&self) -> Platform {
282 self.platform
283 }
284
285 /// Returns whether missing files are silently ignored during apply.
286 ///
287 /// When `true`, operations that target a file that does not exist log a
288 /// warning instead of returning an error. This flag may be overwritten
289 /// mid-stream by an `ApplyOption` chunk.
290 #[must_use]
291 pub fn ignore_missing(&self) -> bool {
292 self.ignore_missing
293 }
294
295 /// Returns whether old-data mismatches are silently ignored during apply.
296 ///
297 /// When `true`, apply operations that detect a checksum or data mismatch
298 /// against the existing on-disk content proceed without error. This flag
299 /// may be overwritten mid-stream by an `ApplyOption` chunk.
300 #[must_use]
301 pub fn ignore_old_mismatch(&self) -> bool {
302 self.ignore_old_mismatch
303 }
304
305 /// Sets the target platform. Defaults to [`Platform::Win32`].
306 ///
307 /// The platform determines the directory suffix used when resolving `SqPack`
308 /// file paths (`win32`, `ps3`, or `ps4`).
309 ///
310 /// Note: a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk encountered during
311 /// apply will override this value.
312 #[must_use]
313 pub fn with_platform(mut self, platform: Platform) -> Self {
314 self.platform = platform;
315 self
316 }
317
318 /// Silently ignore missing files instead of returning an error during apply.
319 ///
320 /// When `false` (the default), any apply operation that cannot find its
321 /// target file returns [`crate::ZiPatchError::Io`] with kind
322 /// [`std::io::ErrorKind::NotFound`].
323 ///
324 /// When `true`, those failures are demoted to `warn!`-level tracing events.
325 #[must_use]
326 pub fn with_ignore_missing(mut self, v: bool) -> Self {
327 self.ignore_missing = v;
328 self
329 }
330
331 /// Silently ignore old-data mismatches instead of returning an error during apply.
332 ///
333 /// When `false` (the default), an apply operation that detects that the
334 /// on-disk data does not match the expected "before" state returns an error.
335 ///
336 /// When `true`, the mismatch is logged at `warn!` level and the operation
337 /// continues.
338 #[must_use]
339 pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
340 self.ignore_old_mismatch = v;
341 self
342 }
343
344 /// Install an [`ApplyObserver`] for progress reporting and cancellation.
345 ///
346 /// The observer's [`on_chunk_applied`](ApplyObserver::on_chunk_applied)
347 /// method is called after each top-level chunk; its
348 /// [`should_cancel`](ApplyObserver::should_cancel) method is polled
349 /// inside long-running chunks (currently the
350 /// [`SqpkFile`](crate::chunk::sqpk::SqpkFile) block-write loop) so that
351 /// cancellation is observable mid-chunk on multi-hundred-MB payloads.
352 ///
353 /// Returning [`ControlFlow::Break`](std::ops::ControlFlow::Break) from
354 /// `on_chunk_applied`, or `true` from `should_cancel`, aborts the apply
355 /// loop with [`crate::ZiPatchError::Cancelled`]. Filesystem changes already
356 /// applied are **not** rolled back.
357 ///
358 /// The default observer is a no-op: parsing-only consumers and existing
359 /// callers that never call `with_observer` pay nothing.
360 ///
361 /// # `'static` bound
362 ///
363 /// The `'static` bound follows from [`ApplyContext`] storing the
364 /// observer in a `Box<dyn ApplyObserver>` — a trait object whose
365 /// lifetime parameter defaults to `'static`. To pass an observer that
366 /// holds a channel sender or similar handle, wrap it in
367 /// `Arc<Mutex<...>>` (which is `'static`) or implement
368 /// [`ApplyObserver`] on a struct that owns the handle directly.
369 #[must_use]
370 pub fn with_observer(mut self, observer: impl ApplyObserver + 'static) -> Self {
371 self.observer = Box::new(observer);
372 self
373 }
374
375 /// Return a writable handle to `path`, opening it if not already cached.
376 ///
377 /// If the cache has reached 256 entries and `path` is not already present,
378 /// all cached handles are flushed and dropped before opening the new one.
379 /// The file is opened with `write=true, create=true, truncate=false` and
380 /// wrapped in a [`BufWriter`] with a 64 KiB buffer.
381 ///
382 /// # Errors
383 ///
384 /// Returns `std::io::Error` if the file cannot be opened, or if the
385 /// flush triggered by cache-full eviction fails on any of the dropped
386 /// handles (e.g. disk full while persisting buffered writes).
387 pub(crate) fn open_cached(&mut self, path: PathBuf) -> std::io::Result<&mut BufWriter<File>> {
388 use std::collections::hash_map::Entry;
389 // Crude eviction: flush + clear all when full to bound open FD count.
390 // Flushing first surfaces write errors (disk full, quota) that would
391 // otherwise be silently swallowed by `BufWriter::drop`.
392 if self.file_cache.len() >= MAX_CACHED_FDS && !self.file_cache.contains_key(&path) {
393 self.drain_and_flush()?;
394 }
395 match self.file_cache.entry(path) {
396 Entry::Occupied(e) => Ok(e.into_mut()),
397 Entry::Vacant(e) => {
398 let file = OpenOptions::new()
399 .write(true)
400 .create(true)
401 .truncate(false)
402 .open(e.key())?;
403 Ok(e.insert(BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file)))
404 }
405 }
406 }
407
408 /// Flush and remove the cached handle for `path`, if any.
409 ///
410 /// Called before a file is deleted so that the OS handle is closed before
411 /// the unlink (no-op on Linux, required on Windows where an open handle
412 /// prevents deletion). The buffered writes are flushed first so that any
413 /// pending data lands on disk before the close.
414 ///
415 /// If no handle is cached for `path`, returns `Ok(())`.
416 ///
417 /// # Errors
418 ///
419 /// Returns `std::io::Error` if flushing the buffered writes fails.
420 pub(crate) fn evict_cached(&mut self, path: &Path) -> std::io::Result<()> {
421 if let Some(mut writer) = self.file_cache.remove(path) {
422 writer.flush()?;
423 }
424 Ok(())
425 }
426
427 /// Flush and drop every cached file handle.
428 ///
429 /// Called by `RemoveAll` before bulk-deleting an expansion folder's files
430 /// to ensure no lingering open handles survive into the deletion window,
431 /// and by `flush()` to provide a public durability checkpoint.
432 ///
433 /// # Errors
434 ///
435 /// Returns the first `std::io::Error` produced by any handle's flush.
436 /// Remaining handles are still flushed and cleared even if an earlier
437 /// one failed; the cache is always empty on return.
438 pub(crate) fn clear_file_cache(&mut self) -> std::io::Result<()> {
439 self.drain_and_flush()
440 }
441
442 /// Flush every cached writer's buffer, then drain the cache.
443 ///
444 /// Used by both [`Self::clear_file_cache`] and the cache-full path inside
445 /// [`Self::open_cached`]. Distinct from [`Self::flush`], which flushes in
446 /// place without dropping the handles.
447 fn drain_and_flush(&mut self) -> std::io::Result<()> {
448 let mut first_err: Option<std::io::Error> = None;
449 for (_, mut writer) in self.file_cache.drain() {
450 if let Err(e) = writer.flush() {
451 first_err.get_or_insert(e);
452 }
453 }
454 match first_err {
455 Some(e) => Err(e),
456 None => Ok(()),
457 }
458 }
459
460 /// Flush every buffered write through to the operating system.
461 ///
462 /// Forces any data still sitting in [`BufWriter`] buffers (one per cached
463 /// `SqPack` file) to be written via `write(2)`. Open handles are retained
464 /// — this is a durability checkpoint, not a cache eviction. Subsequent
465 /// chunks targeting the same files reuse the existing handles.
466 ///
467 /// `apply_to` calls this automatically before returning so successful
468 /// completion of a patch implies all writes have reached the OS. Explicit
469 /// calls are useful when applying chunks one at a time via
470 /// [`Apply::apply`] and reading the resulting file state in between, or
471 /// when implementing a custom apply driver that wants intermediate
472 /// commit points.
473 ///
474 /// This is **not** `fsync`. Data is handed off to the OS but may still
475 /// reside in the page cache; survival across a crash requires
476 /// `File::sync_all` on the underlying handles, which this method does
477 /// not perform.
478 ///
479 /// # Errors
480 ///
481 /// Returns the first `std::io::Error` produced by any writer's flush.
482 /// Remaining writers are still attempted even if an earlier one failed.
483 pub fn flush(&mut self) -> std::io::Result<()> {
484 let mut first_err: Option<std::io::Error> = None;
485 for writer in self.file_cache.values_mut() {
486 if let Err(e) = writer.flush() {
487 first_err.get_or_insert(e);
488 }
489 }
490 match first_err {
491 Some(e) => Err(e),
492 None => Ok(()),
493 }
494 }
495}
496
497/// Applies a parsed chunk to the filesystem via an [`ApplyContext`].
498///
499/// Every top-level [`Chunk`] variant and every
500/// [`crate::chunk::sqpk::SqpkCommand`] variant implements this trait. The
501/// usual entry point is [`Chunk::apply`], which dispatches to the appropriate
502/// implementation.
503///
504/// # Ordering
505///
506/// Chunks must be applied in the order they appear in the patch stream.
507/// The format is a sequential log; later chunks may depend on state produced
508/// by earlier ones.
509///
510/// # Idempotency
511///
512/// Apply operations are **not idempotent** in general. Write operations are
513/// idempotent only if the data payload is identical to what is already on
514/// disk. Destructive operations (`RemoveAll`, `DeleteFile`, `DeleteDirectory`)
515/// are not repeatable without error unless `ignore_missing` is set.
516///
517/// # Errors
518///
519/// Returns [`crate::ZiPatchError`] on any filesystem or data error. The error
520/// is not recovered from; the caller should treat it as fatal for the current
521/// apply session.
522///
523/// # Panics
524///
525/// Implementations do not panic under normal operation. Panics would indicate
526/// a bug in the parsing layer (e.g. a chunk with fields that violate internal
527/// invariants established during parsing).
528pub trait Apply {
529 /// Apply this chunk to `ctx`.
530 ///
531 /// On success, any filesystem changes the chunk describes have been
532 /// written. On error, changes may be partial; the caller is responsible
533 /// for any recovery.
534 fn apply(&self, ctx: &mut ApplyContext) -> Result<()>;
535}
536
537/// Dispatch table for top-level chunk variants.
538///
539/// `FileHeader`, `ApplyFreeSpace`, and `EndOfFile` are metadata or structural
540/// chunks with no filesystem effect; they return `Ok(())` immediately.
541/// All other variants delegate to their specific `Apply` implementation.
542impl Apply for Chunk {
543 fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
544 match self {
545 Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
546 Chunk::Sqpk(c) => c.apply(ctx),
547 Chunk::ApplyOption(c) => c.apply(ctx),
548 Chunk::AddDirectory(c) => c.apply(ctx),
549 Chunk::DeleteDirectory(c) => c.apply(ctx),
550 }
551 }
552}
553
554/// Updates [`ApplyContext`] ignore-flags from the chunk payload.
555///
556/// `ApplyOption` chunks are embedded in the patch stream to toggle
557/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
558/// at specific points during apply. Applying this chunk mutates `ctx` in
559/// place; no filesystem I/O is performed.
560impl Apply for ApplyOption {
561 fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
562 debug!(kind = ?self.kind, value = self.value, "apply option");
563 match self.kind {
564 ApplyOptionKind::IgnoreMissing => ctx.ignore_missing = self.value,
565 ApplyOptionKind::IgnoreOldMismatch => ctx.ignore_old_mismatch = self.value,
566 }
567 Ok(())
568 }
569}
570
571/// Creates a directory under the game install root.
572///
573/// Equivalent to `fs::create_dir_all(game_path / name)`. Intermediate
574/// directories are created as needed; the call is idempotent if the directory
575/// already exists.
576///
577/// # Errors
578///
579/// Returns [`crate::ZiPatchError::Io`] if directory creation fails for any
580/// reason other than the directory already existing (e.g. a permission error
581/// or a non-directory file at the path).
582impl Apply for AddDirectory {
583 fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
584 trace!(name = %self.name, "create directory");
585 std::fs::create_dir_all(ctx.game_path.join(&self.name))?;
586 Ok(())
587 }
588}
589
590/// Removes a directory from the game install root.
591///
592/// The directory must be **empty**; `remove_dir` (not `remove_dir_all`) is
593/// used intentionally so that stale files inside the directory cause a visible
594/// error rather than silent data loss.
595///
596/// If the directory does not exist and [`ApplyContext::ignore_missing`] is
597/// `true`, the missing directory is logged at `warn!` level and `Ok(())` is
598/// returned. If `ignore_missing` is `false`, the `NotFound` I/O error is
599/// propagated.
600///
601/// # Errors
602///
603/// Returns [`crate::ZiPatchError::Io`] if the removal fails for any reason
604/// other than a missing directory with `ignore_missing = true`.
605impl Apply for DeleteDirectory {
606 fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
607 match std::fs::remove_dir(ctx.game_path.join(&self.name)) {
608 Ok(()) => {
609 trace!(name = %self.name, "delete directory");
610 Ok(())
611 }
612 Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
613 warn!(name = %self.name, "delete directory: not found, ignored");
614 Ok(())
615 }
616 Err(e) => Err(e.into()),
617 }
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 // --- Cache semantics ---
626
627 #[test]
628 fn cache_eviction_clears_all_entries_when_at_capacity() {
629 // Fill the cache to exactly MAX_CACHED_FDS, then request one new path.
630 // The eviction must drop all 256 entries and leave only the new one.
631 let tmp = tempfile::tempdir().unwrap();
632 let mut ctx = ApplyContext::new(tmp.path());
633
634 for i in 0..MAX_CACHED_FDS {
635 ctx.open_cached(tmp.path().join(format!("{i}.dat")))
636 .unwrap();
637 }
638 assert_eq!(
639 ctx.file_cache.len(),
640 MAX_CACHED_FDS,
641 "cache should be full before triggering eviction"
642 );
643
644 ctx.open_cached(tmp.path().join("new.dat")).unwrap();
645 assert_eq!(
646 ctx.file_cache.len(),
647 1,
648 "eviction must clear all entries and leave only the new handle"
649 );
650 }
651
652 #[test]
653 fn cache_hit_does_not_trigger_eviction_when_full() {
654 // With a full cache, requesting an *already-cached* path must NOT evict
655 // — the size stays at MAX_CACHED_FDS.
656 let tmp = tempfile::tempdir().unwrap();
657 let mut ctx = ApplyContext::new(tmp.path());
658
659 for i in 0..MAX_CACHED_FDS {
660 ctx.open_cached(tmp.path().join(format!("{i}.dat")))
661 .unwrap();
662 }
663 // Re-request the first path — it is already in the cache.
664 ctx.open_cached(tmp.path().join("0.dat")).unwrap();
665 assert_eq!(
666 ctx.file_cache.len(),
667 MAX_CACHED_FDS,
668 "cache hit on a full cache must not evict anything"
669 );
670 }
671
672 #[test]
673 fn evict_cached_removes_only_target_path() {
674 let tmp = tempfile::tempdir().unwrap();
675 let mut ctx = ApplyContext::new(tmp.path());
676 let a = tmp.path().join("a.dat");
677 let b = tmp.path().join("b.dat");
678 ctx.open_cached(a.clone()).unwrap();
679 ctx.open_cached(b.clone()).unwrap();
680 assert_eq!(ctx.file_cache.len(), 2);
681
682 ctx.evict_cached(&a).unwrap();
683 assert_eq!(
684 ctx.file_cache.len(),
685 1,
686 "evict_cached must remove exactly the targeted path"
687 );
688 assert!(
689 ctx.file_cache.contains_key(&b),
690 "evict_cached must not remove the other path"
691 );
692 }
693
694 #[test]
695 fn evict_cached_is_noop_for_absent_path() {
696 let tmp = tempfile::tempdir().unwrap();
697 let mut ctx = ApplyContext::new(tmp.path());
698 ctx.open_cached(tmp.path().join("a.dat")).unwrap();
699 // Evicting a path that was never inserted must not panic or change the cache.
700 ctx.evict_cached(&tmp.path().join("nonexistent.dat"))
701 .unwrap();
702 assert_eq!(ctx.file_cache.len(), 1);
703 }
704
705 #[test]
706 fn clear_file_cache_removes_all_handles() {
707 let tmp = tempfile::tempdir().unwrap();
708 let mut ctx = ApplyContext::new(tmp.path());
709 ctx.open_cached(tmp.path().join("a.dat")).unwrap();
710 ctx.open_cached(tmp.path().join("b.dat")).unwrap();
711 assert_eq!(ctx.file_cache.len(), 2);
712 ctx.clear_file_cache().unwrap();
713 assert_eq!(
714 ctx.file_cache.len(),
715 0,
716 "clear_file_cache must empty the cache"
717 );
718 }
719
720 // --- Builder accessors ---
721
722 #[test]
723 fn game_path_returns_install_root_unchanged() {
724 let tmp = tempfile::tempdir().unwrap();
725 let ctx = ApplyContext::new(tmp.path());
726 assert_eq!(
727 ctx.game_path(),
728 tmp.path(),
729 "game_path() must return exactly the path passed to new()"
730 );
731 }
732
733 #[test]
734 fn default_platform_is_win32() {
735 let ctx = ApplyContext::new("/irrelevant");
736 assert_eq!(
737 ctx.platform(),
738 Platform::Win32,
739 "default platform must be Win32"
740 );
741 }
742
743 #[test]
744 fn with_platform_overrides_default() {
745 let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
746 assert_eq!(
747 ctx.platform(),
748 Platform::Ps4,
749 "with_platform must override the Win32 default"
750 );
751 }
752
753 #[test]
754 fn default_ignore_missing_is_false() {
755 let ctx = ApplyContext::new("/irrelevant");
756 assert!(
757 !ctx.ignore_missing(),
758 "ignore_missing must default to false"
759 );
760 }
761
762 #[test]
763 fn with_ignore_missing_toggles_flag_both_ways() {
764 let ctx = ApplyContext::new("/irrelevant").with_ignore_missing(true);
765 assert!(
766 ctx.ignore_missing(),
767 "with_ignore_missing(true) must set the flag"
768 );
769 let ctx = ctx.with_ignore_missing(false);
770 assert!(
771 !ctx.ignore_missing(),
772 "with_ignore_missing(false) must clear the flag"
773 );
774 }
775
776 #[test]
777 fn default_ignore_old_mismatch_is_false() {
778 let ctx = ApplyContext::new("/irrelevant");
779 assert!(
780 !ctx.ignore_old_mismatch(),
781 "ignore_old_mismatch must default to false"
782 );
783 }
784
785 #[test]
786 fn with_ignore_old_mismatch_toggles_flag_both_ways() {
787 let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
788 assert!(
789 ctx.ignore_old_mismatch(),
790 "with_ignore_old_mismatch(true) must set the flag"
791 );
792 let ctx = ctx.with_ignore_old_mismatch(false);
793 assert!(
794 !ctx.ignore_old_mismatch(),
795 "with_ignore_old_mismatch(false) must clear the flag"
796 );
797 }
798
799 // --- with_observer ---
800 //
801 // The end-to-end "default context uses NoopObserver" check lives in
802 // `src/lib.rs` as `default_no_observer_apply_succeeds_as_before`, which
803 // exercises the full `apply_to` driver path; duplicating it here would
804 // only re-test the same behaviour through a slightly different lens.
805
806 // --- BufWriter cache ---
807
808 #[test]
809 fn buffered_writes_are_invisible_before_flush() {
810 // The whole point of wrapping cached handles in a BufWriter is to
811 // hold small writes in user-space memory until enough have queued
812 // up. Lock that down: a 1-byte write must not be visible on disk
813 // until flush() is called.
814 use std::io::Write;
815
816 let tmp = tempfile::tempdir().unwrap();
817 let mut ctx = ApplyContext::new(tmp.path());
818 let path = tmp.path().join("buffered.dat");
819
820 let writer = ctx.open_cached(path.clone()).unwrap();
821 writer.write_all(&[0xAB]).unwrap();
822
823 // File exists (open_cached opened it with create=true) but is empty
824 // — the byte is sitting in the BufWriter, not on disk.
825 assert_eq!(
826 std::fs::metadata(&path).unwrap().len(),
827 0,
828 "buffered write must not reach disk before flush"
829 );
830
831 ctx.flush().unwrap();
832 assert_eq!(
833 std::fs::read(&path).unwrap(),
834 vec![0xAB],
835 "flush must drain the buffer to disk"
836 );
837 }
838
839 #[test]
840 fn flush_keeps_handles_in_cache() {
841 // flush() is a durability checkpoint, not an eviction — handles
842 // must survive so subsequent chunks targeting the same file reuse
843 // them rather than reopening.
844 let tmp = tempfile::tempdir().unwrap();
845 let mut ctx = ApplyContext::new(tmp.path());
846 ctx.open_cached(tmp.path().join("a.dat")).unwrap();
847 ctx.open_cached(tmp.path().join("b.dat")).unwrap();
848 assert_eq!(ctx.file_cache.len(), 2);
849
850 ctx.flush().unwrap();
851 assert_eq!(
852 ctx.file_cache.len(),
853 2,
854 "flush must not drop cached handles"
855 );
856 }
857
858 #[test]
859 fn evict_cached_flushes_pending_writes_to_disk() {
860 // evict_cached must flush before dropping — otherwise buffered
861 // writes against the about-to-be-closed handle would be silently
862 // discarded by BufWriter::drop's error-swallowing flush.
863 use std::io::Write;
864
865 let tmp = tempfile::tempdir().unwrap();
866 let mut ctx = ApplyContext::new(tmp.path());
867 let path = tmp.path().join("evict.dat");
868
869 let writer = ctx.open_cached(path.clone()).unwrap();
870 writer.write_all(b"queued").unwrap();
871 assert_eq!(
872 std::fs::metadata(&path).unwrap().len(),
873 0,
874 "pre-condition: write is buffered, not on disk"
875 );
876
877 ctx.evict_cached(&path).unwrap();
878 assert_eq!(
879 std::fs::read(&path).unwrap(),
880 b"queued",
881 "evict_cached must flush before closing the handle"
882 );
883 assert!(
884 !ctx.file_cache.contains_key(&path),
885 "evict_cached must also remove the entry"
886 );
887 }
888
889 #[test]
890 fn clear_file_cache_flushes_every_pending_write() {
891 // clear_file_cache must flush every buffered writer before dropping
892 // — RemoveAll relies on this to avoid losing pending data against
893 // the about-to-be-unlinked files.
894 use std::io::Write;
895
896 let tmp = tempfile::tempdir().unwrap();
897 let mut ctx = ApplyContext::new(tmp.path());
898 let a = tmp.path().join("a.dat");
899 let b = tmp.path().join("b.dat");
900
901 ctx.open_cached(a.clone())
902 .unwrap()
903 .write_all(b"AA")
904 .unwrap();
905 ctx.open_cached(b.clone())
906 .unwrap()
907 .write_all(b"BB")
908 .unwrap();
909
910 ctx.clear_file_cache().unwrap();
911
912 assert_eq!(std::fs::read(&a).unwrap(), b"AA");
913 assert_eq!(std::fs::read(&b).unwrap(), b"BB");
914 assert!(ctx.file_cache.is_empty(), "cache must be empty after clear");
915 }
916
917 // --- Debug impl ---
918
919 #[test]
920 fn apply_context_debug_renders_all_fields() {
921 // ApplyContext can't derive Debug because Box<dyn ApplyObserver> doesn't
922 // implement it; the hand-written impl substitutes a placeholder for the
923 // observer. Lock down the rendered shape so future refactors don't
924 // accidentally drop fields (and so the impl itself is exercised by tests).
925 let tmp = tempfile::tempdir().unwrap();
926 let ctx = ApplyContext::new(tmp.path())
927 .with_platform(Platform::Ps4)
928 .with_ignore_missing(true);
929
930 let rendered = format!("{ctx:?}");
931 for needle in [
932 "ApplyContext",
933 "game_path",
934 "platform",
935 "Ps4",
936 "ignore_missing",
937 "true",
938 "ignore_old_mismatch",
939 "file_cache_len",
940 "observer",
941 "<dyn ApplyObserver>",
942 ] {
943 assert!(
944 rendered.contains(needle),
945 "Debug output must mention {needle:?}; got: {rendered}"
946 );
947 }
948 }
949
950 // --- DeleteDirectory happy path ---
951
952 #[test]
953 fn delete_directory_success_removes_existing_dir() {
954 // Exercises the Ok(()) trace+return arm of DeleteDirectory::apply
955 // (previously only the ignore_missing and propagate-error arms were
956 // covered).
957 let tmp = tempfile::tempdir().unwrap();
958 let target = tmp.path().join("to_remove");
959 std::fs::create_dir(&target).unwrap();
960 assert!(target.is_dir(), "pre-condition: directory must exist");
961
962 let mut ctx = ApplyContext::new(tmp.path());
963 DeleteDirectory {
964 name: "to_remove".into(),
965 }
966 .apply(&mut ctx)
967 .expect("delete on an existing directory must succeed");
968
969 assert!(!target.exists(), "directory must be removed");
970 }
971}