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 ®ion.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}