zipatch_rs/index/plan.rs
1//! Pure-data plan describing every region a patch writes to every target file.
2//!
3//! A [`Plan`] is built once from a [`crate::ZiPatchReader`] by
4//! [`crate::index::PlanBuilder`] and then handed to verifier / applier code that
5//! actually touches disk. Nothing here resolves filesystem paths, opens files,
6//! or fetches bytes from the patch source.
7
8use crate::Platform;
9use crate::Result;
10use crate::index::apply::decompress_full;
11use crate::index::source::PatchSource;
12use std::collections::HashMap;
13use tracing::{info, info_span, warn};
14
15/// 4-byte ASCII patch-type tag carried by a `FHDR` chunk.
16///
17/// In retail FFXIV patches this is always `D000` (game-data) or `H000`
18/// (boot/header). The variant is kept open via `#[non_exhaustive]` and the
19/// `Other` arm so future tags surface unchanged for diagnostics.
20#[non_exhaustive]
21#[derive(Debug, Clone, PartialEq, Eq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub enum PatchType {
24 /// `D000` — game-data patch.
25 GameData,
26 /// `H000` — boot/header patch.
27 Boot,
28 /// Any other 4-byte ASCII tag, preserved verbatim.
29 Other([u8; 4]),
30}
31
32impl PatchType {
33 /// Map the raw 4-byte tag from `FHDR` to a [`PatchType`].
34 #[must_use]
35 pub fn from_tag(tag: [u8; 4]) -> Self {
36 match &tag {
37 b"D000" => PatchType::GameData,
38 b"H000" => PatchType::Boot,
39 _ => PatchType::Other(tag),
40 }
41 }
42}
43
44/// Reference to a single source patch file from which a [`Plan`] is built.
45///
46/// A [`Plan`] always carries the full chain of patches that produced it in
47/// [`Plan::patches`], in chain order. [`PartSource::Patch::patch_idx`] is an index
48/// into that slice.
49#[non_exhaustive]
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub struct PatchRef {
53 /// Human-readable patch name (typically the filename without extension).
54 pub name: String,
55 /// `FHDR` patch-type tag if the patch declared one.
56 pub patch_type: Option<PatchType>,
57}
58
59impl PatchRef {
60 /// Construct a [`PatchRef`] from its component parts.
61 ///
62 /// Pairs with [`Plan::new`] for callers outside this crate after the
63 /// struct picked up `#[non_exhaustive]`.
64 #[must_use]
65 pub fn new(name: impl Into<String>, patch_type: Option<PatchType>) -> Self {
66 Self {
67 name: name.into(),
68 patch_type,
69 }
70 }
71}
72
73/// Path identity of a single target file the plan writes to.
74///
75/// Resolution to a concrete [`std::path::PathBuf`] lives in the applier; the
76/// plan only carries the symbolic identity so it can be compared, hashed, and
77/// serialised without an `ApplyContext`.
78#[non_exhaustive]
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
81pub enum TargetPath {
82 /// `SqPack` `.dat` file at `(main_id, sub_id, file_id)`.
83 SqpackDat {
84 /// `SqPack` category identifier.
85 main_id: u16,
86 /// `SqPack` sub-category identifier; high byte selects the expansion folder.
87 sub_id: u16,
88 /// `.dat` file index, appended directly as `.datN`.
89 file_id: u32,
90 },
91 /// `SqPack` `.index` file at `(main_id, sub_id, file_id)`.
92 SqpackIndex {
93 /// `SqPack` category identifier.
94 main_id: u16,
95 /// `SqPack` sub-category identifier; high byte selects the expansion folder.
96 sub_id: u16,
97 /// `.index` file index. `0` produces no numeric suffix; `> 0` appends directly.
98 file_id: u32,
99 },
100 /// Generic relative path under the game install root (e.g. an `SqpkFile`
101 /// `AddFile` target outside the `sqpack/` subtree).
102 Generic(String),
103}
104
105/// One target file's complete write profile.
106#[non_exhaustive]
107#[derive(Debug, Clone, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub struct Target {
110 /// Where the writes land.
111 pub path: TargetPath,
112 /// Highest `target_offset + length` produced by any region. May leave
113 /// trailing or interior gaps that the indexed applier must not touch.
114 pub final_size: u64,
115 /// Region timeline for this target, sorted by `target_offset` and
116 /// non-overlapping. Gaps between regions represent bytes the patch does
117 /// not modify (the sequential apply leaves them sparse / unchanged).
118 pub regions: Vec<Region>,
119}
120
121impl Target {
122 /// Construct a [`Target`] from its component parts.
123 ///
124 /// Pairs with [`Plan::new`] for callers outside this crate after the
125 /// struct picked up `#[non_exhaustive]`.
126 #[must_use]
127 pub fn new(path: TargetPath, final_size: u64, regions: Vec<Region>) -> Self {
128 Self {
129 path,
130 final_size,
131 regions,
132 }
133 }
134}
135
136/// One contiguous range of bytes the patch writes into a target file.
137#[non_exhaustive]
138#[derive(Debug, Clone, PartialEq, Eq)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
140pub struct Region {
141 /// First byte offset within the target file that this region writes.
142 pub target_offset: u64,
143 /// Length of the region in bytes.
144 pub length: u32,
145 /// Where the bytes come from.
146 pub source: PartSource,
147 /// What the post-write content should be (size-only by default).
148 pub expected: PartExpected,
149}
150
151impl Region {
152 /// Construct a [`Region`] from its component parts.
153 ///
154 /// Pairs with [`Plan::new`] for callers outside this crate after the
155 /// struct picked up `#[non_exhaustive]`.
156 #[must_use]
157 pub fn new(
158 target_offset: u64,
159 length: u32,
160 source: PartSource,
161 expected: PartExpected,
162 ) -> Self {
163 Self {
164 target_offset,
165 length,
166 source,
167 expected,
168 }
169 }
170}
171
172/// Where a [`Region`]'s bytes come from.
173#[non_exhaustive]
174#[derive(Debug, Clone, PartialEq, Eq)]
175#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
176pub enum PartSource {
177 /// Bytes live in the source patch file at [`Plan::patches`]`[patch_idx]`.
178 /// `offset` is **absolute within that patch file**, not chunk-relative —
179 /// the builder has already added the chunk's body position. `kind` selects
180 /// raw vs DEFLATE-encoded.
181 Patch {
182 /// Index into [`Plan::patches`] selecting which patch file holds the bytes.
183 patch_idx: u32,
184 /// Absolute byte offset within the patch file where the source bytes begin.
185 offset: u64,
186 /// How the source bytes are encoded at that offset.
187 kind: PatchSourceKind,
188 /// For [`PatchSourceKind::Deflated`] sources only: bytes to skip in the
189 /// decompressed output before writing this region. Always `0` for
190 /// [`PatchSourceKind::Raw`]. Used when a single DEFLATE block is split
191 /// across two regions by a cross-patch overlap — both halves share
192 /// `(patch_idx, offset, kind)` but slice the decompressed output differently.
193 decoded_skip: u16,
194 },
195 /// Region is a run of zero bytes (e.g. an `SqpkAddData` `block_delete_number`
196 /// trailing zero-fill).
197 Zeros,
198 /// Region is the canonical `SqPack` empty-block payload covering `units`
199 /// 128-byte blocks (`SqpkDeleteData` / `SqpkExpandData`).
200 EmptyBlock {
201 /// Number of 128-byte `SqPack` blocks (total length = `units * 128`).
202 units: u32,
203 },
204 /// Region exists in the plan but its source bytes are not reachable from
205 /// the [`PatchSource`] the applier will be given. The builder does not
206 /// emit this variant from any in-tree chunk parser; it is provided for
207 /// hand-constructed plans (or deserialized plans) that intentionally name
208 /// a region without backing bytes. [`crate::index::IndexApplier::execute`]
209 /// surfaces these as
210 /// [`crate::ZiPatchError::IndexSourceUnavailable`], and
211 /// [`crate::index::Verifier`] always flags them as needing repair.
212 Unavailable,
213}
214
215/// Encoding of a [`PartSource::Patch`]'s bytes at its absolute patch-file offset.
216#[non_exhaustive]
217#[derive(Debug, Clone, PartialEq, Eq)]
218#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
219pub enum PatchSourceKind {
220 /// Bytes are stored verbatim at `offset` and have length `len`.
221 Raw {
222 /// Length of the raw payload in bytes.
223 len: u32,
224 },
225 /// Bytes are a raw DEFLATE stream at `offset` of `compressed_len` bytes,
226 /// producing `decompressed_len` bytes of output.
227 Deflated {
228 /// Length of the DEFLATE-encoded payload at `offset` (may include
229 /// trailing 128-byte alignment padding past the end-of-stream marker —
230 /// the decoder stops at `StreamEnd` and ignores the tail).
231 compressed_len: u32,
232 /// Number of output bytes produced after decompression.
233 decompressed_len: u32,
234 },
235}
236
237/// Post-write content invariant for a [`Region`].
238///
239/// By default only the sentinel-source regions (`Zeros`, `EmptyBlock`) carry a
240/// meaningful expectation; `Patch`-sourced regions default to
241/// [`PartExpected::SizeOnly`]. Call [`Plan::compute_crc32`] to populate every
242/// region (including `Patch`) with [`PartExpected::Crc32`].
243#[non_exhaustive]
244#[derive(Debug, Clone, PartialEq, Eq)]
245#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
246pub enum PartExpected {
247 /// Only the byte count is checked.
248 SizeOnly,
249 /// CRC32 of the region's expected output bytes (populated by
250 /// [`Plan::compute_crc32`]; not emitted by [`PlanBuilder`](crate::index::PlanBuilder)
251 /// out of the box).
252 Crc32(u32),
253 /// Region must be all-zero bytes.
254 Zeros,
255 /// Region must match the canonical `SqPack` empty-block payload for `units`
256 /// 128-byte blocks.
257 EmptyBlock {
258 /// Number of 128-byte `SqPack` blocks.
259 units: u32,
260 },
261}
262
263/// Filesystem-level operation that runs before any region writes.
264#[non_exhaustive]
265#[derive(Debug, Clone, PartialEq, Eq)]
266#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
267pub enum FilesystemOp {
268 /// Ensure the directory at this relative path (joined under the install
269 /// root) exists. Idempotent.
270 EnsureDir(String),
271 /// Remove the empty directory at this relative path.
272 DeleteDir(String),
273 /// Remove the file at this relative path.
274 DeleteFile(String),
275 /// Create the directory tree at this relative path (equivalent to
276 /// `fs::create_dir_all`).
277 MakeDirTree(String),
278 /// Bulk-remove all non-keep-listed files in an expansion folder. The
279 /// applier owns the keep-list policy; the plan only names the expansion.
280 RemoveAllInExpansion(u16),
281}
282
283/// Complete write plan for a chain of one or more source patches.
284///
285/// # Schema versioning
286///
287/// Under the `serde` feature, every [`Plan`] carries a `schema_version: u32`
288/// that records the in-memory layout this build emits. The current value is
289/// exposed as [`Plan::CURRENT_SCHEMA_VERSION`] and is currently `1`.
290///
291/// **Compatibility policy:** the schema version is bumped any time a new
292/// **required** field is added to [`Plan`] or any of the types it transitively
293/// contains. Additive *optional* fields (defaulted via `#[serde(default)]`)
294/// do not bump the version. On deserialize, a [`Plan`] whose persisted
295/// `schema_version` does not equal [`Plan::CURRENT_SCHEMA_VERSION`] is
296/// rejected with [`crate::ZiPatchError::PlanSchemaVersionMismatch`] — older
297/// readers refuse to silently drop fields they cannot represent, rather than
298/// risk an apply against a partial plan. Callers persisting plans across
299/// crate-version boundaries should be prepared to rebuild the plan from the
300/// patch chain on mismatch.
301#[non_exhaustive]
302#[derive(Debug, Clone, PartialEq, Eq)]
303#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
304pub struct Plan {
305 /// Schema-format version of this persisted plan. See the
306 /// [type-level docs](Plan) for the compatibility policy. New plans
307 /// constructed via [`Plan::new`] / [`PlanBuilder`](crate::index::PlanBuilder)
308 /// always carry [`Plan::CURRENT_SCHEMA_VERSION`].
309 #[cfg_attr(feature = "serde", serde(default = "Plan::default_schema_version"))]
310 pub schema_version: u32,
311 /// Target platform pinned by the chain's most recent `SqpkTargetInfo`
312 /// chunk. Defaults to [`Platform::Win32`] when no `TargetInfo` is seen.
313 pub platform: Platform,
314 /// Source patches in chain order. [`PartSource::Patch::patch_idx`] indexes
315 /// into this vector; the applier asks the caller's [`crate::index::PatchSource`]
316 /// for bytes by `(patch_idx, offset)`.
317 pub patches: Vec<PatchRef>,
318 /// Per-target write timelines, reflecting the chain's end state — regions
319 /// killed by a mid-chain `RemoveAll`, `DeleteFile`, or `AddFile@0` are
320 /// dropped at build time, not at apply time.
321 pub targets: Vec<Target>,
322 /// Filesystem operations to run before any region writes, in chain order.
323 /// Destructive ops (`DeleteFile`, `DeleteDir`, `RemoveAllInExpansion`) are
324 /// preserved so that the applier still removes any pre-chain artefacts
325 /// that the plan's region set does not re-create.
326 pub fs_ops: Vec<FilesystemOp>,
327}
328
329impl Plan {
330 /// Current on-disk schema version for [`Plan`] under the `serde` feature.
331 /// See the [type-level docs](Plan) for the compatibility policy.
332 pub const CURRENT_SCHEMA_VERSION: u32 = 1;
333
334 /// Default used by serde to populate `schema_version` on plans persisted
335 /// before the field was introduced. Returns `Self::CURRENT_SCHEMA_VERSION`
336 /// because the only pre-versioning shape that exists in the wild is the
337 /// one this build still understands; deliberate version mismatches must
338 /// be authored explicitly.
339 #[cfg(feature = "serde")]
340 #[must_use]
341 fn default_schema_version() -> u32 {
342 Self::CURRENT_SCHEMA_VERSION
343 }
344
345 /// Validate that `self.schema_version` matches
346 /// [`Self::CURRENT_SCHEMA_VERSION`]. Intended to be called by consumers
347 /// immediately after deserializing a [`Plan`] from persistent storage,
348 /// before handing it to the applier or verifier.
349 ///
350 /// # Errors
351 ///
352 /// Returns [`crate::ZiPatchError::PlanSchemaVersionMismatch`] when the
353 /// persisted version does not equal [`Self::CURRENT_SCHEMA_VERSION`].
354 pub fn check_schema_version(&self) -> Result<()> {
355 if self.schema_version != Self::CURRENT_SCHEMA_VERSION {
356 return Err(crate::ZiPatchError::PlanSchemaVersionMismatch {
357 persisted: self.schema_version,
358 expected: Self::CURRENT_SCHEMA_VERSION,
359 });
360 }
361 Ok(())
362 }
363
364 /// Construct a [`Plan`] from its component parts.
365 ///
366 /// Exists so callers outside this crate can still build synthetic plans
367 /// after the struct picked up `#[non_exhaustive]` for `SemVer` hygiene —
368 /// in-crate code may continue to use the struct literal form directly.
369 /// The constructed plan always carries
370 /// [`Plan::CURRENT_SCHEMA_VERSION`].
371 #[must_use]
372 pub fn new(
373 platform: Platform,
374 patches: Vec<PatchRef>,
375 targets: Vec<Target>,
376 fs_ops: Vec<FilesystemOp>,
377 ) -> Self {
378 Self {
379 schema_version: Self::CURRENT_SCHEMA_VERSION,
380 platform,
381 patches,
382 targets,
383 fs_ops,
384 }
385 }
386
387 /// Walk every region and populate [`PartExpected::Crc32`] with the CRC32
388 /// of the region's effective output bytes.
389 ///
390 /// - [`PartSource::Patch`] regions read the source bytes via `source` and,
391 /// for [`PatchSourceKind::Deflated`] sources, decompress them in full
392 /// before slicing the `[decoded_skip..decoded_skip + region.length]`
393 /// window and CRC32-ing the slice. A single shared
394 /// [`flate2::Decompress`] is reused across every Deflated region.
395 /// - [`PartSource::Zeros`] regions use the canonical all-zero payload,
396 /// cached per-length so plans with many same-sized Zeros regions hit
397 /// `crc32fast::hash` once per unique length.
398 /// - [`PartSource::EmptyBlock`] regions use the canonical empty-block
399 /// payload the apply layer's internal `write_empty_block` helper
400 /// produces (a 20-byte `SqPack` empty-block header followed by
401 /// `units * 128 - 20` zero bytes), cached per-`units`.
402 /// - [`PartSource::Unavailable`] regions are left with their existing
403 /// [`PartExpected`] (typically [`PartExpected::SizeOnly`]). A single
404 /// `tracing::warn!` summary fires per call with the total count of
405 /// skipped regions — per-region tracing happens at `trace!`.
406 ///
407 /// Once populated, [`crate::index::Verifier`] uses
408 /// [`PartExpected::Crc32`] to detect single-byte damage inside `Patch`
409 /// regions, which the v1 size-only policy missed.
410 ///
411 /// # Errors
412 ///
413 /// Surfaces any [`crate::ZiPatchError`] produced by `source.read` or by
414 /// DEFLATE decompression of a `Patch` region's bytes.
415 ///
416 /// # Atomicity
417 ///
418 /// All-or-nothing: a successful return commits a new
419 /// [`PartExpected::Crc32`] to every applicable region; an error return
420 /// leaves every region's `expected` field exactly as it was at call entry.
421 /// CRCs are computed into a side buffer first and only flushed back into
422 /// the plan after the whole pass succeeds, so a midway `source.read`
423 /// failure cannot leave the plan partially populated.
424 pub fn compute_crc32<S: PatchSource>(&mut self, source: &mut S) -> Result<()> {
425 let span = info_span!("compute_crc32", targets = self.targets.len());
426 let _enter = span.enter();
427
428 let mut compressed_scratch: Vec<u8> = Vec::new();
429 let mut decompressed_scratch: Vec<u8> = Vec::new();
430 let mut decompressor = flate2::Decompress::new(false);
431 // Unique region lengths / `units` counts are tiny in practice; pre-size
432 // both caches to skip the initial `HashMap` resize.
433 let mut zeros_cache: HashMap<u32, u32> = HashMap::with_capacity(4);
434 let mut empty_block_cache: HashMap<u32, u32> = HashMap::with_capacity(4);
435
436 // Stage every successful CRC into a side buffer. The plan's
437 // `expected` fields are only mutated after the whole pass completes,
438 // so an `Err` surfaced midway leaves the plan untouched.
439 let mut updates: Vec<(usize, usize, u32)> = Vec::new();
440 let mut unavailable_skipped: usize = 0;
441
442 for (t_idx, target) in self.targets.iter().enumerate() {
443 for (r_idx, region) in target.regions.iter().enumerate() {
444 match ®ion.source {
445 PartSource::Patch {
446 patch_idx,
447 offset,
448 kind,
449 decoded_skip,
450 } => {
451 let crc = patch_region_crc(
452 source,
453 *patch_idx,
454 *offset,
455 kind,
456 *decoded_skip,
457 region.length,
458 &mut compressed_scratch,
459 &mut decompressed_scratch,
460 &mut decompressor,
461 )?;
462 updates.push((t_idx, r_idx, crc));
463 }
464 PartSource::Zeros => {
465 let crc = *zeros_cache
466 .entry(region.length)
467 .or_insert_with(|| crc32_of_zeros(region.length));
468 updates.push((t_idx, r_idx, crc));
469 }
470 PartSource::EmptyBlock { units } => {
471 let crc = match empty_block_cache.entry(*units) {
472 std::collections::hash_map::Entry::Occupied(e) => *e.get(),
473 std::collections::hash_map::Entry::Vacant(e) => {
474 let crc = crc32_of_empty_block(*units)?;
475 *e.insert(crc)
476 }
477 };
478 updates.push((t_idx, r_idx, crc));
479 }
480 PartSource::Unavailable => {
481 unavailable_skipped += 1;
482 tracing::trace!(
483 target_idx = t_idx,
484 region_idx = r_idx,
485 target_offset = region.target_offset,
486 length = region.length,
487 "compute_crc32: skipping Unavailable region"
488 );
489 }
490 }
491 }
492 }
493
494 // Commit phase — every read succeeded, so flush the staged values.
495 let populated = updates.len();
496 for (t_idx, r_idx, crc) in updates {
497 self.targets[t_idx].regions[r_idx].expected = PartExpected::Crc32(crc);
498 }
499 if unavailable_skipped > 0 {
500 warn!(
501 skipped = unavailable_skipped,
502 "compute_crc32: left Unavailable regions with their existing expected"
503 );
504 }
505 info!(
506 populated,
507 skipped = unavailable_skipped,
508 "compute_crc32: populated CRC32 for regions"
509 );
510 Ok(())
511 }
512}
513
514#[allow(clippy::too_many_arguments)]
515fn patch_region_crc<S: PatchSource>(
516 source: &mut S,
517 patch_idx: u32,
518 offset: u64,
519 kind: &PatchSourceKind,
520 decoded_skip: u16,
521 length: u32,
522 compressed_scratch: &mut Vec<u8>,
523 decompressed_scratch: &mut Vec<u8>,
524 decompressor: &mut flate2::Decompress,
525) -> Result<u32> {
526 match *kind {
527 PatchSourceKind::Raw { len } => {
528 let len_us = len as usize;
529 if compressed_scratch.len() < len_us {
530 compressed_scratch.resize(len_us, 0);
531 }
532 source.read(patch_idx, offset, &mut compressed_scratch[..len_us])?;
533 // Raw never carries decoded_skip; kind.len always equals region.length
534 // after any truncation, so the whole filled slice is the region.
535 Ok(crc32fast::hash(&compressed_scratch[..len_us]))
536 }
537 PatchSourceKind::Deflated {
538 compressed_len,
539 decompressed_len,
540 } => {
541 let comp_us = compressed_len as usize;
542 if compressed_scratch.len() < comp_us {
543 compressed_scratch.resize(comp_us, 0);
544 }
545 source.read(patch_idx, offset, &mut compressed_scratch[..comp_us])?;
546 let produced = decompress_full(
547 decompressor,
548 &compressed_scratch[..comp_us],
549 decompressed_len,
550 decompressed_scratch,
551 )?;
552 let skip = decoded_skip as usize;
553 let end = skip + length as usize;
554 // `decompressed_scratch` is reused without zeroing — clamp to the
555 // bytes the decoder actually produced so the hash never folds in
556 // stale data from a prior region's decompression.
557 let clamped_end = end.min(produced);
558 let clamped_start = skip.min(clamped_end);
559 Ok(crc32fast::hash(
560 &decompressed_scratch[clamped_start..clamped_end],
561 ))
562 }
563 }
564}
565
566fn crc32_of_zeros(length: u32) -> u32 {
567 // Stream the all-zero payload through a shared 64 KiB buffer; same trick
568 // as `write_zeros` in the apply layer. Avoids allocating a fresh
569 // `vec![0; length]` for every unique zero-region length on huge plans.
570 static ZERO_BUF: [u8; 64 * 1024] = [0; 64 * 1024];
571 let mut hasher = crc32fast::Hasher::new();
572 let mut remaining = length as u64;
573 while remaining > 0 {
574 let n = remaining.min(ZERO_BUF.len() as u64) as usize;
575 hasher.update(&ZERO_BUF[..n]);
576 remaining -= n as u64;
577 }
578 hasher.finalize()
579}
580
581fn crc32_of_empty_block(units: u32) -> Result<u32> {
582 // Stream the canonical payload through the hasher in 64 KiB chunks so the
583 // work is bounded by hashing throughput, not by `units * 128` allocation.
584 // A pathological plan with `units` near `MAX_UNITS_PER_REGION` would
585 // otherwise request a ~4 GiB buffer here (see issue #32). The byte layout
586 // (20-byte header + zeros) matches `apply::sqpk::write_empty_block` by
587 // sharing the `empty_block_header` helper.
588 static ZERO_BUF: [u8; 64 * 1024] = [0; 64 * 1024];
589 if units == 0 {
590 return Err(crate::ZiPatchError::InvalidField {
591 context: "EmptyBlock units must be non-zero",
592 });
593 }
594 let mut hasher = crc32fast::Hasher::new();
595 hasher.update(&crate::apply::sqpk::empty_block_header(units));
596 let mut remaining = u64::from(units) * 128 - 20;
597 while remaining > 0 {
598 let n = remaining.min(ZERO_BUF.len() as u64) as usize;
599 hasher.update(&ZERO_BUF[..n]);
600 remaining -= n as u64;
601 }
602 Ok(hasher.finalize())
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn patch_type_from_tag_known_values() {
611 assert_eq!(PatchType::from_tag(*b"D000"), PatchType::GameData);
612 assert_eq!(PatchType::from_tag(*b"H000"), PatchType::Boot);
613 assert_eq!(
614 PatchType::from_tag(*b"Z999"),
615 PatchType::Other(*b"Z999"),
616 "unknown tags must round-trip through Other"
617 );
618 }
619
620 #[test]
621 fn part_source_variants_are_clone_partial_eq() {
622 let a = PartSource::Patch {
623 patch_idx: 0,
624 offset: 1024,
625 kind: PatchSourceKind::Raw { len: 128 },
626 decoded_skip: 0,
627 };
628 let b = a.clone();
629 assert_eq!(a, b);
630
631 let z1 = PartSource::Zeros;
632 let z2 = PartSource::Zeros;
633 assert_eq!(z1, z2);
634
635 let e1 = PartSource::EmptyBlock { units: 4 };
636 let e2 = PartSource::EmptyBlock { units: 4 };
637 assert_eq!(e1, e2);
638 assert_ne!(e1, PartSource::EmptyBlock { units: 5 });
639 }
640
641 #[test]
642 fn patch_source_kind_distinguishes_raw_and_deflated() {
643 let raw = PatchSourceKind::Raw { len: 16 };
644 let def = PatchSourceKind::Deflated {
645 compressed_len: 8,
646 decompressed_len: 16,
647 };
648 assert_ne!(raw, def);
649 // Same kind, different decompressed_len → not equal.
650 assert_ne!(
651 def,
652 PatchSourceKind::Deflated {
653 compressed_len: 8,
654 decompressed_len: 32,
655 }
656 );
657 }
658
659 #[test]
660 fn part_expected_variants_round_trip() {
661 let cases = [
662 PartExpected::SizeOnly,
663 PartExpected::Crc32(0xDEAD_BEEF),
664 PartExpected::Zeros,
665 PartExpected::EmptyBlock { units: 2 },
666 ];
667 for c in &cases {
668 assert_eq!(c, &c.clone());
669 }
670 }
671
672 #[test]
673 fn filesystem_op_variants_round_trip() {
674 let ops = [
675 FilesystemOp::EnsureDir("sqpack/ffxiv".to_owned()),
676 FilesystemOp::DeleteDir("old".to_owned()),
677 FilesystemOp::DeleteFile("dead.dat".to_owned()),
678 FilesystemOp::MakeDirTree("sqpack/ex1".to_owned()),
679 FilesystemOp::RemoveAllInExpansion(2),
680 ];
681 for op in &ops {
682 assert_eq!(op, &op.clone());
683 }
684 }
685
686 // ---- compute_crc32 ----
687
688 use crate::index::source::MemoryPatchSource;
689 use flate2::Compression;
690 use flate2::write::DeflateEncoder;
691 use std::io::Write;
692
693 fn plan_with_regions(regions: Vec<Region>) -> Plan {
694 Plan {
695 schema_version: Plan::CURRENT_SCHEMA_VERSION,
696 platform: Platform::Win32,
697 patches: vec![PatchRef {
698 name: "synthetic".into(),
699 patch_type: None,
700 }],
701 targets: vec![Target {
702 path: TargetPath::Generic("file.bin".into()),
703 final_size: regions
704 .last()
705 .map_or(0, |r| r.target_offset + u64::from(r.length)),
706 regions,
707 }],
708 fs_ops: vec![],
709 }
710 }
711
712 #[test]
713 fn compute_crc32_populates_every_patch_region() {
714 let raw_payload: Vec<u8> = (0..64u8).collect();
715 let deflate_src: Vec<u8> = (0..128u8).map(|i| i.wrapping_mul(3)).collect();
716 let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
717 enc.write_all(&deflate_src).unwrap();
718 let compressed = enc.finish().unwrap();
719
720 let mut source_buf = vec![0u8; 4096];
721 source_buf[..raw_payload.len()].copy_from_slice(&raw_payload);
722 source_buf[256..256 + compressed.len()].copy_from_slice(&compressed);
723
724 let regions = vec![
725 Region {
726 target_offset: 0,
727 length: raw_payload.len() as u32,
728 source: PartSource::Patch {
729 patch_idx: 0,
730 offset: 0,
731 kind: PatchSourceKind::Raw {
732 len: raw_payload.len() as u32,
733 },
734 decoded_skip: 0,
735 },
736 expected: PartExpected::SizeOnly,
737 },
738 Region {
739 target_offset: raw_payload.len() as u64,
740 length: deflate_src.len() as u32,
741 source: PartSource::Patch {
742 patch_idx: 0,
743 offset: 256,
744 kind: PatchSourceKind::Deflated {
745 compressed_len: compressed.len() as u32,
746 decompressed_len: deflate_src.len() as u32,
747 },
748 decoded_skip: 0,
749 },
750 expected: PartExpected::SizeOnly,
751 },
752 ];
753 let mut plan = plan_with_regions(regions);
754
755 let mut src = MemoryPatchSource::new(source_buf);
756 plan.compute_crc32(&mut src).expect("compute_crc32");
757
758 let raw_expected = crc32fast::hash(&raw_payload);
759 let def_expected = crc32fast::hash(&deflate_src);
760 assert_eq!(
761 plan.targets[0].regions[0].expected,
762 PartExpected::Crc32(raw_expected)
763 );
764 assert_eq!(
765 plan.targets[0].regions[1].expected,
766 PartExpected::Crc32(def_expected)
767 );
768 }
769
770 #[test]
771 fn compute_crc32_deflated_honors_decoded_skip() {
772 // Compress a 256-byte block; build a region that slices [128..256] of
773 // the decompressed output. Pin the CRC against the canonical slice.
774 let payload: Vec<u8> = (0..256u32).map(|i| (i * 11) as u8).collect();
775 let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
776 enc.write_all(&payload).unwrap();
777 let compressed = enc.finish().unwrap();
778
779 let mut source_buf = vec![0u8; 4096];
780 source_buf[100..100 + compressed.len()].copy_from_slice(&compressed);
781
782 let regions = vec![Region {
783 target_offset: 0,
784 length: 128,
785 source: PartSource::Patch {
786 patch_idx: 0,
787 offset: 100,
788 kind: PatchSourceKind::Deflated {
789 compressed_len: compressed.len() as u32,
790 decompressed_len: payload.len() as u32,
791 },
792 decoded_skip: 128,
793 },
794 expected: PartExpected::SizeOnly,
795 }];
796 let mut plan = plan_with_regions(regions);
797 let mut src = MemoryPatchSource::new(source_buf);
798 plan.compute_crc32(&mut src).unwrap();
799
800 let expected = crc32fast::hash(&payload[128..256]);
801 assert_eq!(
802 plan.targets[0].regions[0].expected,
803 PartExpected::Crc32(expected)
804 );
805 }
806
807 #[test]
808 fn compute_crc32_uses_canonical_zeros() {
809 // Pin the CRC of 128 zero bytes. The crc32 of the all-zero payload of
810 // length N is a fixed value — `crc32fast::hash(&[0u8; 128])`.
811 let regions = vec![Region {
812 target_offset: 0,
813 length: 128,
814 source: PartSource::Zeros,
815 expected: PartExpected::Zeros,
816 }];
817 let mut plan = plan_with_regions(regions);
818 let mut src = MemoryPatchSource::new(Vec::new());
819 plan.compute_crc32(&mut src).unwrap();
820
821 let expected = crc32fast::hash(&[0u8; 128]);
822 assert_eq!(
823 plan.targets[0].regions[0].expected,
824 PartExpected::Crc32(expected)
825 );
826 }
827
828 #[test]
829 fn compute_crc32_uses_canonical_empty_block() {
830 let regions = vec![Region {
831 target_offset: 0,
832 length: 128,
833 source: PartSource::EmptyBlock { units: 1 },
834 expected: PartExpected::EmptyBlock { units: 1 },
835 }];
836 let mut plan = plan_with_regions(regions);
837 let mut src = MemoryPatchSource::new(Vec::new());
838 plan.compute_crc32(&mut src).unwrap();
839
840 let mut buf = Vec::with_capacity(128);
841 crate::apply::sqpk::write_empty_block(&mut std::io::Cursor::new(&mut buf), 0, 1).unwrap();
842 let expected = crc32fast::hash(&buf);
843 assert_eq!(
844 plan.targets[0].regions[0].expected,
845 PartExpected::Crc32(expected)
846 );
847 }
848
849 #[test]
850 fn compute_crc32_short_stream_after_prior_region_does_not_fold_stale_bytes() {
851 // Regression: `decompressed_scratch` in `patch_region_crc` is reused
852 // across regions and never zeroed. If a Deflated region's stream
853 // produces fewer bytes than its declared `decompressed_len`, the
854 // pre-fix code hashed `decompressed_scratch[skip..end]` — including
855 // stale bytes left over from a prior region's decompression. The CRC
856 // must be derived only from bytes the decoder actually produced.
857 let first_payload: Vec<u8> = (0..96u8).collect();
858 let second_payload: &[u8] = b"abcd";
859
860 let compress = |raw: &[u8]| {
861 let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
862 enc.write_all(raw).unwrap();
863 enc.finish().unwrap()
864 };
865 let first_compressed = compress(&first_payload);
866 let second_compressed = compress(second_payload);
867
868 let mut src_buf = Vec::new();
869 let first_offset = src_buf.len() as u64;
870 src_buf.extend_from_slice(&first_compressed);
871 let second_offset = src_buf.len() as u64;
872 src_buf.extend_from_slice(&second_compressed);
873
874 let declared_second_len: u32 = first_payload.len() as u32;
875 let regions = vec![
876 Region {
877 target_offset: 0,
878 length: first_payload.len() as u32,
879 source: PartSource::Patch {
880 patch_idx: 0,
881 offset: first_offset,
882 kind: PatchSourceKind::Deflated {
883 compressed_len: first_compressed.len() as u32,
884 decompressed_len: first_payload.len() as u32,
885 },
886 decoded_skip: 0,
887 },
888 expected: PartExpected::SizeOnly,
889 },
890 Region {
891 target_offset: u64::from(first_payload.len() as u32),
892 length: declared_second_len,
893 source: PartSource::Patch {
894 patch_idx: 0,
895 offset: second_offset,
896 kind: PatchSourceKind::Deflated {
897 compressed_len: second_compressed.len() as u32,
898 decompressed_len: declared_second_len,
899 },
900 decoded_skip: 0,
901 },
902 expected: PartExpected::SizeOnly,
903 },
904 ];
905 let mut plan = plan_with_regions(regions);
906 let mut src = MemoryPatchSource::new(src_buf);
907 plan.compute_crc32(&mut src).unwrap();
908
909 let expected_second = crc32fast::hash(second_payload);
910 let leaked_second = {
911 let mut buf = Vec::with_capacity(declared_second_len as usize);
912 buf.extend_from_slice(second_payload);
913 buf.extend_from_slice(&first_payload[second_payload.len()..]);
914 crc32fast::hash(&buf)
915 };
916 let got = match &plan.targets[0].regions[1].expected {
917 PartExpected::Crc32(c) => *c,
918 other => panic!("expected Crc32, got {other:?}"),
919 };
920 assert_eq!(
921 got, expected_second,
922 "CRC must be of decoded bytes only (got {got:#x}, expected {expected_second:#x})"
923 );
924 assert_ne!(
925 got, leaked_second,
926 "CRC must not fold in stale scratch bytes from a prior region"
927 );
928 }
929
930 #[test]
931 fn compute_crc32_rolls_back_on_midway_source_failure() {
932 // Two targets. Target 0's regions can be read from the source. Target 1's
933 // Patch region points past the source buffer, so its read fails. The
934 // contract: on Err return, no region's `expected` may have been mutated.
935 let payload: Vec<u8> = (0..32u8).collect();
936 let mut src_buf = vec![0u8; 64];
937 src_buf[..payload.len()].copy_from_slice(&payload);
938
939 let plan_targets = vec![
940 Target {
941 path: TargetPath::Generic("a.bin".into()),
942 final_size: payload.len() as u64,
943 regions: vec![Region {
944 target_offset: 0,
945 length: payload.len() as u32,
946 source: PartSource::Patch {
947 patch_idx: 0,
948 offset: 0,
949 kind: PatchSourceKind::Raw {
950 len: payload.len() as u32,
951 },
952 decoded_skip: 0,
953 },
954 expected: PartExpected::SizeOnly,
955 }],
956 },
957 Target {
958 path: TargetPath::Generic("b.bin".into()),
959 final_size: 4096,
960 regions: vec![Region {
961 target_offset: 0,
962 length: 32,
963 source: PartSource::Patch {
964 patch_idx: 0,
965 // Past the 64-byte source buffer: MemoryPatchSource
966 // returns PatchSourceTooShort, surfacing as Err.
967 offset: 4096,
968 kind: PatchSourceKind::Raw { len: 32 },
969 decoded_skip: 0,
970 },
971 expected: PartExpected::SizeOnly,
972 }],
973 },
974 ];
975 let mut plan = Plan {
976 schema_version: Plan::CURRENT_SCHEMA_VERSION,
977 platform: Platform::Win32,
978 patches: vec![PatchRef {
979 name: "synthetic".into(),
980 patch_type: None,
981 }],
982 targets: plan_targets,
983 fs_ops: vec![],
984 };
985
986 let mut src = MemoryPatchSource::new(src_buf);
987 let err = plan
988 .compute_crc32(&mut src)
989 .expect_err("second target's read must fail");
990 assert!(
991 matches!(err, crate::ZiPatchError::PatchSourceTooShort { .. }),
992 "expected PatchSourceTooShort, got {err:?}"
993 );
994
995 // Every region must still carry its pre-call `expected`. Pre-fix code
996 // would have mutated target 0's region into `Crc32(_)` before failing
997 // on target 1.
998 for target in &plan.targets {
999 for region in &target.regions {
1000 assert_eq!(
1001 region.expected,
1002 PartExpected::SizeOnly,
1003 "Err return must leave plan unmutated, but a region was set to {:?}",
1004 region.expected
1005 );
1006 }
1007 }
1008 }
1009
1010 #[test]
1011 fn crc32_of_empty_block_matches_explicit_buffer() {
1012 // Build the canonical payload by hand (20-byte header + zeros) and
1013 // confirm the streaming hash returns the same CRC across a range of
1014 // units values, including one large enough to exercise many loop
1015 // iterations (128 KiB / 64 KiB chunk = ≥2 iterations).
1016 for units in [1u32, 2, 4, 8, 16, 100, 1024, 8192] {
1017 let mut buf = vec![0u8; (units as usize) * 128];
1018 buf[0..4].copy_from_slice(&128u32.to_le_bytes());
1019 buf[12..16].copy_from_slice(&units.wrapping_sub(1).to_le_bytes());
1020 let expected = crc32fast::hash(&buf);
1021 let got = crc32_of_empty_block(units).unwrap();
1022 assert_eq!(got, expected, "units={units}");
1023 }
1024 }
1025
1026 #[test]
1027 fn crc32_of_empty_block_rejects_zero_units() {
1028 let err = crc32_of_empty_block(0).unwrap_err();
1029 assert!(
1030 matches!(err, crate::ZiPatchError::InvalidField { context } if context.contains("non-zero")),
1031 "got {err:?}"
1032 );
1033 }
1034
1035 #[test]
1036 fn compute_crc32_skips_unavailable_regions() {
1037 let regions = vec![Region {
1038 target_offset: 0,
1039 length: 32,
1040 source: PartSource::Unavailable,
1041 expected: PartExpected::SizeOnly,
1042 }];
1043 let mut plan = plan_with_regions(regions);
1044 let mut src = MemoryPatchSource::new(Vec::new());
1045 plan.compute_crc32(&mut src)
1046 .expect("must not error on Unavailable");
1047 assert_eq!(plan.targets[0].regions[0].expected, PartExpected::SizeOnly);
1048 }
1049}