zipatch-rs 1.0.2

Parser for FFXIV ZiPatch patch files
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Filesystem application of parsed `ZiPatch` chunks.
//!
//! # Parse / apply separation
//!
//! The crate is intentionally split into two independent layers:
//!
//! - **Parsing** (`src/chunk/`) — reads the binary wire format and produces
//!   [`Chunk`] values. Nothing in the parser allocates file handles, stats
//!   paths, or performs I/O against the install tree.
//! - **Applying** (this module) — takes a stream of [`Chunk`] values and
//!   writes the patch changes to disk.
//!
//! The only bridge between the two layers is the [`Apply`] trait, which every
//! chunk type implements. Callers that only need to inspect patch contents can
//! use the parser without ever touching this module.
//!
//! # `ApplyContext`
//!
//! [`ApplyContext`] holds all mutable apply-time state:
//!
//! - **Install root** — the absolute path to the game installation directory.
//!   All `SqPack` paths (`sqpack/<expansion>/...`) are resolved relative to
//!   this root by the internal path submodule.
//! - **Target platform** — selects the `win32`/`ps3`/`ps4` subfolder suffix
//!   used in `SqPack` file paths. Defaults to [`Platform::Win32`] and can be
//!   overridden either at construction time with [`ApplyContext::with_platform`]
//!   or at apply time when a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk is
//!   encountered.
//! - **Ignore flags** — control whether missing files and old-data mismatches
//!   produce errors or logged warnings. `SqpkTargetInfo` chunks set these via
//!   the stream; callers can also pre-configure them.
//! - **File-handle cache** — a bounded map of open file handles. Because a
//!   typical patch applies dozens of chunks to the same `.dat` file,
//!   re-opening that file for every chunk would be wasteful. The cache avoids
//!   this while bounding the number of simultaneously open file descriptors.
//!   See the [cache section](#file-handle-cache) below.
//!
//! # File-handle cache
//!
//! Every `Apply` impl that writes to a `SqPack` file calls an internal
//! `open_cached` method on `ApplyContext` rather than opening the file
//! directly. The cache transparently returns an existing writable handle or
//! opens a new one (with `write=true, create=true, truncate=false`).
//!
//! The cache is capped at 256 entries. When it is full and a new, uncached
//! path is requested, **all** cached handles are closed at once before the
//! new one is inserted. This is a simple eviction strategy — it trades some
//! re-open overhead at eviction boundaries for bounded file-descriptor usage.
//!
//! Callers should not rely on cached handles persisting across arbitrary
//! chunks. In particular, [`crate::chunk::sqpk::SqpkFile`]'s `RemoveAll`
//! operation flushes all cached handles before bulk-deleting files to ensure
//! no open handles survive into the deletion window (which matters on
//! Windows). Similarly, `DeleteFile` evicts the cached handle for the
//! specific path being removed.
//!
//! # Ordering and idempotency
//!
//! Chunks **must** be applied in stream order. The `ZiPatch` format is a
//! sequential log, not a random-access manifest: later chunks may depend on
//! filesystem state produced by earlier ones (e.g. an `AddFile` that writes
//! blocks into a file created by an earlier `MakeDirTree` or `AddDirectory`).
//!
//! Apply operations are **not idempotent** in general. Seeking to an offset
//! and writing data is idempotent if the same data is written, but
//! `RemoveAll` is destructive and `DeleteFile` can fail if the file is
//! already gone (unless `ignore_missing` is set). Partial application
//! followed by a retry requires careful state tracking at a higher level;
//! this crate does not provide transactional semantics.
//!
//! # Errors
//!
//! Every [`Apply::apply`] call returns [`crate::Result`], which is
//! `Result<(), `[`crate::ZiPatchError`]`>`. Errors propagate from:
//!
//! - `std::io::Error` — filesystem failures (permissions, missing parent
//!   directories, disk full, etc.) wrapped as [`crate::ZiPatchError::Io`].
//! - [`crate::ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk
//!   carried a negative `file_offset` that cannot be converted to a seek
//!   position.
//!
//! On error, the apply operation aborts at the failing chunk. Any changes
//! already applied to the filesystem are **not** rolled back.
//!
//! # Example
//!
//! ```no_run
//! use std::fs::File;
//! use zipatch_rs::{ApplyContext, ZiPatchReader};
//!
//! let patch_file = File::open("game.patch").unwrap();
//! let mut ctx = ApplyContext::new("/opt/ffxiv/game");
//!
//! ZiPatchReader::new(patch_file)
//!     .unwrap()
//!     .apply_to(&mut ctx)
//!     .unwrap();
//! ```

