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}