Skip to main content

zipatch_rs/apply/
sqpk.rs

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