pub(crate) mod path;
pub(crate) mod sqpk;

use crate::Platform;
use crate::Result;
use crate::chunk::Chunk;
use crate::chunk::adir::AddDirectory;
use crate::chunk::aply::{ApplyOption, ApplyOptionKind};
use crate::chunk::ddir::DeleteDirectory;
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use tracing::{debug, trace, warn};

const MAX_CACHED_FDS: usize = 256;

/// Apply-time state: install root, target platform, flag toggles, and the
/// internal file-handle cache used by SQPK writers.
///
/// # Construction
///
/// Build with [`ApplyContext::new`], then chain the `with_*` builder methods
/// to override defaults:
///
/// ```
/// use zipatch_rs::{ApplyContext, Platform};
///
/// let ctx = ApplyContext::new("/opt/ffxiv/game")
///     .with_platform(Platform::Win32)
///     .with_ignore_missing(true);
///
/// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
/// assert_eq!(ctx.platform(), Platform::Win32);
/// assert!(ctx.ignore_missing());
/// ```
///
/// # Platform mutation
///
/// The platform defaults to [`Platform::Win32`]. If the patch stream contains
/// a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk, applying it overwrites
/// [`ApplyContext::platform`] with the platform declared in the chunk. This is
/// the normal case: real FFXIV patches begin with a `TargetInfo` chunk that
/// pins the platform, so the default is rarely used in practice.
///
/// Set the platform explicitly with [`ApplyContext::with_platform`] when you
/// know the target in advance or are processing a synthetic patch.
///
/// # Flag mutation
///
/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
/// can also be overwritten mid-stream by `ApplyOption` chunks embedded in the
/// patch file. Set initial values with the `with_ignore_*` builder methods.
///
/// # File-handle cache
///
/// Internally, `ApplyContext` maintains a bounded map of open file handles
/// keyed by absolute path. The cache is an optimisation: a patch that writes
/// many chunks into the same `.dat` file re-uses a single handle rather than
/// opening and closing the file for every write.
///
/// The cache is capped at 256 entries. When that limit is reached and a new
/// path is needed, **all** entries are evicted at once. Handles are also
/// evicted explicitly before deleting a file (see `DeleteFile`) and before a
/// `RemoveAll` bulk operation.
pub struct ApplyContext {
    pub(crate) game_path: PathBuf,
    /// The target platform. Defaults to `Win32`. Note: `SqpkTargetInfo` chunks
    /// in the patch stream will override this value when applied.
    pub(crate) platform: Platform,
    pub(crate) ignore_missing: bool,
    pub(crate) ignore_old_mismatch: bool,
    // Capped at MAX_CACHED_FDS entries; cleared wholesale when full to bound open FD count.
    file_cache: HashMap<PathBuf, File>,
}

impl ApplyContext {
    /// Create a context targeting the given game install directory.
    ///
    /// Defaults: platform is [`Platform::Win32`], both ignore-flags are off.
    ///
    /// Use the `with_*` builder methods to change these defaults before
    /// applying the first chunk.
    ///
    /// # Example
    ///
    /// ```
    /// use zipatch_rs::ApplyContext;
    ///
    /// let ctx = ApplyContext::new("/opt/ffxiv/game");
    /// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
    /// ```
    pub fn new(game_path: impl Into<PathBuf>) -> Self {
        Self {
            game_path: game_path.into(),
            platform: Platform::Win32,
            ignore_missing: false,
            ignore_old_mismatch: false,
            file_cache: HashMap::new(),
        }
    }

    /// Returns the game installation directory.
    ///
    /// All file paths produced during apply are relative to this root.
    #[must_use]
    pub fn game_path(&self) -> &std::path::Path {
        &self.game_path
    }

    /// Returns the current target platform.
    ///
    /// This value may change during apply if the patch stream contains a
    /// [`crate::chunk::sqpk::SqpkTargetInfo`] chunk.
    #[must_use]
    pub fn platform(&self) -> Platform {
        self.platform
    }

    /// Returns whether missing files are silently ignored during apply.
    ///
    /// When `true`, operations that target a file that does not exist log a
    /// warning instead of returning an error. This flag may be overwritten
    /// mid-stream by an `ApplyOption` chunk.
    #[must_use]
    pub fn ignore_missing(&self) -> bool {
        self.ignore_missing
    }

