Skip to main content

zipatch_rs/index/
verify.rs

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