Skip to main content

zipatch_rs/apply/
sqpk.rs

1//! [`Apply`] implementations for [`SqpkCommand`] variants.
2//!
3//! This module is the consumer side of the SQPK dispatcher. Each function
4//! applies one SQPK sub-command to the filesystem via an [`ApplyContext`].
5//!
6//! # Dispatch
7//!
8//! [`SqpkCommand`]'s [`Apply`] impl matches on the variant and calls the
9//! corresponding private function. Two variants — `Index` and `PatchInfo` —
10//! carry metadata that is used by the indexed `ZiPatch` reader (not yet
11//! implemented) and have no direct filesystem effect; their apply arms return
12//! `Ok(())` immediately.
13//!
14//! # File-handle caching
15//!
16//! All write operations (`AddData`, `DeleteData`, `ExpandData`, `Header`, and
17//! the `AddFile` arm of `File`) call an internal `open_cached` method rather
18//! than opening a new handle directly. See [`crate::apply`] for the cache
19//! semantics. The `RemoveAll` and `DeleteFile` arms flush or evict cached
20//! handles before touching the filesystem.
21//!
22//! # Block-offset scaling
23//!
24//! Block offsets in `AddData`, `DeleteData`, and `ExpandData` are stored in
25//! the wire format as a `u32` scaled by 128 (`<< 7`). The shift is applied
26//! during parsing; by the time an apply function sees a chunk, `block_offset`
27//! is already in bytes.
28
29use crate::Platform;
30use crate::apply::path::{dat_path, expansion_folder_id, generic_path, index_path};
31use crate::apply::{Apply, ApplyContext, ApplyObserver};
32use crate::chunk::sqpk::SqpkCommand;
33use crate::chunk::sqpk::add_data::SqpkAddData;
34use crate::chunk::sqpk::delete_data::SqpkDeleteData;
35use crate::chunk::sqpk::expand_data::SqpkExpandData;
36use crate::chunk::sqpk::file::{SqpkFile, SqpkFileOperation};
37use crate::chunk::sqpk::header::{SqpkHeader, SqpkHeaderTarget, TargetHeaderKind};
38use crate::chunk::sqpk::target_info::SqpkTargetInfo;
39use crate::{Result, ZiPatchError};
40use std::fs;
41use std::io::{Seek, SeekFrom, Write};
42use std::path::Path;
43use tracing::{debug, trace, warn};
44
45/// Write `len` zero bytes to `w` in 64 KiB chunks.
46///
47/// `std::io::copy` against `std::io::repeat(0).take(len)` used to do this
48/// for us but at an 8 KiB internal buffer, which fragments large zero runs
49/// (the `block_delete_number` tail of `AddData` and the full-block zero-fill
50/// in `write_empty_block`) into many small `write_all` calls. The 64 KiB
51/// constant lives in the binary's read-only data section, so no per-call
52/// zero-init or stack pressure is involved.
53fn write_zeros(w: &mut impl Write, len: u64) -> std::io::Result<()> {
54    static BUF: [u8; 64 * 1024] = [0; 64 * 1024];
55    let mut remaining = len;
56    while remaining > 0 {
57        let n = remaining.min(BUF.len() as u64) as usize;
58        w.write_all(&BUF[..n])?;
59        remaining -= n as u64;
60    }
61    Ok(())
62}
63
64/// Write a `SqPack` empty-block header at `offset` and zero the full block range.
65///
66/// The block range is `block_number * 128` bytes starting at `offset`. After
67/// zeroing, a 5-field little-endian header is written at `offset`:
68///
69/// | Offset | Size | Value |
70/// |--------|------|-------|
71/// | 0 | 4 | `128` — block-size marker |
72/// | 4 | 4 | `0` |
73/// | 8 | 4 | `0` |
74/// | 12 | 4 | `block_number - 1` — "next free" count |
75/// | 16 | 4 | `0` |
76///
77/// `block_number` must be non-zero; the function returns
78/// [`std::io::ErrorKind::InvalidInput`] otherwise, because a zero-block range
79/// has no meaningful on-disk representation and would silently skip the seek.
80fn write_empty_block(
81    f: &mut (impl Write + Seek),
82    offset: u64,
83    block_number: u32,
84) -> std::io::Result<()> {
85    if block_number == 0 {
86        return Err(std::io::Error::new(
87            std::io::ErrorKind::InvalidInput,
88            "block_number must be non-zero",
89        ));
90    }
91    f.seek(SeekFrom::Start(offset))?;
92    write_zeros(f, (block_number as u64) << 7)?;
93    f.seek(SeekFrom::Start(offset))?;
94    f.write_all(&128u32.to_le_bytes())?;
95    f.write_all(&0u32.to_le_bytes())?;
96    f.write_all(&0u32.to_le_bytes())?;
97    f.write_all(&block_number.wrapping_sub(1).to_le_bytes())?;
98    f.write_all(&0u32.to_le_bytes())?;
99    Ok(())
100}
101
102/// Decide whether a path should be preserved by `RemoveAll`.
103///
104/// `RemoveAll` deletes all files in an expansion folder's `sqpack/` and
105/// `movie/` subdirectories, but preserves:
106///
107/// - Any file whose name ends in `.var` (version markers used by the patcher).
108/// - The four introductory movie files `00000.bk2` through `00003.bk2`.
109///   Files named `00004.bk2` and beyond are deleted.
110///
111/// The `.bk2` match is exact on the base name — a file like
112/// `prefix00000.bk2` is **not** kept.
113fn keep_in_remove_all(path: &Path) -> bool {
114    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
115        return false;
116    };
117    #[allow(clippy::case_sensitive_file_extension_comparisons)]
118    let is_var = name.ends_with(".var");
119    is_var || matches!(name, "00000.bk2" | "00001.bk2" | "00002.bk2" | "00003.bk2")
120}
121
122/// Dispatch an [`SqpkCommand`] to its specific apply function.
123///
124/// - [`SqpkCommand::TargetInfo`] — updates [`ApplyContext::platform`] in place.
125/// - [`SqpkCommand::Index`] and [`SqpkCommand::PatchInfo`] — metadata only;
126///   returns `Ok(())` without touching the filesystem.
127/// - All other variants — delegate to the write/delete functions below.
128impl Apply for SqpkCommand {
129    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
130        match self {
131            SqpkCommand::TargetInfo(c) => apply_target_info(c, ctx),
132            SqpkCommand::Index(_) | SqpkCommand::PatchInfo(_) => Ok(()),
133            SqpkCommand::AddData(c) => apply_add_data(c, ctx),
134            SqpkCommand::DeleteData(c) => apply_delete_data(c, ctx),
135            SqpkCommand::ExpandData(c) => apply_expand_data(c, ctx),
136            SqpkCommand::Header(c) => apply_header(c, ctx),
137            SqpkCommand::File(c) => apply_file(c, ctx),
138        }
139    }
140}
141
142/// Apply a [`SqpkTargetInfo`] chunk.
143///
144/// Overwrites [`ApplyContext::platform`] with the platform declared by the
145/// chunk. All subsequent path resolution — for `AddData`, `DeleteData`, etc.
146/// — uses the updated platform. Real FFXIV patches start with a `TargetInfo`
147/// chunk, so this is typically the first mutation applied to the context.
148///
149/// Platform ID mapping:
150///
151/// | `platform_id` | [`Platform`] |
152/// |---------------|-------------|
153/// | `0` | [`Platform::Win32`] |
154/// | `1` | [`Platform::Ps3`] |
155/// | `2` | [`Platform::Ps4`] |
156/// | anything else | [`Platform::Unknown`] + `warn!` |
157///
158/// No filesystem I/O is performed. The `Unknown` case deliberately stores the
159/// raw `platform_id` and returns `Ok(())` rather than failing eagerly — a
160/// patch that only touches non-SqPack chunks (e.g. `ADIR`, `DELD`, or
161/// `SqpkFile` operations resolved via `generic_path`) can still apply.
162/// `SqPack` `.dat`/`.index` path resolution refuses to guess and returns
163/// [`crate::ZiPatchError::UnsupportedPlatform`] on the first lookup.
164#[allow(clippy::unnecessary_wraps)] // sibling dispatch arms all return Result<()>
165fn apply_target_info(cmd: &SqpkTargetInfo, ctx: &mut ApplyContext) -> Result<()> {
166    let new_platform = match cmd.platform_id {
167        0 => Platform::Win32,
168        1 => Platform::Ps3,
169        2 => Platform::Ps4,
170        id => {
171            warn!(
172                platform_id = id,
173                "unknown platform_id in TargetInfo; stored as Unknown"
174            );
175            Platform::Unknown(id)
176        }
177    };
178    // Cached SqPack paths embed the platform string, so a platform change
179    // makes every existing entry stale. The first `TargetInfo` chunk in a
180    // patch fires against an empty cache (no-op clear); subsequent chunks
181    // that re-pin the same platform also short-circuit through the equality
182    // check.
183    if ctx.platform != new_platform {
184        ctx.invalidate_path_cache();
185    }
186    ctx.platform = new_platform;
187    debug!(platform = ?ctx.platform, "target info");
188    Ok(())
189}
190
191/// Apply a [`SqpkAddData`] chunk.
192///
193/// Seeks to `block_offset` in the resolved `.dat` file and writes the chunk's
194/// inline `data` payload, then appends `block_delete_number` zero bytes
195/// immediately after it. The file is opened via the handle cache, so
196/// repeated writes to the same file re-use a single open handle.
197///
198/// The target path is resolved from `(main_id, sub_id, file_id, platform)`.
199///
200/// # Errors
201///
202/// - [`crate::ZiPatchError::UnsupportedPlatform`] — the context's platform is
203///   [`Platform::Unknown`]; path resolution refuses to guess a layout.
204/// - [`crate::ZiPatchError::Io`] — file open, seek, or write failure.
205fn apply_add_data(cmd: &SqpkAddData, ctx: &mut ApplyContext) -> Result<()> {
206    let tf = &cmd.target_file;
207    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
208    trace!(path = %path.display(), offset = cmd.block_offset, delete_zeros = cmd.block_delete_number, "add data");
209    let file = ctx.open_cached(path)?;
210    file.seek(SeekFrom::Start(cmd.block_offset))?;
211    file.write_all(&cmd.data)?;
212    write_zeros(file, cmd.block_delete_number)?;
213    Ok(())
214}
215
216/// Apply a [`SqpkDeleteData`] chunk.
217///
218/// Writes an empty-block header at `block_offset` covering `block_count`
219/// 128-byte blocks. The operation logically "frees" the range in the `SqPack`
220/// data file so the game's archive reader treats those blocks as available
221/// space.
222///
223/// The target path is resolved from `(main_id, sub_id, file_id, platform)`.
224///
225/// # Errors
226///
227/// - [`crate::ZiPatchError::UnsupportedPlatform`] — the context's platform is
228///   [`Platform::Unknown`]; path resolution refuses to guess a layout.
229/// - [`crate::ZiPatchError::Io`] — file open or write failure (e.g.
230///   `block_count` is zero, or a seek or write error).
231fn apply_delete_data(cmd: &SqpkDeleteData, ctx: &mut ApplyContext) -> Result<()> {
232    let tf = &cmd.target_file;
233    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
234    trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "delete data");
235    let file = ctx.open_cached(path)?;
236    write_empty_block(file, cmd.block_offset, cmd.block_count)?;
237    Ok(())
238}
239
240/// Apply a [`SqpkExpandData`] chunk.
241///
242/// Behaves identically to `apply_delete_data`: writes an empty-block header
243/// at `block_offset` for `block_count` blocks. The semantic difference is
244/// in the patch's intent — `ExpandData` extends the file into previously
245/// unallocated space, while `DeleteData` clears existing content — but the
246/// on-disk operation (writing empty-block markers) is the same.
247///
248/// # Errors
249///
250/// - [`crate::ZiPatchError::UnsupportedPlatform`] — the context's platform is
251///   [`Platform::Unknown`]; path resolution refuses to guess a layout.
252/// - [`crate::ZiPatchError::Io`] — file open or write failure.
253fn apply_expand_data(cmd: &SqpkExpandData, ctx: &mut ApplyContext) -> Result<()> {
254    let tf = &cmd.target_file;
255    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
256    trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "expand data");
257    let file = ctx.open_cached(path)?;
258    write_empty_block(file, cmd.block_offset, cmd.block_count)?;
259    Ok(())
260}
261
262/// Apply a [`SqpkHeader`] chunk.
263///
264/// Writes exactly 1024 bytes of header data into a `.dat` or `.index` `SqPack`
265/// file at one of two fixed offsets determined by `header_kind`:
266///
267/// | [`TargetHeaderKind`] | File offset |
268/// |--------------------|------------|
269/// | `Version` | `0` (version header slot) |
270/// | `Index` or `Data` | `1024` (secondary header slot) |
271///
272/// The target file is determined by `SqpkHeaderTarget`:
273///
274/// - `Dat(f)` → `.dat` path resolved from `(f.main_id, f.sub_id, f.file_id)`
275/// - `Index(f)` → `.index` path resolved from `(f.main_id, f.sub_id, f.file_id)`
276///
277/// The file handle is obtained from the cache.
278///
279/// # Errors
280///
281/// - [`crate::ZiPatchError::UnsupportedPlatform`] — the context's platform is
282///   [`Platform::Unknown`]; path resolution refuses to guess a layout.
283/// - [`crate::ZiPatchError::Io`] — file open, seek, or write failure.
284fn apply_header(cmd: &SqpkHeader, ctx: &mut ApplyContext) -> Result<()> {
285    let path = match &cmd.target {
286        SqpkHeaderTarget::Dat(f) => dat_path(ctx, f.main_id, f.sub_id, f.file_id)?,
287        SqpkHeaderTarget::Index(f) => index_path(ctx, f.main_id, f.sub_id, f.file_id)?,
288    };
289    let offset: u64 = match cmd.header_kind {
290        TargetHeaderKind::Version => 0,
291        _ => 1024,
292    };
293    trace!(path = %path.display(), offset, kind = ?cmd.header_kind, "apply header");
294    let file = ctx.open_cached(path)?;
295    file.seek(SeekFrom::Start(offset))?;
296    file.write_all(&cmd.header_data)?;
297    Ok(())
298}
299
300/// Apply a [`SqpkFile`] chunk.
301///
302/// Dispatches on [`SqpkFileOperation`]:
303///
304/// ## `AddFile`
305///
306/// Writes compressed-or-raw block payloads into the target file at
307/// `file_offset`. The target path is resolved by joining the game install root
308/// with `cmd.path` (a relative path). Parent directories are created with
309/// `create_dir_all` if they do not exist.
310///
311/// If `file_offset == 0`, the file is truncated to zero before writing (the
312/// operation replaces the file entirely). If `file_offset > 0`, only the
313/// covered byte range is overwritten.
314///
315/// Each block in `cmd.blocks` is decompressed (or passed through verbatim if
316/// uncompressed) into the file handle in sequence. The file handle is kept
317/// open in the cache for subsequent chunks targeting the same path.
318///
319/// ## Errors (`AddFile`)
320///
321/// - [`crate::ZiPatchError::Io`] — file open, `set_len`, seek, or write
322///   failure.
323/// - [`crate::ZiPatchError::NegativeFileOffset`] — `cmd.file_offset` is
324///   negative and cannot be converted to a `u64` seek position.
325/// - [`crate::ZiPatchError::Decompress`] — a DEFLATE block could not be
326///   decompressed.
327///
328/// ## `RemoveAll`
329///
330/// Deletes all files in `sqpack/<expansion>` and `movie/<expansion>` that are
331/// not in the keep-list (`.var` files and `00000`–`00003.bk2`). Before
332/// iterating the directories, **all** cached file handles are flushed to avoid
333/// open-handle conflicts on Windows.
334///
335/// Directories that do not exist are silently skipped.
336///
337/// ## Errors (`RemoveAll`)
338///
339/// - [`crate::ZiPatchError::Io`] — directory read or file deletion failure.
340///
341/// ## `DeleteFile`
342///
343/// Removes a single file at the path resolved from `cmd.path`. The cached
344/// handle for that path is evicted before the deletion (required on Windows;
345/// harmless on Linux).
346///
347/// If the file does not exist and [`ApplyContext::ignore_missing`] is `true`,
348/// the error is demoted to a `warn!` log and `Ok(())` is returned.
349///
350/// ## Errors (`DeleteFile`)
351///
352/// - [`crate::ZiPatchError::Io`] — deletion failed for a reason other than
353///   `NotFound`, or `NotFound` with `ignore_missing = false`.
354///
355/// ## `MakeDirTree`
356///
357/// Creates the directory tree at the path resolved from `cmd.path`,
358/// equivalent to `fs::create_dir_all`. Idempotent.
359///
360/// ## Errors (`MakeDirTree`)
361///
362/// - [`crate::ZiPatchError::Io`] — directory creation failed.
363fn apply_file(cmd: &SqpkFile, ctx: &mut ApplyContext) -> Result<()> {
364    match cmd.operation {
365        SqpkFileOperation::AddFile => {
366            let path = generic_path(ctx, &cmd.path);
367            trace!(path = %path.display(), file_offset = cmd.file_offset, blocks = cmd.blocks.len(), "add file");
368            if let Some(parent) = path.parent() {
369                ctx.ensure_dir_all(parent)?;
370            }
371            let writer = ctx.open_cached(path.clone())?;
372            if cmd.file_offset == 0 {
373                // `set_len` is on the raw `File`, not on `BufWriter`. Flush
374                // any pending buffered writes destined for the pre-truncate
375                // offsets before reaching through to the underlying handle —
376                // otherwise the in-memory buffer would be silently dropped on
377                // the next seek and a write error would never surface.
378                writer.flush()?;
379                writer.get_mut().set_len(0)?;
380            }
381            let offset = u64::try_from(cmd.file_offset)
382                .map_err(|_| ZiPatchError::NegativeFileOffset(cmd.file_offset))?;
383            writer.seek(SeekFrom::Start(offset))?;
384            // Split-borrow the observer and the reusable DEFLATE state
385            // separately from the file-handle cache so we can poll
386            // cancellation and decompress each block in place while still
387            // holding the cached `&mut BufWriter<File>`. The entry is
388            // guaranteed to be in the cache because the `open_cached` call
389            // immediately above just inserted (or refreshed) it, and no
390            // cache-mutating call sits between them.
391            let observer: &mut dyn ApplyObserver = &mut *ctx.observer;
392            let decompressor = &mut ctx.decompressor;
393            let writer = ctx
394                .file_cache
395                .get_mut(&path)
396                .expect("open_cached above inserted this path");
397            for block in &cmd.blocks {
398                if observer.should_cancel() {
399                    debug!(path = %path.display(), "add file: cancelled mid-blocks");
400                    return Err(ZiPatchError::Cancelled);
401                }
402                block.decompress_into_with(decompressor, writer)?;
403            }
404            Ok(())
405        }
406        SqpkFileOperation::RemoveAll => {
407            // Flush all cached handles before bulk-deleting files — buffered
408            // writes against any of the about-to-be-removed paths must reach
409            // disk first, or be surfaced as an error rather than silently
410            // dropped when the file is unlinked.
411            ctx.clear_file_cache()?;
412            let folder = expansion_folder_id(cmd.expansion_id);
413            debug!(folder = %folder, "remove all");
414            for top in &["sqpack", "movie"] {
415                let dir = ctx.game_path.join(top).join(&folder);
416                if !dir.exists() {
417                    continue;
418                }
419                for entry in fs::read_dir(&dir)? {
420                    let path = entry?.path();
421                    if path.is_file() && !keep_in_remove_all(&path) {
422                        fs::remove_file(&path)?;
423                    }
424                }
425            }
426            Ok(())
427        }
428        SqpkFileOperation::DeleteFile => {
429            let path = generic_path(ctx, &cmd.path);
430            // Flush and drop the cached handle before the OS delete so the
431            // fd is closed first (required on Windows; harmless on Linux),
432            // and any buffered writes against the to-be-deleted path either
433            // land on disk or surface as an error.
434            ctx.evict_cached(&path)?;
435            match fs::remove_file(&path) {
436                Ok(()) => {
437                    trace!(path = %path.display(), "delete file");
438                    Ok(())
439                }
440                Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
441                    warn!(path = %path.display(), "delete file: not found, ignored");
442                    Ok(())
443                }
444                Err(e) => Err(e.into()),
445            }
446        }
447        SqpkFileOperation::MakeDirTree => {
448            let path = generic_path(ctx, &cmd.path);
449            debug!(path = %path.display(), "make dir tree");
450            ctx.ensure_dir_all(&path)?;
451            Ok(())
452        }
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::io::Cursor;
460    use std::path::Path;
461
462    // --- write_empty_block ---
463
464    #[test]
465    fn write_empty_block_writes_correct_header_and_zeroed_body() {
466        // block_number=2: zeroes 2*128=256 bytes, then writes the 5-field LE
467        // header at offset 0.
468        let mut cur = Cursor::new(Vec::<u8>::new());
469        write_empty_block(&mut cur, 0, 2).unwrap();
470        let buf = cur.into_inner();
471
472        assert_eq!(
473            buf.len(),
474            256,
475            "block_number=2 must produce exactly 256 zeroed bytes"
476        );
477        // Bytes beyond the 20-byte header must all be zero.
478        assert!(
479            buf[20..].iter().all(|&b| b == 0),
480            "bytes after the header must remain zeroed"
481        );
482        // Header field layout (all LE u32).
483        assert_eq!(
484            &buf[0..4],
485            &128u32.to_le_bytes(),
486            "field 0: block-size marker must be 128"
487        );
488        assert_eq!(&buf[4..8], &0u32.to_le_bytes(), "field 1: must be 0");
489        assert_eq!(&buf[8..12], &0u32.to_le_bytes(), "field 2: must be 0");
490        assert_eq!(
491            &buf[12..16],
492            &1u32.to_le_bytes(),
493            "field 3: block_number.wrapping_sub(1) must be 1"
494        );
495        assert_eq!(&buf[16..20], &0u32.to_le_bytes(), "field 4: must be 0");
496    }
497
498    #[test]
499    fn write_empty_block_rejects_zero_block_number() {
500        let mut cur = Cursor::new(Vec::<u8>::new());
501        let err = write_empty_block(&mut cur, 0, 0).expect_err("block_number=0 must be rejected");
502        assert_eq!(
503            err.kind(),
504            std::io::ErrorKind::InvalidInput,
505            "zero block_number must produce InvalidInput error kind"
506        );
507    }
508
509    #[test]
510    fn write_empty_block_at_nonzero_offset_seeks_correctly() {
511        // offset=128: should write 128 zero bytes at position 128, then write
512        // the header at position 128.  The first 128 bytes must remain untouched
513        // (i.e. whatever was there before).
514        let initial = vec![0xABu8; 256];
515        let mut cur = Cursor::new(initial);
516        write_empty_block(&mut cur, 128, 1).unwrap();
517        let buf = cur.into_inner();
518
519        // First 128 bytes untouched.
520        assert!(
521            buf[..128].iter().all(|&b| b == 0xAB),
522            "bytes before offset must be untouched"
523        );
524        // Bytes from offset 128 to 148 are the header; rest zeroed.
525        assert_eq!(
526            &buf[128..132],
527            &128u32.to_le_bytes(),
528            "header marker at offset 128"
529        );
530    }
531
532    // --- keep_in_remove_all ---
533
534    #[test]
535    fn keep_in_remove_all_var_extension_always_kept() {
536        assert!(
537            keep_in_remove_all(Path::new("path/to/something.var")),
538            ".var files must be kept"
539        );
540        // .var at root.
541        assert!(keep_in_remove_all(Path::new("ffxiv.var")));
542    }
543
544    #[test]
545    fn keep_in_remove_all_bk2_00000_through_00003_kept() {
546        for name in &["00000.bk2", "00001.bk2", "00002.bk2", "00003.bk2"] {
547            assert!(keep_in_remove_all(Path::new(name)), "{name} must be kept");
548        }
549    }
550
551    #[test]
552    fn keep_in_remove_all_bk2_00004_and_beyond_deleted() {
553        for name in &["00004.bk2", "00005.bk2", "00099.bk2"] {
554            assert!(
555                !keep_in_remove_all(Path::new(name)),
556                "{name} must NOT be kept"
557            );
558        }
559    }
560
561    #[test]
562    fn keep_in_remove_all_sqpack_dat_and_index_deleted() {
563        assert!(!keep_in_remove_all(Path::new("040100.win32.dat0")));
564        assert!(!keep_in_remove_all(Path::new("040100.win32.index")));
565    }
566
567    #[test]
568    fn keep_in_remove_all_prefixed_bk2_not_kept() {
569        // The match is exact on the base name — a file like prefix00000.bk2
570        // must NOT be kept.
571        assert!(!keep_in_remove_all(Path::new("prefix00000.bk2")));
572    }
573
574    #[test]
575    fn keep_in_remove_all_path_without_filename_not_kept() {
576        // A path component with no file_name (e.g. "/") exercises the
577        // `let Some(name) = … else { return false; }` arm (line 102).
578        assert!(
579            !keep_in_remove_all(Path::new("/")),
580            "root path with no filename must return false, not panic"
581        );
582    }
583
584    // --- SqpkCommand dispatch: Index and PatchInfo are no-ops ---
585
586    #[test]
587    fn sqpk_command_index_apply_is_noop() {
588        use crate::chunk::SqpackFile;
589        use crate::chunk::sqpk::{IndexCommand, SqpkIndex};
590
591        let index_cmd = SqpkIndex {
592            command: IndexCommand::Add,
593            is_synonym: false,
594            target_file: SqpackFile {
595                main_id: 0,
596                sub_id: 0,
597                file_id: 0,
598            },
599            file_hash: 0,
600            block_offset: 0,
601            block_number: 0,
602        };
603        let cmd = SqpkCommand::Index(index_cmd);
604        let tmp = tempfile::tempdir().unwrap();
605        let mut ctx = ApplyContext::new(tmp.path());
606        // Must return Ok(()) without touching the filesystem.
607        cmd.apply(&mut ctx).unwrap();
608    }
609
610    #[test]
611    fn sqpk_command_patch_info_apply_is_noop() {
612        use crate::chunk::sqpk::SqpkPatchInfo;
613
614        let patch_info = SqpkPatchInfo {
615            status: 0,
616            version: 0,
617            install_size: 0,
618        };
619        let cmd = SqpkCommand::PatchInfo(patch_info);
620        let tmp = tempfile::tempdir().unwrap();
621        let mut ctx = ApplyContext::new(tmp.path());
622        cmd.apply(&mut ctx).unwrap();
623    }
624
625    // --- apply_file AddFile: parent directory creation ---
626
627    #[test]
628    fn add_file_creates_parent_directories_automatically() {
629        // Exercises the `fs::create_dir_all(parent)?` branch (line 403):
630        // the path "deep/nested/file.dat" requires two directory levels that
631        // do not yet exist in the temp dir.
632        use crate::apply::Apply;
633        use crate::chunk::sqpk::{SqpkCompressedBlock, SqpkFile, SqpkFileOperation};
634
635        let file_cmd = SqpkFile {
636            operation: SqpkFileOperation::AddFile,
637            file_offset: 0,
638            file_size: 4,
639            expansion_id: 0,
640            path: "deep/nested/file.dat".to_owned(),
641            blocks: vec![SqpkCompressedBlock::new(false, 4, b"data".to_vec())],
642            block_source_offsets: vec![0],
643        };
644        let cmd = SqpkCommand::File(Box::new(file_cmd));
645
646        let tmp = tempfile::tempdir().unwrap();
647        let mut ctx = ApplyContext::new(tmp.path());
648        cmd.apply(&mut ctx).unwrap();
649        // `cmd.apply` writes through `BufWriter`; explicit flush is required
650        // before reading the on-disk state because `apply_to`'s end-of-stream
651        // auto-flush is not in play when callers drive `Apply::apply` directly.
652        ctx.flush().unwrap();
653
654        let target = tmp.path().join("deep").join("nested").join("file.dat");
655        assert!(
656            target.is_file(),
657            "AddFile must create parent directories and write the file"
658        );
659        assert_eq!(
660            std::fs::read(&target).unwrap(),
661            b"data",
662            "file contents must match the block payload"
663        );
664    }
665
666    // --- apply_file AddFile: negative file_offset rejected ---
667
668    #[test]
669    fn add_file_negative_offset_returns_negative_file_offset_error() {
670        // Exercises the `u64::try_from(cmd.file_offset).map_err(|_| NegativeFileOffset)?`
671        // arm. The wire format stores file_offset as u64 but is cast to i64 after
672        // parsing, so a value with the high bit set arrives here as a negative i64.
673        let file_cmd = SqpkFile {
674            operation: SqpkFileOperation::AddFile,
675            file_offset: -1,
676            file_size: 0,
677            expansion_id: 0,
678            path: "neg_offset.dat".to_owned(),
679            blocks: vec![],
680            block_source_offsets: vec![],
681        };
682        let cmd = SqpkCommand::File(Box::new(file_cmd));
683
684        let tmp = tempfile::tempdir().unwrap();
685        let mut ctx = ApplyContext::new(tmp.path());
686        let err = cmd.apply(&mut ctx).unwrap_err();
687        match err {
688            ZiPatchError::NegativeFileOffset(n) => assert_eq!(
689                n, -1,
690                "error must carry the original negative offset for diagnostics"
691            ),
692            other => panic!("expected NegativeFileOffset(-1), got {other:?}"),
693        }
694    }
695
696    // --- apply_file DeleteFile: ignore_missing branches ---
697
698    fn delete_file_cmd(path: &str) -> SqpkCommand {
699        SqpkCommand::File(Box::new(SqpkFile {
700            operation: SqpkFileOperation::DeleteFile,
701            file_offset: 0,
702            file_size: 0,
703            expansion_id: 0,
704            path: path.to_owned(),
705            blocks: vec![],
706            block_source_offsets: vec![],
707        }))
708    }
709
710    #[test]
711    fn delete_file_removes_existing_file() {
712        let tmp = tempfile::tempdir().unwrap();
713        let target = tmp.path().join("victim.dat");
714        std::fs::write(&target, b"bye").unwrap();
715        assert!(target.is_file(), "pre-condition: file must exist");
716
717        let cmd = delete_file_cmd("victim.dat");
718        let mut ctx = ApplyContext::new(tmp.path());
719        cmd.apply(&mut ctx)
720            .expect("delete on an existing file must succeed");
721
722        assert!(!target.exists(), "file must be removed after DeleteFile");
723    }
724
725    #[test]
726    fn delete_file_missing_with_ignore_missing_returns_ok() {
727        // Exercises the `Err(NotFound) && ctx.ignore_missing` arm in apply_file's
728        // DeleteFile branch — warn-and-continue rather than propagating the error.
729        let tmp = tempfile::tempdir().unwrap();
730        let target = tmp.path().join("ghost.dat");
731        assert!(!target.exists(), "pre-condition: file must not exist");
732
733        let cmd = delete_file_cmd("ghost.dat");
734        let mut ctx = ApplyContext::new(tmp.path()).with_ignore_missing(true);
735        cmd.apply(&mut ctx)
736            .expect("missing file must be silently ignored when ignore_missing=true");
737    }
738
739    #[test]
740    fn delete_file_missing_without_ignore_missing_returns_not_found() {
741        // Companion to the above: with the flag off (default), the NotFound error
742        // must propagate as ZiPatchError::Io.
743        let tmp = tempfile::tempdir().unwrap();
744        let cmd = delete_file_cmd("ghost.dat");
745        let mut ctx = ApplyContext::new(tmp.path());
746
747        let err = cmd.apply(&mut ctx).unwrap_err();
748        match err {
749            ZiPatchError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {}
750            other => panic!("expected Io(NotFound), got {other:?}"),
751        }
752    }
753}