    /// Returns whether old-data mismatches are silently ignored during apply.
    ///
    /// When `true`, apply operations that detect a checksum or data mismatch
    /// against the existing on-disk content proceed without error. This flag
    /// may be overwritten mid-stream by an `ApplyOption` chunk.
    #[must_use]
    pub fn ignore_old_mismatch(&self) -> bool {
        self.ignore_old_mismatch
    }

    /// Sets the target platform. Defaults to [`Platform::Win32`].
    ///
    /// The platform determines the directory suffix used when resolving `SqPack`
    /// file paths (`win32`, `ps3`, or `ps4`).
    ///
    /// Note: a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk encountered during
    /// apply will override this value.
    #[must_use]
    pub fn with_platform(mut self, platform: Platform) -> Self {
        self.platform = platform;
        self
    }

    /// Silently ignore missing files instead of returning an error during apply.
    ///
    /// When `false` (the default), any apply operation that cannot find its
    /// target file returns [`crate::ZiPatchError::Io`] with kind
    /// [`std::io::ErrorKind::NotFound`].
    ///
    /// When `true`, those failures are demoted to `warn!`-level tracing events.
    #[must_use]
    pub fn with_ignore_missing(mut self, v: bool) -> Self {
        self.ignore_missing = v;
        self
    }

    /// Silently ignore old-data mismatches instead of returning an error during apply.
    ///
    /// When `false` (the default), an apply operation that detects that the
    /// on-disk data does not match the expected "before" state returns an error.
    ///
    /// When `true`, the mismatch is logged at `warn!` level and the operation
    /// continues.
    #[must_use]
    pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
        self.ignore_old_mismatch = v;
        self
    }

    /// Return a writable handle to `path`, opening it if not already cached.
    ///
    /// If the cache has reached 256 entries and `path` is not already present,
    /// all cached handles are dropped before opening the new one. The file is
    /// opened with `write=true, create=true, truncate=false`.
    ///
    /// # Errors
    ///
    /// Returns `std::io::Error` if the file cannot be opened.
    pub(crate) fn open_cached(&mut self, path: PathBuf) -> std::io::Result<&mut File> {
        use std::collections::hash_map::Entry;
        // Crude eviction: clear all when full to bound open FD count.
        if self.file_cache.len() >= MAX_CACHED_FDS && !self.file_cache.contains_key(&path) {
            self.file_cache.clear();
        }
        match self.file_cache.entry(path) {
            Entry::Occupied(e) => Ok(e.into_mut()),
            Entry::Vacant(e) => {
                let file = OpenOptions::new()
                    .write(true)
                    .create(true)
                    .truncate(false)
                    .open(e.key())?;
                Ok(e.insert(file))
            }
        }
    }

    /// Remove the cached handle for `path`, if any.
    ///
    /// Called before a file is deleted so that the OS handle is closed before
    /// the unlink. This is a no-op on Linux but required for correctness on
    /// Windows, where an open handle prevents deletion.
    pub(crate) fn evict_cached(&mut self, path: &Path) {
        self.file_cache.remove(path);
    }

    /// Drop all cached file handles.
    ///
    /// Called by `RemoveAll` before bulk-deleting an expansion folder's files
    /// to ensure no lingering open handles survive into the deletion window.
    pub(crate) fn clear_file_cache(&mut self) {
        self.file_cache.clear();
    }
}

/// Applies a parsed chunk to the filesystem via an [`ApplyContext`].
///
/// Every top-level [`Chunk`] variant and every
/// [`crate::chunk::sqpk::SqpkCommand`] variant implements this trait. The
/// usual entry point is [`Chunk::apply`], which dispatches to the appropriate
/// implementation.
///
/// # Ordering
///
/// Chunks must be applied in the order they appear in the patch stream.
/// The format is a sequential log; later chunks may depend on state produced
/// by earlier ones.
///
/// # Idempotency
///
/// Apply operations are **not idempotent** in general. Write operations are
/// idempotent only if the data payload is identical to what is already on
/// disk. Destructive operations (`RemoveAll`, `DeleteFile`, `DeleteDirectory`)
/// are not repeatable without error unless `ignore_missing` is set.
///
/// # Errors
///
/// Returns [`crate::ZiPatchError`] on any filesystem or data error. The error
/// is not recovered from; the caller should treat it as fatal for the current
/// apply session.
///
/// # Panics
///
/// Implementations do not panic under normal operation. Panics would indicate
/// a bug in the parsing layer (e.g. a chunk with fields that violate internal
/// invariants established during parsing).
pub trait Apply {
    /// Apply this chunk to `ctx`.
    ///
    /// On success, any filesystem changes the chunk describes have been
    /// written. On error, changes may be partial; the caller is responsible
    /// for any recovery.
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()>;
}

