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//! The cache is capped at 256 entries. When it is full and a new, uncached
46//! path is requested, **all** cached handles are closed at once before the
47//! new one is inserted. This is a simple eviction strategy — it trades some
48//! re-open overhead at eviction boundaries for bounded file-descriptor usage.
49//!
50//! Callers should not rely on cached handles persisting across arbitrary
51//! chunks. In particular, [`crate::chunk::sqpk::SqpkFile`]'s `RemoveAll`
52//! operation flushes all cached handles before bulk-deleting files to ensure
53//! no open handles survive into the deletion window (which matters on
54//! Windows). Similarly, `DeleteFile` evicts the cached handle for the
55//! specific path being removed.
56//!
57//! # Ordering and idempotency
58//!
59//! Chunks **must** be applied in stream order. The `ZiPatch` format is a
60//! sequential log, not a random-access manifest: later chunks may depend on
61//! filesystem state produced by earlier ones (e.g. an `AddFile` that writes
62//! blocks into a file created by an earlier `MakeDirTree` or `AddDirectory`).
63//!
64//! Apply operations are **not idempotent** in general. Seeking to an offset
65//! and writing data is idempotent if the same data is written, but
66//! `RemoveAll` is destructive and `DeleteFile` can fail if the file is
67//! already gone (unless `ignore_missing` is set). Partial application
68//! followed by a retry requires careful state tracking at a higher level;
69//! this crate does not provide transactional semantics.
70//!
71//! # Errors
72//!
73//! Every [`Apply::apply`] call returns [`crate::Result`], which is
74//! `Result<(), `[`crate::ZiPatchError`]`>`. Errors propagate from:
75//!
76//! - `std::io::Error` — filesystem failures (permissions, missing parent
77//!   directories, disk full, etc.) wrapped as [`crate::ZiPatchError::Io`].
78//! - [`crate::ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk
79//!   carried a negative `file_offset` that cannot be converted to a seek
80//!   position.
81//!
82//! On error, the apply operation aborts at the failing chunk. Any changes
83//! already applied to the filesystem are **not** rolled back.
84//!
85//! # Example
86//!
87//! ```no_run
88//! use std::fs::File;
89//! use zipatch_rs::{ApplyContext, ZiPatchReader};
90//!
91//! let patch_file = File::open("game.patch").unwrap();
92//! let mut ctx = ApplyContext::new("/opt/ffxiv/game");
93//!
94//! ZiPatchReader::new(patch_file)
95//!     .unwrap()
96//!     .apply_to(&mut ctx)
97//!     .unwrap();
98//! ```
99
100pub(crate) mod path;
101pub(crate) mod sqpk;
102
103use crate::Platform;
104use crate::Result;
105use crate::chunk::Chunk;
106use crate::chunk::adir::AddDirectory;
107use crate::chunk::aply::{ApplyOption, ApplyOptionKind};
108use crate::chunk::ddir::DeleteDirectory;
109use std::collections::HashMap;
110use std::fs::{File, OpenOptions};
111use std::path::{Path, PathBuf};
112use tracing::{debug, trace, warn};
113
114const MAX_CACHED_FDS: usize = 256;
115
116/// Apply-time state: install root, target platform, flag toggles, and the
117/// internal file-handle cache used by SQPK writers.
118///
119/// # Construction
120///
121/// Build with [`ApplyContext::new`], then chain the `with_*` builder methods
122/// to override defaults:
123///
124/// ```
125/// use zipatch_rs::{ApplyContext, Platform};
126///
127/// let ctx = ApplyContext::new("/opt/ffxiv/game")
128///     .with_platform(Platform::Win32)
129///     .with_ignore_missing(true);
130///
131/// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
132/// assert_eq!(ctx.platform(), Platform::Win32);
133/// assert!(ctx.ignore_missing());
134/// ```
135///
136/// # Platform mutation
137///
138/// The platform defaults to [`Platform::Win32`]. If the patch stream contains
139/// a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk, applying it overwrites
140/// [`ApplyContext::platform`] with the platform declared in the chunk. This is
141/// the normal case: real FFXIV patches begin with a `TargetInfo` chunk that
142/// pins the platform, so the default is rarely used in practice.
143///
144/// Set the platform explicitly with [`ApplyContext::with_platform`] when you
145/// know the target in advance or are processing a synthetic patch.
146///
147/// # Flag mutation
148///
149/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
150/// can also be overwritten mid-stream by `ApplyOption` chunks embedded in the
151/// patch file. Set initial values with the `with_ignore_*` builder methods.
152///
153/// # File-handle cache
154///
155/// Internally, `ApplyContext` maintains a bounded map of open file handles
156/// keyed by absolute path. The cache is an optimisation: a patch that writes
157/// many chunks into the same `.dat` file re-uses a single handle rather than
158/// opening and closing the file for every write.
159///
160/// The cache is capped at 256 entries. When that limit is reached and a new
161/// path is needed, **all** entries are evicted at once. Handles are also
162/// evicted explicitly before deleting a file (see `DeleteFile`) and before a
163/// `RemoveAll` bulk operation.
164pub struct ApplyContext {
165    pub(crate) game_path: PathBuf,
166    /// The target platform. Defaults to `Win32`. Note: `SqpkTargetInfo` chunks
167    /// in the patch stream will override this value when applied.
168    pub(crate) platform: Platform,
169    pub(crate) ignore_missing: bool,
170    pub(crate) ignore_old_mismatch: bool,
171    // Capped at MAX_CACHED_FDS entries; cleared wholesale when full to bound open FD count.
172    file_cache: HashMap<PathBuf, File>,
173}
174
175impl ApplyContext {
176    /// Create a context targeting the given game install directory.
177    ///
178    /// Defaults: platform is [`Platform::Win32`], both ignore-flags are off.
179    ///
180    /// Use the `with_*` builder methods to change these defaults before
181    /// applying the first chunk.
182    ///
183    /// # Example
184    ///
185    /// ```
186    /// use zipatch_rs::ApplyContext;
187    ///
188    /// let ctx = ApplyContext::new("/opt/ffxiv/game");
189    /// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
190    /// ```
191    pub fn new(game_path: impl Into<PathBuf>) -> Self {
192        Self {
193            game_path: game_path.into(),
194            platform: Platform::Win32,
195            ignore_missing: false,
196            ignore_old_mismatch: false,
197            file_cache: HashMap::new(),
198        }
199    }
200
201    /// Returns the game installation directory.
202    ///
203    /// All file paths produced during apply are relative to this root.
204    #[must_use]
205    pub fn game_path(&self) -> &std::path::Path {
206        &self.game_path
207    }
208
209    /// Returns the current target platform.
210    ///
211    /// This value may change during apply if the patch stream contains a
212    /// [`crate::chunk::sqpk::SqpkTargetInfo`] chunk.
213    #[must_use]
214    pub fn platform(&self) -> Platform {
215        self.platform
216    }
217
218    /// Returns whether missing files are silently ignored during apply.
219    ///
220    /// When `true`, operations that target a file that does not exist log a
221    /// warning instead of returning an error. This flag may be overwritten
222    /// mid-stream by an `ApplyOption` chunk.
223    #[must_use]
224    pub fn ignore_missing(&self) -> bool {
225        self.ignore_missing
226    }
227
228    /// Returns whether old-data mismatches are silently ignored during apply.
229    ///
230    /// When `true`, apply operations that detect a checksum or data mismatch
231    /// against the existing on-disk content proceed without error. This flag
232    /// may be overwritten mid-stream by an `ApplyOption` chunk.
233    #[must_use]
234    pub fn ignore_old_mismatch(&self) -> bool {
235        self.ignore_old_mismatch
236    }
237
238    /// Sets the target platform. Defaults to [`Platform::Win32`].
239    ///
240    /// The platform determines the directory suffix used when resolving `SqPack`
241    /// file paths (`win32`, `ps3`, or `ps4`).
242    ///
243    /// Note: a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk encountered during
244    /// apply will override this value.
245    #[must_use]
246    pub fn with_platform(mut self, platform: Platform) -> Self {
247        self.platform = platform;
248        self
249    }
250
251    /// Silently ignore missing files instead of returning an error during apply.
252    ///
253    /// When `false` (the default), any apply operation that cannot find its
254    /// target file returns [`crate::ZiPatchError::Io`] with kind
255    /// [`std::io::ErrorKind::NotFound`].
256    ///
257    /// When `true`, those failures are demoted to `warn!`-level tracing events.
258    #[must_use]
259    pub fn with_ignore_missing(mut self, v: bool) -> Self {
260        self.ignore_missing = v;
261        self
262    }
263
264    /// Silently ignore old-data mismatches instead of returning an error during apply.
265    ///
266    /// When `false` (the default), an apply operation that detects that the
267    /// on-disk data does not match the expected "before" state returns an error.
268    ///
269    /// When `true`, the mismatch is logged at `warn!` level and the operation
270    /// continues.
271    #[must_use]
272    pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
273        self.ignore_old_mismatch = v;
274        self
275    }
276
277    /// Return a writable handle to `path`, opening it if not already cached.
278    ///
279    /// If the cache has reached 256 entries and `path` is not already present,
280    /// all cached handles are dropped before opening the new one. The file is
281    /// opened with `write=true, create=true, truncate=false`.
282    ///
283    /// # Errors
284    ///
285    /// Returns `std::io::Error` if the file cannot be opened.
286    pub(crate) fn open_cached(&mut self, path: PathBuf) -> std::io::Result<&mut File> {
287        use std::collections::hash_map::Entry;
288        // Crude eviction: clear all when full to bound open FD count.
289        if self.file_cache.len() >= MAX_CACHED_FDS && !self.file_cache.contains_key(&path) {
290            self.file_cache.clear();
291        }
292        match self.file_cache.entry(path) {
293            Entry::Occupied(e) => Ok(e.into_mut()),
294            Entry::Vacant(e) => {
295                let file = OpenOptions::new()
296                    .write(true)
297                    .create(true)
298                    .truncate(false)
299                    .open(e.key())?;
300                Ok(e.insert(file))
301            }
302        }
303    }
304
305    /// Remove the cached handle for `path`, if any.
306    ///
307    /// Called before a file is deleted so that the OS handle is closed before
308    /// the unlink. This is a no-op on Linux but required for correctness on
309    /// Windows, where an open handle prevents deletion.
310    pub(crate) fn evict_cached(&mut self, path: &Path) {
311        self.file_cache.remove(path);
312    }
313
314    /// Drop all cached file handles.
315    ///
316    /// Called by `RemoveAll` before bulk-deleting an expansion folder's files
317    /// to ensure no lingering open handles survive into the deletion window.
318    pub(crate) fn clear_file_cache(&mut self) {
319        self.file_cache.clear();
320    }
321}
322
323/// Applies a parsed chunk to the filesystem via an [`ApplyContext`].
324///
325/// Every top-level [`Chunk`] variant and every
326/// [`crate::chunk::sqpk::SqpkCommand`] variant implements this trait. The
327/// usual entry point is [`Chunk::apply`], which dispatches to the appropriate
328/// implementation.
329///
330/// # Ordering
331///
332/// Chunks must be applied in the order they appear in the patch stream.
333/// The format is a sequential log; later chunks may depend on state produced
334/// by earlier ones.
335///
336/// # Idempotency
337///
338/// Apply operations are **not idempotent** in general. Write operations are
339/// idempotent only if the data payload is identical to what is already on
340/// disk. Destructive operations (`RemoveAll`, `DeleteFile`, `DeleteDirectory`)
341/// are not repeatable without error unless `ignore_missing` is set.
342///
343/// # Errors
344///
345/// Returns [`crate::ZiPatchError`] on any filesystem or data error. The error
346/// is not recovered from; the caller should treat it as fatal for the current
347/// apply session.
348///
349/// # Panics
350///
351/// Implementations do not panic under normal operation. Panics would indicate
352/// a bug in the parsing layer (e.g. a chunk with fields that violate internal
353/// invariants established during parsing).
354pub trait Apply {
355    /// Apply this chunk to `ctx`.
356    ///
357    /// On success, any filesystem changes the chunk describes have been
358    /// written. On error, changes may be partial; the caller is responsible
359    /// for any recovery.
360    fn apply(&self, ctx: &mut ApplyContext) -> Result<()>;
361}
362
363/// Dispatch table for top-level chunk variants.
364///
365/// `FileHeader`, `ApplyFreeSpace`, and `EndOfFile` are metadata or structural
366/// chunks with no filesystem effect; they return `Ok(())` immediately.
367/// All other variants delegate to their specific `Apply` implementation.
368impl Apply for Chunk {
369    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
370        match self {
371            Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
372            Chunk::Sqpk(c) => c.apply(ctx),
373            Chunk::ApplyOption(c) => c.apply(ctx),
374            Chunk::AddDirectory(c) => c.apply(ctx),
375            Chunk::DeleteDirectory(c) => c.apply(ctx),
376        }
377    }
378}
379
380/// Updates [`ApplyContext`] ignore-flags from the chunk payload.
381///
382/// `ApplyOption` chunks are embedded in the patch stream to toggle
383/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
384/// at specific points during apply. Applying this chunk mutates `ctx` in
385/// place; no filesystem I/O is performed.
386impl Apply for ApplyOption {
387    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
388        debug!(kind = ?self.kind, value = self.value, "apply option");
389        match self.kind {
390            ApplyOptionKind::IgnoreMissing => ctx.ignore_missing = self.value,
391            ApplyOptionKind::IgnoreOldMismatch => ctx.ignore_old_mismatch = self.value,
392        }
393        Ok(())
394    }
395}
396
397/// Creates a directory under the game install root.
398///
399/// Equivalent to `fs::create_dir_all(game_path / name)`. Intermediate
400/// directories are created as needed; the call is idempotent if the directory
401/// already exists.
402///
403/// # Errors
404///
405/// Returns [`crate::ZiPatchError::Io`] if directory creation fails for any
406/// reason other than the directory already existing (e.g. a permission error
407/// or a non-directory file at the path).
408impl Apply for AddDirectory {
409    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
410        trace!(name = %self.name, "create directory");
411        std::fs::create_dir_all(ctx.game_path.join(&self.name))?;
412        Ok(())
413    }
414}
415
416/// Removes a directory from the game install root.
417///
418/// The directory must be **empty**; `remove_dir` (not `remove_dir_all`) is
419/// used intentionally so that stale files inside the directory cause a visible
420/// error rather than silent data loss.
421///
422/// If the directory does not exist and [`ApplyContext::ignore_missing`] is
423/// `true`, the missing directory is logged at `warn!` level and `Ok(())` is
424/// returned. If `ignore_missing` is `false`, the `NotFound` I/O error is
425/// propagated.
426///
427/// # Errors
428///
429/// Returns [`crate::ZiPatchError::Io`] if the removal fails for any reason
430/// other than a missing directory with `ignore_missing = true`.
431impl Apply for DeleteDirectory {
432    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
433        match std::fs::remove_dir(ctx.game_path.join(&self.name)) {
434            Ok(()) => {
435                trace!(name = %self.name, "delete directory");
436                Ok(())
437            }
438            Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
439                warn!(name = %self.name, "delete directory: not found, ignored");
440                Ok(())
441            }
442            Err(e) => Err(e.into()),
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn cache_eviction_clears_when_full() {
453        let tmp = tempfile::tempdir().unwrap();
454        let mut ctx = ApplyContext::new(tmp.path());
455
456        for i in 0..MAX_CACHED_FDS {
457            ctx.open_cached(tmp.path().join(format!("{i}.dat")))
458                .unwrap();
459        }
460        assert_eq!(ctx.file_cache.len(), MAX_CACHED_FDS);
461
462        ctx.open_cached(tmp.path().join("new.dat")).unwrap();
463        assert_eq!(ctx.file_cache.len(), 1);
464    }
465
466    #[test]
467    fn game_path_returns_install_root() {
468        let tmp = tempfile::tempdir().unwrap();
469        let ctx = ApplyContext::new(tmp.path());
470        assert_eq!(ctx.game_path(), tmp.path());
471    }
472
473    #[test]
474    fn with_platform_overrides_default() {
475        let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
476        assert_eq!(ctx.platform(), Platform::Ps4);
477    }
478
479    #[test]
480    fn with_ignore_old_mismatch_toggles_flag() {
481        let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
482        assert!(ctx.ignore_old_mismatch());
483        let ctx = ctx.with_ignore_old_mismatch(false);
484        assert!(!ctx.ignore_old_mismatch());
485    }
486}