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