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