Skip to main content

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 &region.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}