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.
53// PR2: exposed for IndexApplier
54pub(crate) fn write_zeros(w: &mut impl Write, len: u64) -> std::io::Result<()> {
55 static BUF: [u8; 64 * 1024] = [0; 64 * 1024];
56 let mut remaining = len;
57 while remaining > 0 {
58 let n = remaining.min(BUF.len() as u64) as usize;
59 w.write_all(&BUF[..n])?;
60 remaining -= n as u64;
61 }
62 Ok(())
63}
64
65/// Write a `SqPack` empty-block header at `offset` and zero the full block range.
66///
67/// The block range is `block_number * 128` bytes starting at `offset`. After
68/// zeroing, a 5-field little-endian header is written at `offset`:
69///
70/// | Offset | Size | Value |
71/// |--------|------|-------|
72/// | 0 | 4 | `128` — block-size marker |
73/// | 4 | 4 | `0` |
74/// | 8 | 4 | `0` |
75/// | 12 | 4 | `block_number - 1` — "next free" count |
76/// | 16 | 4 | `0` |
77///
78/// `block_number` must be non-zero; the function returns
79/// [`std::io::ErrorKind::InvalidInput`] otherwise, because a zero-block range
80/// has no meaningful on-disk representation and would silently skip the seek.
81// PR2: exposed for IndexApplier
82pub(crate) fn write_empty_block(
83 f: &mut (impl Write + Seek),
84 offset: u64,
85 block_number: u32,
86) -> std::io::Result<()> {
87 if block_number == 0 {
88 return Err(std::io::Error::new(
89 std::io::ErrorKind::InvalidInput,
90 "block_number must be non-zero",
91 ));
92 }
93 f.seek(SeekFrom::Start(offset))?;
94 write_zeros(f, (block_number as u64) << 7)?;
95 f.seek(SeekFrom::Start(offset))?;
96 f.write_all(&empty_block_header(block_number))?;
97 Ok(())
98}
99
100/// Return the canonical 20-byte `SqPack` empty-block header for `block_number`.
101///
102/// The bytes are identical to what [`write_empty_block`] places at the start
103/// of the region: five little-endian `u32` fields `[128, 0, 0,
104/// block_number - 1, 0]`. Exposed for verifier / CRC paths that need to
105/// match the on-disk header byte-for-byte without materializing the full
106/// `block_number * 128` byte payload (see the streaming helpers in
107/// [`crate::index::plan`] and [`crate::index::verify`]).
108///
109/// `block_number` must be non-zero, mirroring [`write_empty_block`].
110pub(crate) fn empty_block_header(block_number: u32) -> [u8; 20] {
111 debug_assert!(block_number != 0, "block_number must be non-zero");
112 let mut h = [0u8; 20];
113 h[0..4].copy_from_slice(&128u32.to_le_bytes());
114 h[12..16].copy_from_slice(&block_number.wrapping_sub(1).to_le_bytes());
115 h
116}
117
118/// Decide whether a path should be preserved by `RemoveAll`.
119///
120/// `RemoveAll` deletes all files in an expansion folder's `sqpack/` and
121/// `movie/` subdirectories, but preserves:
122///
123/// - Any file whose name ends in `.var` (version markers used by the patcher).
124/// - The four introductory movie files `00000.bk2` through `00003.bk2`.
125/// Files named `00004.bk2` and beyond are deleted.
126///
127/// The `.bk2` match is exact on the base name — a file like
128/// `prefix00000.bk2` is **not** kept.
129// PR2: exposed for IndexApplier
130pub(crate) fn keep_in_remove_all(path: &Path) -> bool {
131 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
132 return false;
133 };
134 #[allow(clippy::case_sensitive_file_extension_comparisons)]
135 let is_var = name.ends_with(".var");
136 is_var || matches!(name, "00000.bk2" | "00001.bk2" | "00002.bk2" | "00003.bk2")
137}
138
139/// Dispatch an [`SqpkCommand`] to its specific apply function.
140///
141/// - [`SqpkCommand::TargetInfo`] — updates [`ApplyContext::platform`] in place.
142/// - [`SqpkCommand::Index`] and [`SqpkCommand::PatchInfo`] — metadata only;
143/// returns `Ok(())` without touching the filesystem.
144/// - All other variants — delegate to the write/delete functions below.
145impl Apply for SqpkCommand {
146 fn apply(&self, ctx: &mut ApplyContext) -> 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 [`ApplyContext::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::ZiPatchError::UnsupportedPlatform`] on the first lookup.
181#[allow(clippy::unnecessary_wraps)] // sibling dispatch arms all return Result<()>
182fn apply_target_info(cmd: &SqpkTargetInfo, ctx: &mut ApplyContext) -> 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.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::ZiPatchError::UnsupportedPlatform`] — the context's platform is
220/// [`Platform::Unknown`]; path resolution refuses to guess a layout.
221/// - [`crate::ZiPatchError::Io`] — file open, seek, or write failure.
222fn apply_add_data(cmd: &SqpkAddData, ctx: &mut ApplyContext) -> 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::ZiPatchError::UnsupportedPlatform`] — the context's platform is
245/// [`Platform::Unknown`]; path resolution refuses to guess a layout.
246/// - [`crate::ZiPatchError::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 ApplyContext) -> 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::ZiPatchError::UnsupportedPlatform`] — the context's platform is
268/// [`Platform::Unknown`]; path resolution refuses to guess a layout.
269/// - [`crate::ZiPatchError::Io`] — file open or write failure.
270fn apply_expand_data(cmd: &SqpkExpandData, ctx: &mut ApplyContext) -> 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::ZiPatchError::UnsupportedPlatform`] — the context's platform is
299/// [`Platform::Unknown`]; path resolution refuses to guess a layout.
300/// - [`crate::ZiPatchError::Io`] — file open, seek, or write failure.
301fn apply_header(cmd: &SqpkHeader, ctx: &mut ApplyContext) -> 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::ZiPatchError::Io`] — file open, `set_len`, seek, or write
339/// failure.
340/// - [`crate::ZiPatchError::NegativeFileOffset`] — `cmd.file_offset` is
341/// negative and cannot be converted to a `u64` seek position.
342/// - [`crate::ZiPatchError::Decompress`] — a DEFLATE block could not be
343/// decompressed.
344///
345/// ## `RemoveAll`
346///
347/// Deletes all files in `sqpack/<expansion>` and `movie/<expansion>` that are
348/// not in the keep-list (`.var` files and `00000`–`00003.bk2`). Before
349/// iterating the directories, **all** cached file handles are flushed to avoid
350/// open-handle conflicts on Windows.
351///
352/// Directories that do not exist are silently skipped.
353///
354/// ## Errors (`RemoveAll`)
355///
356/// - [`crate::ZiPatchError::Io`] — directory read or file deletion failure.
357///
358/// ## `DeleteFile`
359///
360/// Removes a single file at the path resolved from `cmd.path`. The cached
361/// handle for that path is evicted before the deletion (required on Windows;
362/// harmless on Linux).
363///
364/// If the file does not exist and [`ApplyContext::ignore_missing`] is `true`,
365/// the error is demoted to a `warn!` log and `Ok(())` is returned.
366///
367/// ## Errors (`DeleteFile`)
368///
369/// - [`crate::ZiPatchError::Io`] — deletion failed for a reason other than
370/// `NotFound`, or `NotFound` with `ignore_missing = false`.
371///
372/// ## `MakeDirTree`
373///
374/// Creates the directory tree at the path resolved from `cmd.path`,
375/// equivalent to `fs::create_dir_all`. Idempotent.
376///
377/// ## Errors (`MakeDirTree`)
378///
379/// - [`crate::ZiPatchError::Io`] — directory creation failed.
380fn apply_file(cmd: &SqpkFile, ctx: &mut ApplyContext) -> Result<()> {
381 match cmd.operation {
382 SqpkFileOperation::AddFile => {
383 let path = generic_path(ctx, &cmd.path);
384 trace!(path = %path.display(), file_offset = cmd.file_offset, blocks = cmd.blocks.len(), "add file");
385 if let Some(parent) = path.parent() {
386 ctx.ensure_dir_all(parent)?;
387 }
388 let writer = ctx.open_cached(&path)?;
389 if cmd.file_offset == 0 {
390 // `set_len` is on the raw `File`, not on `BufWriter`. Flush
391 // any pending buffered writes destined for the pre-truncate
392 // offsets before reaching through to the underlying handle —
393 // otherwise the in-memory buffer would be silently dropped on
394 // the next seek and a write error would never surface.
395 writer.flush()?;
396 writer.get_mut().set_len(0)?;
397 }
398 let offset = u64::try_from(cmd.file_offset)
399 .map_err(|_| ZiPatchError::NegativeFileOffset(cmd.file_offset))?;
400 writer.seek(SeekFrom::Start(offset))?;
401 // Split-borrow the observer and the reusable DEFLATE state
402 // separately from the file-handle cache so we can poll
403 // cancellation and decompress each block in place while still
404 // holding the cached `&mut BufWriter<File>`. The entry is
405 // guaranteed to be in the cache because the `open_cached` call
406 // immediately above just inserted (or refreshed) it, and no
407 // cache-mutating call sits between them.
408 let observer: &mut dyn ApplyObserver = &mut *ctx.observer;
409 let decompressor = &mut ctx.decompressor;
410 let writer = ctx
411 .file_cache
412 .get_mut(&path)
413 .expect("open_cached above inserted this path");
414 for block in &cmd.blocks {
415 if observer.should_cancel() {
416 debug!(path = %path.display(), "add file: cancelled mid-blocks");
417 return Err(ZiPatchError::Cancelled);
418 }
419 block.decompress_into_with(decompressor, writer)?;
420 }
421 Ok(())
422 }
423 SqpkFileOperation::RemoveAll => {
424 // Flush all cached handles before bulk-deleting files — buffered
425 // writes against any of the about-to-be-removed paths must reach
426 // disk first, or be surfaced as an error rather than silently
427 // dropped when the file is unlinked.
428 ctx.clear_file_cache()?;
429 let folder = expansion_folder_id(cmd.expansion_id);
430 debug!(folder = %folder, "remove all");
431 for top in &["sqpack", "movie"] {
432 let dir = ctx.game_path.join(top).join(&folder);
433 if !dir.exists() {
434 continue;
435 }
436 for entry in fs::read_dir(&dir)? {
437 let path = entry?.path();
438 if path.is_file() && !keep_in_remove_all(&path) {
439 fs::remove_file(&path)?;
440 }
441 }
442 }
443 Ok(())
444 }
445 SqpkFileOperation::DeleteFile => {
446 let path = generic_path(ctx, &cmd.path);
447 // Flush and drop the cached handle before the OS delete so the
448 // fd is closed first (required on Windows; harmless on Linux),
449 // and any buffered writes against the to-be-deleted path either
450 // land on disk or surface as an error.
451 ctx.evict_cached(&path)?;
452 match fs::remove_file(&path) {
453 Ok(()) => {
454 trace!(path = %path.display(), "delete file");
455 Ok(())
456 }
457 Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
458 warn!(path = %path.display(), "delete file: not found, ignored");
459 Ok(())
460 }
461 Err(e) => Err(e.into()),
462 }
463 }
464 SqpkFileOperation::MakeDirTree => {
465 let path = generic_path(ctx, &cmd.path);
466 debug!(path = %path.display(), "make dir tree");
467 ctx.ensure_dir_all(&path)?;
468 Ok(())
469 }
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use std::io::Cursor;
477 use std::path::Path;
478
479 // --- write_empty_block ---
480
481 #[test]
482 fn write_empty_block_writes_correct_header_and_zeroed_body() {
483 // block_number=2: zeroes 2*128=256 bytes, then writes the 5-field LE
484 // header at offset 0.
485 let mut cur = Cursor::new(Vec::<u8>::new());
486 write_empty_block(&mut cur, 0, 2).unwrap();
487 let buf = cur.into_inner();
488
489 assert_eq!(
490 buf.len(),
491 256,
492 "block_number=2 must produce exactly 256 zeroed bytes"
493 );
494 // Bytes beyond the 20-byte header must all be zero.
495 assert!(
496 buf[20..].iter().all(|&b| b == 0),
497 "bytes after the header must remain zeroed"
498 );
499 // Header field layout (all LE u32).
500 assert_eq!(
501 &buf[0..4],
502 &128u32.to_le_bytes(),
503 "field 0: block-size marker must be 128"
504 );
505 assert_eq!(&buf[4..8], &0u32.to_le_bytes(), "field 1: must be 0");
506 assert_eq!(&buf[8..12], &0u32.to_le_bytes(), "field 2: must be 0");
507 assert_eq!(
508 &buf[12..16],
509 &1u32.to_le_bytes(),
510 "field 3: block_number.wrapping_sub(1) must be 1"
511 );
512 assert_eq!(&buf[16..20], &0u32.to_le_bytes(), "field 4: must be 0");
513 }
514
515 #[test]
516 fn write_empty_block_rejects_zero_block_number() {
517 let mut cur = Cursor::new(Vec::<u8>::new());
518 let err = write_empty_block(&mut cur, 0, 0).expect_err("block_number=0 must be rejected");
519 assert_eq!(
520 err.kind(),
521 std::io::ErrorKind::InvalidInput,
522 "zero block_number must produce InvalidInput error kind"
523 );
524 }
525
526 #[test]
527 fn write_empty_block_at_nonzero_offset_seeks_correctly() {
528 // offset=128: should write 128 zero bytes at position 128, then write
529 // the header at position 128. The first 128 bytes must remain untouched
530 // (i.e. whatever was there before).
531 let initial = vec![0xABu8; 256];
532 let mut cur = Cursor::new(initial);
533 write_empty_block(&mut cur, 128, 1).unwrap();
534 let buf = cur.into_inner();
535
536 // First 128 bytes untouched.
537 assert!(
538 buf[..128].iter().all(|&b| b == 0xAB),
539 "bytes before offset must be untouched"
540 );
541 // Bytes from offset 128 to 148 are the header; rest zeroed.
542 assert_eq!(
543 &buf[128..132],
544 &128u32.to_le_bytes(),
545 "header marker at offset 128"
546 );
547 }
548
549 // --- keep_in_remove_all ---
550
551 #[test]
552 fn keep_in_remove_all_var_extension_always_kept() {
553 assert!(
554 keep_in_remove_all(Path::new("path/to/something.var")),
555 ".var files must be kept"
556 );
557 // .var at root.
558 assert!(keep_in_remove_all(Path::new("ffxiv.var")));
559 }
560
561 #[test]
562 fn keep_in_remove_all_bk2_00000_through_00003_kept() {
563 for name in &["00000.bk2", "00001.bk2", "00002.bk2", "00003.bk2"] {
564 assert!(keep_in_remove_all(Path::new(name)), "{name} must be kept");
565 }
566 }
567
568 #[test]
569 fn keep_in_remove_all_bk2_00004_and_beyond_deleted() {
570 for name in &["00004.bk2", "00005.bk2", "00099.bk2"] {
571 assert!(
572 !keep_in_remove_all(Path::new(name)),
573 "{name} must NOT be kept"
574 );
575 }
576 }
577
578 #[test]
579 fn keep_in_remove_all_sqpack_dat_and_index_deleted() {
580 assert!(!keep_in_remove_all(Path::new("040100.win32.dat0")));
581 assert!(!keep_in_remove_all(Path::new("040100.win32.index")));
582 }
583
584 #[test]
585 fn keep_in_remove_all_prefixed_bk2_not_kept() {
586 // The match is exact on the base name — a file like prefix00000.bk2
587 // must NOT be kept.
588 assert!(!keep_in_remove_all(Path::new("prefix00000.bk2")));
589 }
590
591 #[test]
592 fn keep_in_remove_all_path_without_filename_not_kept() {
593 // A path component with no file_name (e.g. "/") exercises the
594 // `let Some(name) = … else { return false; }` arm (line 102).
595 assert!(
596 !keep_in_remove_all(Path::new("/")),
597 "root path with no filename must return false, not panic"
598 );
599 }
600
601 // --- SqpkCommand dispatch: Index and PatchInfo are no-ops ---
602
603 #[test]
604 fn sqpk_command_index_apply_is_noop() {
605 use crate::chunk::SqpackFile;
606 use crate::chunk::sqpk::{IndexCommand, SqpkIndex};
607
608 let index_cmd = SqpkIndex {
609 command: IndexCommand::Add,
610 is_synonym: false,
611 target_file: SqpackFile {
612 main_id: 0,
613 sub_id: 0,
614 file_id: 0,
615 },
616 file_hash: 0,
617 block_offset: 0,
618 block_number: 0,
619 };
620 let cmd = SqpkCommand::Index(index_cmd);
621 let tmp = tempfile::tempdir().unwrap();
622 let mut ctx = ApplyContext::new(tmp.path());
623 // Must return Ok(()) without touching the filesystem.
624 cmd.apply(&mut ctx).unwrap();
625 }
626
627 #[test]
628 fn sqpk_command_patch_info_apply_is_noop() {
629 use crate::chunk::sqpk::SqpkPatchInfo;
630
631 let patch_info = SqpkPatchInfo {
632 status: 0,
633 version: 0,
634 install_size: 0,
635 };
636 let cmd = SqpkCommand::PatchInfo(patch_info);
637 let tmp = tempfile::tempdir().unwrap();
638 let mut ctx = ApplyContext::new(tmp.path());
639 cmd.apply(&mut ctx).unwrap();
640 }
641
642 // --- apply_file AddFile: parent directory creation ---
643
644 #[test]
645 fn add_file_creates_parent_directories_automatically() {
646 // Exercises the `fs::create_dir_all(parent)?` branch (line 403):
647 // the path "deep/nested/file.dat" requires two directory levels that
648 // do not yet exist in the temp dir.
649 use crate::apply::Apply;
650 use crate::chunk::sqpk::{SqpkCompressedBlock, SqpkFile, SqpkFileOperation};
651
652 let file_cmd = SqpkFile {
653 operation: SqpkFileOperation::AddFile,
654 file_offset: 0,
655 file_size: 4,
656 expansion_id: 0,
657 path: "deep/nested/file.dat".to_owned(),
658 blocks: vec![SqpkCompressedBlock::new(false, 4, b"data".to_vec())],
659 block_source_offsets: vec![0],
660 };
661 let cmd = SqpkCommand::File(Box::new(file_cmd));
662
663 let tmp = tempfile::tempdir().unwrap();
664 let mut ctx = ApplyContext::new(tmp.path());
665 cmd.apply(&mut ctx).unwrap();
666 // `cmd.apply` writes through `BufWriter`; explicit flush is required
667 // before reading the on-disk state because `apply_to`'s end-of-stream
668 // auto-flush is not in play when callers drive `Apply::apply` directly.
669 ctx.flush().unwrap();
670
671 let target = tmp.path().join("deep").join("nested").join("file.dat");
672 assert!(
673 target.is_file(),
674 "AddFile must create parent directories and write the file"
675 );
676 assert_eq!(
677 std::fs::read(&target).unwrap(),
678 b"data",
679 "file contents must match the block payload"
680 );
681 }
682
683 // --- apply_file AddFile: negative file_offset rejected ---
684
685 #[test]
686 fn add_file_negative_offset_returns_negative_file_offset_error() {
687 // Exercises the `u64::try_from(cmd.file_offset).map_err(|_| NegativeFileOffset)?`
688 // arm. The wire format stores file_offset as u64 but is cast to i64 after
689 // parsing, so a value with the high bit set arrives here as a negative i64.
690 let file_cmd = SqpkFile {
691 operation: SqpkFileOperation::AddFile,
692 file_offset: -1,
693 file_size: 0,
694 expansion_id: 0,
695 path: "neg_offset.dat".to_owned(),
696 blocks: vec![],
697 block_source_offsets: vec![],
698 };
699 let cmd = SqpkCommand::File(Box::new(file_cmd));
700
701 let tmp = tempfile::tempdir().unwrap();
702 let mut ctx = ApplyContext::new(tmp.path());
703 let err = cmd.apply(&mut ctx).unwrap_err();
704 match err {
705 ZiPatchError::NegativeFileOffset(n) => assert_eq!(
706 n, -1,
707 "error must carry the original negative offset for diagnostics"
708 ),
709 other => panic!("expected NegativeFileOffset(-1), got {other:?}"),
710 }
711 }
712
713 // --- apply_file DeleteFile: ignore_missing branches ---
714
715 fn delete_file_cmd(path: &str) -> SqpkCommand {
716 SqpkCommand::File(Box::new(SqpkFile {
717 operation: SqpkFileOperation::DeleteFile,
718 file_offset: 0,
719 file_size: 0,
720 expansion_id: 0,
721 path: path.to_owned(),
722 blocks: vec![],
723 block_source_offsets: vec![],
724 }))
725 }
726
727 #[test]
728 fn delete_file_removes_existing_file() {
729 let tmp = tempfile::tempdir().unwrap();
730 let target = tmp.path().join("victim.dat");
731 std::fs::write(&target, b"bye").unwrap();
732 assert!(target.is_file(), "pre-condition: file must exist");
733
734 let cmd = delete_file_cmd("victim.dat");
735 let mut ctx = ApplyContext::new(tmp.path());
736 cmd.apply(&mut ctx)
737 .expect("delete on an existing file must succeed");
738
739 assert!(!target.exists(), "file must be removed after DeleteFile");
740 }
741
742 #[test]
743 fn delete_file_missing_with_ignore_missing_returns_ok() {
744 // Exercises the `Err(NotFound) && ctx.ignore_missing` arm in apply_file's
745 // DeleteFile branch — warn-and-continue rather than propagating the error.
746 let tmp = tempfile::tempdir().unwrap();
747 let target = tmp.path().join("ghost.dat");
748 assert!(!target.exists(), "pre-condition: file must not exist");
749
750 let cmd = delete_file_cmd("ghost.dat");
751 let mut ctx = ApplyContext::new(tmp.path()).with_ignore_missing(true);
752 cmd.apply(&mut ctx)
753 .expect("missing file must be silently ignored when ignore_missing=true");
754 }
755
756 #[test]
757 fn delete_file_missing_without_ignore_missing_returns_not_found() {
758 // Companion to the above: with the flag off (default), the NotFound error
759 // must propagate as ZiPatchError::Io.
760 let tmp = tempfile::tempdir().unwrap();
761 let cmd = delete_file_cmd("ghost.dat");
762 let mut ctx = ApplyContext::new(tmp.path());
763
764 let err = cmd.apply(&mut ctx).unwrap_err();
765 match err {
766 ZiPatchError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {}
767 other => panic!("expected Io(NotFound), got {other:?}"),
768 }
769 }
770
771 #[test]
772 fn empty_block_header_layout() {
773 let h = empty_block_header(8);
774 assert_eq!(&h[0..4], &128u32.to_le_bytes());
775 assert_eq!(&h[4..8], &0u32.to_le_bytes());
776 assert_eq!(&h[8..12], &0u32.to_le_bytes());
777 assert_eq!(&h[12..16], &7u32.to_le_bytes());
778 assert_eq!(&h[16..20], &0u32.to_le_bytes());
779 }
780
781 #[test]
782 fn empty_block_header_matches_write_empty_block_prefix() {
783 // The first 20 bytes of write_empty_block's output must equal the
784 // header helper — guards against the two paths drifting apart.
785 let units = 4u32;
786 let mut buf = Vec::with_capacity((units as usize) * 128);
787 write_empty_block(&mut std::io::Cursor::new(&mut buf), 0, units).unwrap();
788 assert_eq!(&buf[..20], &empty_block_header(units));
789 }
790}