Skip to main content

zipatch_rs/index/
plan.rs

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