/// Dispatch table for top-level chunk variants.
///
/// `FileHeader`, `ApplyFreeSpace`, and `EndOfFile` are metadata or structural
/// chunks with no filesystem effect; they return `Ok(())` immediately.
/// All other variants delegate to their specific `Apply` implementation.
impl Apply for Chunk {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        match self {
            Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
            Chunk::Sqpk(c) => c.apply(ctx),
            Chunk::ApplyOption(c) => c.apply(ctx),
            Chunk::AddDirectory(c) => c.apply(ctx),
            Chunk::DeleteDirectory(c) => c.apply(ctx),
        }
    }
}

/// Updates [`ApplyContext`] ignore-flags from the chunk payload.
///
/// `ApplyOption` chunks are embedded in the patch stream to toggle
/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
/// at specific points during apply. Applying this chunk mutates `ctx` in
/// place; no filesystem I/O is performed.
impl Apply for ApplyOption {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        debug!(kind = ?self.kind, value = self.value, "apply option");
        match self.kind {
            ApplyOptionKind::IgnoreMissing => ctx.ignore_missing = self.value,
            ApplyOptionKind::IgnoreOldMismatch => ctx.ignore_old_mismatch = self.value,
        }
        Ok(())
    }
}

/// Creates a directory under the game install root.
///
/// Equivalent to `fs::create_dir_all(game_path / name)`. Intermediate
/// directories are created as needed; the call is idempotent if the directory
/// already exists.
///
/// # Errors
///
/// Returns [`crate::ZiPatchError::Io`] if directory creation fails for any
/// reason other than the directory already existing (e.g. a permission error
/// or a non-directory file at the path).
impl Apply for AddDirectory {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        trace!(name = %self.name, "create directory");
        std::fs::create_dir_all(ctx.game_path.join(&self.name))?;
        Ok(())
    }
}

/// Removes a directory from the game install root.
///
/// The directory must be **empty**; `remove_dir` (not `remove_dir_all`) is
/// used intentionally so that stale files inside the directory cause a visible
/// error rather than silent data loss.
///
/// If the directory does not exist and [`ApplyContext::ignore_missing`] is
/// `true`, the missing directory is logged at `warn!` level and `Ok(())` is
/// returned. If `ignore_missing` is `false`, the `NotFound` I/O error is
/// propagated.
///
/// # Errors
///
/// Returns [`crate::ZiPatchError::Io`] if the removal fails for any reason
/// other than a missing directory with `ignore_missing = true`.
impl Apply for DeleteDirectory {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        match std::fs::remove_dir(ctx.game_path.join(&self.name)) {
            Ok(()) => {
                trace!(name = %self.name, "delete directory");
                Ok(())
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
                warn!(name = %self.name, "delete directory: not found, ignored");
                Ok(())
            }
            Err(e) => Err(e.into()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cache_eviction_clears_when_full() {
        let tmp = tempfile::tempdir().unwrap();
        let mut ctx = ApplyContext::new(tmp.path());

        for i in 0..MAX_CACHED_FDS {
            ctx.open_cached(tmp.path().join(format!("{i}.dat")))
                .unwrap();
        }
        assert_eq!(ctx.file_cache.len(), MAX_CACHED_FDS);

        ctx.open_cached(tmp.path().join("new.dat")).unwrap();
        assert_eq!(ctx.file_cache.len(), 1);
    }

    #[test]
    fn game_path_returns_install_root() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = ApplyContext::new(tmp.path());
        assert_eq!(ctx.game_path(), tmp.path());
    }

    #[test]
    fn with_platform_overrides_default() {
        let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
        assert_eq!(ctx.platform(), Platform::Ps4);
    }

    #[test]
    fn with_ignore_old_mismatch_toggles_flag() {
        let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
        assert!(ctx.ignore_old_mismatch());
        let ctx = ctx.with_ignore_old_mismatch(false);
        assert!(!ctx.ignore_old_mismatch());
    }
}