Skip to main content

zipatch_rs/index/
verify.rs

1//! Read-only verification of an install tree against a [`Plan`].
2//!
3//! [`Verifier`] walks each [`Target`] in a [`Plan`], resolves it to a path in
4//! the install tree, and inspects the bytes against the per-region
5//! expectations the plan carries. The result is a [`RepairManifest`] naming
6//! the targets and regions that need rewriting. Pair the manifest with
7//! [`IndexApplier::execute_with_manifest`](crate::index::IndexApplier::execute_with_manifest)
8//! to rewrite only the flagged regions without touching the rest of the install.
9//!
10//! # Verification policy
11//!
12//! - [`PartSource::Patch`] regions are size-only by default: the verifier
13//!   checks that `target_offset + length <= file size on disk`. Without CRC the
14//!   content inside a `Patch` region is **not** inspected — a single-byte flip
15//!   in the middle of a `Patch` region is invisible. Call
16//!   [`Plan::compute_crc32`] before verifying to opt into content-level checks
17//!   via [`PartExpected::Crc32`].
18//! - [`PartSource::Zeros`] regions are content-checked: the verifier reads the
19//!   range and flags any non-zero byte.
20//! - [`PartSource::EmptyBlock`] regions are content-checked against the
21//!   canonical payload the apply layer's internal `write_empty_block` helper
22//!   would emit for the same `units` value (a 20-byte `SqPack` empty-block
23//!   header followed by `units * 128 - 20` zero bytes).
24//! - [`PartSource::Unavailable`] regions are always flagged — the builder does
25//!   not emit them from any in-tree chunk parser, so encountering one means
26//!   the plan is hand-built (or deserialized) with regions whose source bytes
27//!   are unreachable; the verifier cannot repair them.
28
29use crate::Platform;
30use crate::Result;
31use crate::apply::ApplyContext;
32// Reuse the apply-layer path resolvers and empty-block writer so the verifier
33// stays byte-identical to the applier by construction.
34use crate::apply::path::{dat_path, generic_path, index_path};
35use crate::apply::sqpk::empty_block_header;
36use crate::index::plan::{PartExpected, PartSource, Plan, Region, Target, TargetPath};
37use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
38use std::cell::RefCell;
39use std::collections::BTreeMap;
40use std::fs::File;
41use std::io::{Read, Seek, SeekFrom};
42use std::path::{Path, PathBuf};
43use tracing::{debug, debug_span, info, info_span, trace};
44
45const READ_BUF_CAPACITY: usize = 64 * 1024;
46
47thread_local! {
48    static REGION_SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
49}
50
51/// Per-target buckets of flagged regions and target-level diagnostics.
52///
53/// Produced by [`Verifier::execute`]. The `missing_regions` map keys are
54/// indices into [`Plan::targets`]; each value is a sorted ascending list of
55/// indices into the target's [`Target::regions`].
56///
57/// # Stable iteration order
58///
59/// `missing_regions` is a [`BTreeMap`], so iteration is in ascending
60/// `target_idx` order. Under the `serde` feature this also pins the serialized
61/// key order — two manifests with identical contents serialize to identical
62/// bytes regardless of the order they were populated in, which is required for
63/// persisted-manifest workflows.
64///
65/// # Stability
66///
67/// `#[non_exhaustive]`: new diagnostic buckets may be added in future minor
68/// versions. Consumers should treat the struct as forward-compatible by name.
69#[non_exhaustive]
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub struct RepairManifest {
73    /// `target_idx` → indices into [`Target::regions`] that failed verification.
74    /// A fully-missing target file has every region index listed here. Iteration
75    /// is ordered by `target_idx`.
76    pub missing_regions: BTreeMap<usize, Vec<usize>>,
77    /// Indices of targets whose underlying file does not exist on disk.
78    pub missing_targets: Vec<usize>,
79    /// Indices of targets whose underlying file exists but is shorter than
80    /// [`Target::final_size`].
81    pub size_mismatched: Vec<usize>,
82}
83
84impl RepairManifest {
85    /// `true` iff no targets and no regions need repair.
86    #[must_use]
87    pub fn is_clean(&self) -> bool {
88        self.missing_regions.is_empty()
89            && self.missing_targets.is_empty()
90            && self.size_mismatched.is_empty()
91    }
92
93    /// Sum of `missing_regions[k].len()` across all targets.
94    #[must_use]
95    pub fn total_missing_regions(&self) -> usize {
96        self.missing_regions.values().map(Vec::len).sum()
97    }
98}
99
100/// Walk a [`Plan`] against an install tree and produce a [`RepairManifest`].
101///
102/// Construct via [`Verifier::new`], optionally override the platform with
103/// [`Verifier::with_platform`], then call [`Verifier::execute`] with a `&Plan`.
104/// The plan's [`Plan::platform`] is used by default.
105///
106/// The verifier never writes — it opens files read-only, reads only the
107/// ranges it needs to inspect, and returns a manifest describing what is out
108/// of shape. Pair it with
109/// [`IndexApplier::execute_with_manifest`](crate::index::IndexApplier::execute_with_manifest)
110/// to fix only the flagged regions.
111pub struct Verifier {
112    install_root: PathBuf,
113    platform_override: Option<Platform>,
114}
115
116impl Verifier {
117    /// Construct a verifier rooted at `install_root`.
118    pub fn new(install_root: impl Into<PathBuf>) -> Self {
119        Self {
120            install_root: install_root.into(),
121            platform_override: None,
122        }
123    }
124
125    /// Override the platform pinned on the [`Plan`].
126    #[must_use]
127    pub fn with_platform(mut self, platform: Platform) -> Self {
128        self.platform_override = Some(platform);
129        self
130    }
131
132    /// Verify `plan` against the install tree.
133    ///
134    /// Returns a [`RepairManifest`] describing every target/region that needs
135    /// rewriting. An empty manifest (see [`RepairManifest::is_clean`]) means
136    /// the install matches the plan within the v1 policy: see the
137    /// [module docs][crate::index::verify] for the per-source check matrix.
138    ///
139    /// # Patch-source caveat
140    ///
141    /// Single-byte damage inside a [`PartSource::Patch`]-sourced region is
142    /// **not** detected by default — populate the plan's regions with
143    /// [`PartExpected::Crc32`] via [`Plan::compute_crc32`] to opt into
144    /// content-level checks.
145    ///
146    /// # Errors
147    ///
148    /// Surfaces any [`crate::ZiPatchError::Io`] produced while opening or
149    /// reading an install-tree file, plus
150    /// [`crate::ZiPatchError::UnsupportedPlatform`] if the plan pins
151    /// [`Platform::Unknown`] and the install contains `SqPack` targets.
152    pub fn execute(self, plan: &Plan) -> Result<RepairManifest> {
153        let span = info_span!("verify_plan", targets = plan.targets.len());
154        let _enter = span.enter();
155        let started = std::time::Instant::now();
156
157        let Verifier {
158            install_root,
159            platform_override,
160        } = self;
161
162        let platform = platform_override.unwrap_or(plan.platform);
163        // Reuse ApplyContext purely for its path-resolution caches; no writes
164        // happen here. The handle cache stays empty.
165        let mut ctx = ApplyContext::new(install_root).with_platform(platform);
166
167        let mut resolved: Vec<PathBuf> = Vec::with_capacity(plan.targets.len());
168        for target in &plan.targets {
169            resolved.push(resolve_target_path(&mut ctx, &target.path)?);
170        }
171
172        let parent = &span;
173        let outcomes: Vec<PerTargetOutcome> = plan
174            .targets
175            .par_iter()
176            .zip(resolved.par_iter())
177            .enumerate()
178            .map(|(idx, (target, path))| {
179                parent.in_scope(|| {
180                    let sub = debug_span!("verify_target", target = idx);
181                    let _e = sub.enter();
182                    REGION_SCRATCH
183                        .with(|cell| verify_target(idx, path, target, &mut cell.borrow_mut()))
184                })
185            })
186            .collect::<Result<Vec<_>>>()?;
187
188        let mut manifest = RepairManifest::default();
189        for (idx, outcome) in outcomes.into_iter().enumerate() {
190            match outcome {
191                PerTargetOutcome::Missing => {
192                    manifest.missing_targets.push(idx);
193                    let region_count = plan.targets[idx].regions.len();
194                    if region_count != 0 {
195                        manifest
196                            .missing_regions
197                            .insert(idx, (0..region_count).collect());
198                    }
199                }
200                PerTargetOutcome::Present {
201                    size_mismatch,
202                    flagged,
203                } => {
204                    if size_mismatch {
205                        manifest.size_mismatched.push(idx);
206                    }
207                    if !flagged.is_empty() {
208                        manifest.missing_regions.insert(idx, flagged);
209                    }
210                }
211            }
212        }
213        // Stable ordering for both buckets and per-target region lists.
214        manifest.missing_targets.sort_unstable();
215        manifest.size_mismatched.sort_unstable();
216        for v in manifest.missing_regions.values_mut() {
217            v.sort_unstable();
218        }
219        info!(
220            targets = plan.targets.len(),
221            missing_targets = manifest.missing_targets.len(),
222            size_mismatched = manifest.size_mismatched.len(),
223            damaged_targets = manifest.missing_regions.len(),
224            damaged_regions = manifest.total_missing_regions(),
225            elapsed_ms = started.elapsed().as_millis() as u64,
226            "verify_plan: scan complete"
227        );
228        Ok(manifest)
229    }
230}
231
232enum PerTargetOutcome {
233    Missing,
234    Present {
235        size_mismatch: bool,
236        flagged: Vec<usize>,
237    },
238}
239
240fn verify_target(
241    idx: usize,
242    path: &Path,
243    target: &Target,
244    scratch: &mut Vec<u8>,
245) -> Result<PerTargetOutcome> {
246    trace!(target = idx, path = %path.display(), "verify target");
247
248    let Some(actual_size) = stat_size(path)? else {
249        debug!(target = idx, path = %path.display(), "verify: target file missing");
250        return Ok(PerTargetOutcome::Missing);
251    };
252
253    let size_mismatch = actual_size < target.final_size;
254    if size_mismatch {
255        debug!(
256            target = idx,
257            actual_size,
258            final_size = target.final_size,
259            "verify: target size mismatch"
260        );
261    }
262
263    // Raw File rather than BufReader: every region is read after a fresh seek,
264    // which would invalidate any BufReader's internal buffer on every call.
265    // Each `read_exact` here is one syscall (or one streamed chunk for Zeros);
266    // a 64 KiB BufReader would add a dead allocation per target with zero
267    // amortisation benefit.
268    let mut file = File::open(path)?;
269    let mut flagged: Vec<usize> = Vec::new();
270
271    for (region_idx, region) in target.regions.iter().enumerate() {
272        if region_fails(region, actual_size, &mut file, scratch)? {
273            flagged.push(region_idx);
274        }
275    }
276
277    Ok(PerTargetOutcome::Present {
278        size_mismatch,
279        flagged,
280    })
281}
282
283fn stat_size(path: &std::path::Path) -> Result<Option<u64>> {
284    match std::fs::metadata(path) {
285        Ok(meta) => Ok(Some(meta.len())),
286        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
287        Err(e) => Err(e.into()),
288    }
289}
290
291fn resolve_target_path(ctx: &mut ApplyContext, tp: &TargetPath) -> Result<PathBuf> {
292    match *tp {
293        TargetPath::SqpackDat {
294            main_id,
295            sub_id,
296            file_id,
297        } => dat_path(ctx, main_id, sub_id, file_id),
298        TargetPath::SqpackIndex {
299            main_id,
300            sub_id,
301            file_id,
302        } => index_path(ctx, main_id, sub_id, file_id),
303        TargetPath::Generic(ref rel) => Ok(generic_path(ctx, rel)),
304    }
305}
306
307fn region_fails(
308    region: &Region,
309    actual_size: u64,
310    file: &mut File,
311    scratch: &mut Vec<u8>,
312) -> Result<bool> {
313    let len_u64 = u64::from(region.length);
314    let end = region.target_offset.saturating_add(len_u64);
315    if end > actual_size {
316        return Ok(true);
317    }
318
319    // `PartExpected::Crc32` takes precedence: it is the only check that can
320    // detect single-byte damage inside a `Patch`-sourced region. For Zeros /
321    // EmptyBlock regions the per-source fast paths (no hash) are kept because
322    // they're cheaper than a CRC over the same bytes.
323    if let PartExpected::Crc32(expected) = region.expected {
324        return check_crc32(file, region.target_offset, region.length, scratch, expected);
325    }
326
327    match region.source {
328        PartSource::Patch { .. } => Ok(false),
329        PartSource::Zeros => check_zeros(file, region.target_offset, len_u64, scratch),
330        PartSource::EmptyBlock { units } => {
331            check_empty_block(file, region.target_offset, units, scratch)
332        }
333        PartSource::Unavailable => Ok(true),
334    }
335}
336
337fn check_crc32(
338    file: &mut File,
339    offset: u64,
340    length: u32,
341    scratch: &mut Vec<u8>,
342    expected: u32,
343) -> Result<bool> {
344    let needed = length as usize;
345    if scratch.len() < needed {
346        scratch.resize(needed, 0);
347    }
348    file.seek(SeekFrom::Start(offset))?;
349    file.read_exact(&mut scratch[..needed])?;
350    Ok(crc32fast::hash(&scratch[..needed]) != expected)
351}
352
353fn check_zeros(file: &mut File, offset: u64, len: u64, scratch: &mut Vec<u8>) -> Result<bool> {
354    if len == 0 {
355        return Ok(false);
356    }
357    // Stream-check in 64 KiB chunks so multi-MB Zero runs do not balloon RAM.
358    // Reuses the per-target scratch buffer so we never allocate on the hot path.
359    if scratch.len() < READ_BUF_CAPACITY {
360        scratch.resize(READ_BUF_CAPACITY, 0);
361    }
362    file.seek(SeekFrom::Start(offset))?;
363    let mut remaining = len;
364    while remaining > 0 {
365        let take = remaining.min(READ_BUF_CAPACITY as u64) as usize;
366        file.read_exact(&mut scratch[..take])?;
367        if scratch[..take].iter().any(|&b| b != 0) {
368            return Ok(true);
369        }
370        remaining -= take as u64;
371    }
372    Ok(false)
373}
374
375fn check_empty_block(
376    file: &mut File,
377    offset: u64,
378    units: u32,
379    scratch: &mut Vec<u8>,
380) -> Result<bool> {
381    if units == 0 {
382        return Err(crate::ZiPatchError::InvalidField {
383            context: "EmptyBlock units must be non-zero",
384        });
385    }
386    // Stream-compare the on-disk region against the canonical
387    // (20-byte header + zeros) payload in fixed-size chunks. Avoids
388    // materializing the up-to-4 GiB canonical buffer that a pathological
389    // `units` value (near MAX_UNITS_PER_REGION) would otherwise demand,
390    // and removes the per-`units` byte cache that used to dominate the
391    // verifier's memory footprint. See issue #32.
392    if scratch.len() < READ_BUF_CAPACITY {
393        scratch.resize(READ_BUF_CAPACITY, 0);
394    }
395    file.seek(SeekFrom::Start(offset))?;
396    let total = u64::from(units) * 128;
397    let header = empty_block_header(units);
398    let mut emitted: u64 = 0;
399    let mut first = true;
400    while emitted < total {
401        let chunk_len = (total - emitted).min(READ_BUF_CAPACITY as u64) as usize;
402        file.read_exact(&mut scratch[..chunk_len])?;
403        if first {
404            // total >= 128 (units >= 1) and READ_BUF_CAPACITY >= 128, so the
405            // 20-byte header lives entirely in this first chunk.
406            if scratch[..20] != header {
407                return Ok(true);
408            }
409            if scratch[20..chunk_len].iter().any(|&b| b != 0) {
410                return Ok(true);
411            }
412            first = false;
413        } else if scratch[..chunk_len].iter().any(|&b| b != 0) {
414            return Ok(true);
415        }
416        emitted += chunk_len as u64;
417    }
418    Ok(false)
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::index::PatchRef;
425    use crate::index::plan::{PartExpected, Region, Target, TargetPath};
426
427    fn dat_target(regions: Vec<Region>, final_size: u64) -> Target {
428        Target {
429            path: TargetPath::SqpackDat {
430                main_id: 0,
431                sub_id: 0,
432                file_id: 0,
433            },
434            final_size,
435            regions,
436        }
437    }
438
439    fn plan_with(targets: Vec<Target>) -> Plan {
440        Plan {
441            schema_version: Plan::CURRENT_SCHEMA_VERSION,
442            platform: Platform::Win32,
443            patches: vec![PatchRef {
444                name: "synthetic".into(),
445                patch_type: None,
446            }],
447            targets,
448            fs_ops: vec![],
449        }
450    }
451
452    #[test]
453    fn repair_manifest_is_clean_when_empty() {
454        let m = RepairManifest::default();
455        assert!(m.is_clean());
456        assert_eq!(m.total_missing_regions(), 0);
457    }
458
459    #[test]
460    fn total_missing_regions_sums_per_target_buckets() {
461        let mut m = RepairManifest::default();
462        m.missing_regions.insert(0, vec![1, 2, 3]);
463        m.missing_regions.insert(1, vec![4, 5, 6]);
464        assert!(!m.is_clean());
465        assert_eq!(m.total_missing_regions(), 6);
466    }
467
468    #[test]
469    fn verifier_against_missing_target_flags_entire_target() {
470        let regions = vec![
471            Region {
472                target_offset: 0,
473                length: 16,
474                source: PartSource::Zeros,
475                expected: PartExpected::Zeros,
476            },
477            Region {
478                target_offset: 16,
479                length: 16,
480                source: PartSource::Zeros,
481                expected: PartExpected::Zeros,
482            },
483        ];
484        let plan = plan_with(vec![dat_target(regions, 32)]);
485
486        let tmp = tempfile::tempdir().unwrap();
487        let manifest = Verifier::new(tmp.path()).execute(&plan).unwrap();
488
489        assert!(manifest.missing_targets.contains(&0));
490        let regions = manifest
491            .missing_regions
492            .get(&0)
493            .expect("missing target must populate every region");
494        assert_eq!(regions, &vec![0, 1]);
495    }
496
497    fn canonical_empty_block_bytes(units: u32) -> Vec<u8> {
498        let mut buf = vec![0u8; (units as usize) * 128];
499        buf[0..4].copy_from_slice(&128u32.to_le_bytes());
500        buf[12..16].copy_from_slice(&units.wrapping_sub(1).to_le_bytes());
501        buf
502    }
503
504    fn write_to_temp(bytes: &[u8]) -> std::fs::File {
505        use std::io::{Seek, Write};
506        let mut f = tempfile::tempfile().unwrap();
507        f.write_all(bytes).unwrap();
508        f.seek(SeekFrom::Start(0)).unwrap();
509        f
510    }
511
512    #[test]
513    fn check_empty_block_accepts_canonical_payload() {
514        // Exercises a `units` value that spans multiple read chunks
515        // (8192 * 128 = 1 MiB > 64 KiB READ_BUF_CAPACITY).
516        for units in [1u32, 4, 1024, 8192] {
517            let mut f = write_to_temp(&canonical_empty_block_bytes(units));
518            let mut scratch = Vec::new();
519            let fails = check_empty_block(&mut f, 0, units, &mut scratch).unwrap();
520            assert!(!fails, "units={units}: canonical payload must verify clean");
521        }
522    }
523
524    #[test]
525    fn check_empty_block_flags_corrupted_header() {
526        let units = 4u32;
527        let mut buf = vec![0u8; (units as usize) * 128];
528        // No header bytes at all — first 20 bytes are zero instead of the
529        // canonical `[128, 0, 0, units-1, 0]`.
530        let mut f = write_to_temp(&buf);
531        let mut scratch = Vec::new();
532        let fails = check_empty_block(&mut f, 0, units, &mut scratch).unwrap();
533        assert!(fails, "missing header must be flagged");
534
535        // Also: header partially present but the `units - 1` field is wrong.
536        buf[0..4].copy_from_slice(&128u32.to_le_bytes());
537        buf[12..16].copy_from_slice(&999u32.to_le_bytes());
538        let mut f = write_to_temp(&buf);
539        let fails = check_empty_block(&mut f, 0, units, &mut scratch).unwrap();
540        assert!(fails, "wrong units-1 field must be flagged");
541    }
542
543    #[test]
544    fn check_empty_block_flags_corruption_in_zero_region() {
545        let units = 8u32; // 1024-byte region; corruption past byte 20.
546        let mut buf = canonical_empty_block_bytes(units);
547        buf[500] = 0xFF;
548        let mut f = write_to_temp(&buf);
549        let mut scratch = Vec::new();
550        let fails = check_empty_block(&mut f, 0, units, &mut scratch).unwrap();
551        assert!(fails, "non-zero byte in body must be flagged");
552    }
553
554    #[test]
555    fn check_empty_block_rejects_zero_units() {
556        let mut f = tempfile::tempfile().unwrap();
557        let mut scratch = Vec::new();
558        let err = check_empty_block(&mut f, 0, 0, &mut scratch).unwrap_err();
559        assert!(
560            matches!(err, crate::ZiPatchError::InvalidField { context } if context.contains("non-zero")),
561            "got {err:?}"
562        );
563    }
564
565    // --- Parallel fan-out determinism ---
566
567    fn generic_target(rel: impl Into<String>, final_size: u64) -> Target {
568        let region = Region {
569            target_offset: 0,
570            length: final_size as u32,
571            source: PartSource::Zeros,
572            expected: PartExpected::Zeros,
573        };
574        Target {
575            path: TargetPath::Generic(rel.into()),
576            final_size,
577            regions: vec![region],
578        }
579    }
580
581    // 36 Generic targets: 12 missing, 12 size-mismatched (smaller than declared),
582    // 12 clean. Asserts that missing_targets, size_mismatched, and missing_regions
583    // keys are all sorted ascending, and that two runs on equivalent input produce
584    // equal manifests.
585    #[test]
586    fn parallel_fan_out_manifest_is_deterministic_and_sorted() {
587        const TOTAL: usize = 36;
588        const STRIPE: usize = TOTAL / 3; // 12 each
589        let dir = tempfile::tempdir().unwrap();
590        let mut targets = Vec::with_capacity(TOTAL);
591        for i in 0..TOTAL {
592            let rel = format!("tgt_{i:03}");
593            if i < STRIPE {
594                // Missing: no file on disk; declared size non-zero.
595                targets.push(generic_target(&rel, 16));
596            } else if i < 2 * STRIPE {
597                // Size-mismatched: file shorter than declared final_size.
598                let p = dir.path().join(&rel);
599                std::fs::write(p, [0u8; 8]).unwrap();
600                // declared 1 MiB, but file is only 8 bytes — region extends past EOF.
601                targets.push(generic_target(&rel, 1024 * 1024));
602            } else {
603                // Clean: file matches declared size with zero content.
604                let p = dir.path().join(&rel);
605                std::fs::write(p, [0u8; 16]).unwrap();
606                targets.push(generic_target(&rel, 16));
607            }
608        }
609        let plan = plan_with(targets);
610
611        let run1 = Verifier::new(dir.path()).execute(&plan).unwrap();
612
613        assert_eq!(run1.missing_targets.len(), STRIPE);
614        assert_eq!(run1.size_mismatched.len(), STRIPE);
615
616        for w in run1.missing_targets.windows(2) {
617            assert!(w[0] < w[1], "missing_targets not sorted: {w:?}");
618        }
619        for w in run1.size_mismatched.windows(2) {
620            assert!(w[0] < w[1], "size_mismatched not sorted: {w:?}");
621        }
622        for (key, regions) in &run1.missing_regions {
623            for w in regions.windows(2) {
624                assert!(w[0] < w[1], "missing_regions[{key}] not sorted: {w:?}");
625            }
626        }
627
628        let run2 = Verifier::new(dir.path()).execute(&plan).unwrap();
629        assert_eq!(
630            run1, run2,
631            "two equivalent runs produced different manifests"
632        );
633    }
634
635    // Builds a plan where targets are declared in a deliberately non-ascending
636    // order relative to their expected outcome category. Asserts the sort
637    // invariants still hold, guarding against a future merge-loop refactor that
638    // drops the sort_unstable passes.
639    #[test]
640    fn parallel_fan_out_shuffled_target_order_manifest_sorted() {
641        // Interleave missing and present targets in a non-trivial order.
642        // Pattern (per 4): missing, clean, missing, size-mismatched.
643        const GROUPS: usize = 8; // 32 targets total
644        let dir = tempfile::tempdir().unwrap();
645        let mut targets = Vec::with_capacity(GROUPS * 4);
646        for g in 0..GROUPS {
647            let base = g * 4;
648            // missing
649            targets.push(generic_target(format!("s_{base:03}"), 16));
650            // clean
651            let rel_c = format!("s_{:03}", base + 1);
652            std::fs::write(dir.path().join(&rel_c), [0u8; 16]).unwrap();
653            targets.push(generic_target(rel_c, 16));
654            // missing
655            targets.push(generic_target(format!("s_{:03}", base + 2), 16));
656            // size-mismatched
657            let rel_sm = format!("s_{:03}", base + 3);
658            std::fs::write(dir.path().join(&rel_sm), [0u8; 4]).unwrap();
659            targets.push(generic_target(rel_sm, 1024));
660        }
661        let plan = plan_with(targets);
662        let manifest = Verifier::new(dir.path()).execute(&plan).unwrap();
663
664        for w in manifest.missing_targets.windows(2) {
665            assert!(w[0] < w[1], "missing_targets not sorted: {w:?}");
666        }
667        for w in manifest.size_mismatched.windows(2) {
668            assert!(w[0] < w[1], "size_mismatched not sorted: {w:?}");
669        }
670        for (key, regions) in &manifest.missing_regions {
671            for w in regions.windows(2) {
672                assert!(w[0] < w[1], "missing_regions[{key}] not sorted: {w:?}");
673            }
674        }
675        // Sanity: expected counts.
676        assert_eq!(manifest.missing_targets.len(), GROUPS * 2);
677        assert_eq!(manifest.size_mismatched.len(), GROUPS);
678    }
679
680    // When at least one target causes a non-NotFound IO error, execute() must
681    // return Err rather than swallowing it or hanging. We do not assert which
682    // target's error wins (non-deterministic under rayon), only that the call
683    // terminates with Err.
684    #[cfg(target_family = "unix")]
685    #[test]
686    fn parallel_fan_out_propagates_io_error_from_one_target() {
687        use std::os::unix::fs::PermissionsExt;
688
689        let dir = tempfile::tempdir().unwrap();
690
691        // One readable file (clean) and one unreadable file (will cause File::open to fail).
692        let rel_ok = "ok_target";
693        let rel_err = "err_target";
694        std::fs::write(dir.path().join(rel_ok), [0u8; 16]).unwrap();
695        std::fs::write(dir.path().join(rel_err), [0u8; 16]).unwrap();
696        std::fs::set_permissions(
697            dir.path().join(rel_err),
698            std::fs::Permissions::from_mode(0o000),
699        )
700        .unwrap();
701
702        // Skip when running as root — CAP_DAC_OVERRIDE bypasses mode bits.
703        if std::fs::File::open(dir.path().join(rel_err)).is_ok() {
704            std::fs::set_permissions(
705                dir.path().join(rel_err),
706                std::fs::Permissions::from_mode(0o644),
707            )
708            .unwrap();
709            eprintln!("skipping: running with CAP_DAC_OVERRIDE");
710            return;
711        }
712
713        let targets = vec![generic_target(rel_ok, 16), generic_target(rel_err, 16)];
714        let plan = plan_with(targets);
715        let result = Verifier::new(dir.path()).execute(&plan);
716
717        std::fs::set_permissions(
718            dir.path().join(rel_err),
719            std::fs::Permissions::from_mode(0o644),
720        )
721        .unwrap();
722
723        assert!(
724            result.is_err(),
725            "expected Err from unreadable target, got Ok"
726        );
727    }
728}