Skip to main content

par2_rs/
verify.rs

1use std::collections::HashMap;
2use std::io::{self, Read, Seek};
3use std::path::PathBuf;
4
5use crate::checksum;
6use crate::md5_simd;
7use crate::par2_set::Par2FileSet;
8use crate::types::{
9    CancellationToken, FileId, MAX_SLICES_PER_FILE, ProgressCallback, ProgressPhase, ProgressStage,
10    ProgressUpdate, SliceChecksum,
11};
12
13const VERIFY_SLICE_CHUNK_BYTES: usize = 64 * 1024;
14const VERIFY_SIMD_BATCH_MEMORY_BYTES: usize = 4 * 1024 * 1024;
15/// Upper bound on the multi-buffer MD5 kernel width, used only to size stack
16/// arrays. The width actually driven is [`md5_simd::max_lanes`], which selects
17/// by ISA (8 on AVX2, 4 on NEON/SSE2/simd128, 1 scalar) and never exceeds this.
18const VERIFY_SIMD_MAX_LANES: usize = 8;
19const QUICK_CHECK_16K_BYTES: usize = 16 * 1024;
20const VERIFY_FULL_HASH_CHUNK_BYTES: usize = 1024 * 1024;
21/// Read span per task for slice-parallel staged-file verification.
22const VERIFY_SPAN_TARGET_BYTES: usize = 4 * 1024 * 1024;
23
24/// Seekable reader used by repair streams that revisit many ranges in one
25/// source file. Implementations may return one to avoid reopening per slice.
26pub trait FileRangeReader: Read + Seek {}
27
28impl<T: Read + Seek> FileRangeReader for T {}
29
30/// Abstraction for file I/O during verification and repair.
31pub trait FileAccess {
32    /// Read a range of bytes from a file identified by its FileId.
33    fn read_file_range(&self, file_id: &FileId, offset: u64, len: u64) -> io::Result<Vec<u8>>;
34
35    /// Read bytes into a caller-provided buffer, returning the number of bytes read.
36    fn read_file_range_into(
37        &self,
38        file_id: &FileId,
39        offset: u64,
40        dst: &mut [u8],
41    ) -> io::Result<usize> {
42        let data = self.read_file_range(file_id, offset, dst.len() as u64)?;
43        let read_len = data.len().min(dst.len());
44        dst[..read_len].copy_from_slice(&data[..read_len]);
45        Ok(read_len)
46    }
47
48    /// Open a forward-only reader for hot paths that consume a whole file from offset 0.
49    fn open_sequential_reader(&self, _file_id: &FileId) -> io::Result<Option<Box<dyn Read>>> {
50        Ok(None)
51    }
52
53    /// Open a reusable seekable reader for range-oriented repair input.
54    fn open_range_reader(&self, _file_id: &FileId) -> io::Result<Option<Box<dyn FileRangeReader>>> {
55        Ok(None)
56    }
57
58    /// Check if a file exists on disk.
59    fn file_exists(&self, file_id: &FileId) -> bool;
60
61    /// Get the actual length of a file on disk.
62    fn file_length(&self, file_id: &FileId) -> Option<u64>;
63
64    /// Read the entire file content.
65    fn read_file(&self, file_id: &FileId) -> io::Result<Vec<u8>>;
66
67    /// Write data to a file at the given offset.
68    fn write_file_range(&mut self, file_id: &FileId, offset: u64, data: &[u8]) -> io::Result<()>;
69}
70
71/// Simple in-memory file access for testing.
72pub struct MemoryFileAccess {
73    files: HashMap<FileId, Vec<u8>>,
74}
75
76impl MemoryFileAccess {
77    pub fn new() -> Self {
78        Self {
79            files: HashMap::new(),
80        }
81    }
82
83    pub fn add_file(&mut self, file_id: FileId, data: Vec<u8>) {
84        self.files.insert(file_id, data);
85    }
86}
87
88impl Default for MemoryFileAccess {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl FileAccess for MemoryFileAccess {
95    fn read_file_range(&self, file_id: &FileId, offset: u64, len: u64) -> io::Result<Vec<u8>> {
96        let data = self
97            .files
98            .get(file_id)
99            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))?;
100        let offset = offset as usize;
101        let end = (offset + len as usize).min(data.len());
102        if offset >= data.len() {
103            return Ok(Vec::new());
104        }
105        Ok(data[offset..end].to_vec())
106    }
107
108    fn read_file_range_into(
109        &self,
110        file_id: &FileId,
111        offset: u64,
112        dst: &mut [u8],
113    ) -> io::Result<usize> {
114        let data = self
115            .files
116            .get(file_id)
117            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))?;
118        let offset = offset as usize;
119        if offset >= data.len() {
120            return Ok(0);
121        }
122        let end = (offset + dst.len()).min(data.len());
123        let read_len = end - offset;
124        dst[..read_len].copy_from_slice(&data[offset..end]);
125        Ok(read_len)
126    }
127
128    fn file_exists(&self, file_id: &FileId) -> bool {
129        self.files.contains_key(file_id)
130    }
131
132    fn file_length(&self, file_id: &FileId) -> Option<u64> {
133        self.files.get(file_id).map(|d| d.len() as u64)
134    }
135
136    fn read_file(&self, file_id: &FileId) -> io::Result<Vec<u8>> {
137        self.files
138            .get(file_id)
139            .cloned()
140            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))
141    }
142
143    fn write_file_range(&mut self, file_id: &FileId, offset: u64, data: &[u8]) -> io::Result<()> {
144        let file_data = self
145            .files
146            .get_mut(file_id)
147            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))?;
148        let offset = offset as usize;
149        let end = offset + data.len();
150        // Extend the file if needed.
151        if end > file_data.len() {
152            file_data.resize(end, 0);
153        }
154        file_data[offset..end].copy_from_slice(data);
155        Ok(())
156    }
157}
158
159/// Status of a single file after verification.
160#[derive(Debug, Clone)]
161pub enum FileStatus {
162    /// File is complete: full MD5 matches.
163    Complete,
164    /// File is damaged: N slices are bad.
165    Damaged(u32),
166    /// File was not found on disk.
167    Missing,
168    /// File was found at a different path (identified via 16k hash).
169    Renamed(PathBuf),
170}
171
172/// Verification result for a single file.
173#[derive(Debug, Clone)]
174pub struct FileVerification {
175    pub file_id: FileId,
176    pub filename: String,
177    pub status: FileStatus,
178    /// Per-slice validity: true = valid, false = damaged/missing.
179    pub valid_slices: Vec<bool>,
180    /// Number of damaged/missing slices.
181    pub missing_slice_count: u32,
182}
183
184/// Repairability assessment.
185#[derive(Debug, Clone)]
186pub enum Repairability {
187    /// No repair needed; all files are intact.
188    NotNeeded,
189    /// Repair is possible with available recovery data.
190    Repairable {
191        blocks_needed: u32,
192        blocks_available: u32,
193    },
194    /// Insufficient recovery data to repair.
195    Insufficient {
196        blocks_needed: u32,
197        blocks_available: u32,
198        deficit: u32,
199    },
200    /// Verification could not continue within configured resource limits.
201    ResourceLimited { reason: String },
202}
203
204/// Overall verification result.
205#[derive(Debug, Clone)]
206pub struct VerificationResult {
207    pub files: Vec<FileVerification>,
208    /// Recovery blocks the set describes. Verification never reads a recovery
209    /// payload, so this is [`Par2FileSet::recovery_block_count`]'s upper bound:
210    /// a block with a damaged payload is counted until repair validates it.
211    pub recovery_blocks_available: u32,
212    pub total_missing_blocks: u32,
213    pub repairable: Repairability,
214}
215
216impl VerificationResult {
217    pub fn needs_repair(&self) -> bool {
218        self.total_missing_blocks > 0
219            || self
220                .files
221                .iter()
222                .any(|file| !matches!(file.status, FileStatus::Complete))
223    }
224
225    pub fn refresh_repairability(&mut self) {
226        if let Repairability::ResourceLimited { .. } = self.repairable
227            && (self.total_missing_blocks > 0
228                || self
229                    .files
230                    .iter()
231                    .any(|file| !matches!(file.status, FileStatus::Complete)))
232        {
233            return;
234        }
235
236        self.repairable = repairability_for_result(
237            &self.files,
238            self.total_missing_blocks,
239            self.recovery_blocks_available,
240        );
241    }
242}
243
244fn repairability_for_result(
245    files: &[FileVerification],
246    total_missing_blocks: u32,
247    recovery_blocks_available: u32,
248) -> Repairability {
249    if total_missing_blocks == 0
250        && files
251            .iter()
252            .all(|file| matches!(file.status, FileStatus::Complete))
253    {
254        Repairability::NotNeeded
255    } else {
256        repairability_for_counts(total_missing_blocks, recovery_blocks_available)
257    }
258}
259
260fn repairability_for_result_with_resource_limit(
261    files: &[FileVerification],
262    total_missing_blocks: u32,
263    recovery_blocks_available: u32,
264    resource_limit_reason: Option<String>,
265) -> Repairability {
266    match resource_limit_reason {
267        Some(reason) => Repairability::ResourceLimited { reason },
268        None => repairability_for_result(files, total_missing_blocks, recovery_blocks_available),
269    }
270}
271
272fn repairability_for_counts(
273    total_missing_blocks: u32,
274    recovery_blocks_available: u32,
275) -> Repairability {
276    if total_missing_blocks == 0 {
277        Repairability::NotNeeded
278    } else if total_missing_blocks <= recovery_blocks_available {
279        Repairability::Repairable {
280            blocks_needed: total_missing_blocks,
281            blocks_available: recovery_blocks_available,
282        }
283    } else {
284        Repairability::Insufficient {
285            blocks_needed: total_missing_blocks,
286            blocks_available: recovery_blocks_available,
287            deficit: total_missing_blocks - recovery_blocks_available,
288        }
289    }
290}
291
292fn bounded_slice_count(par2: &Par2FileSet, length: u64) -> Option<usize> {
293    let count = usize::try_from(par2.slice_count_for_file(length)).ok()?;
294    (count <= MAX_SLICES_PER_FILE).then_some(count)
295}
296
297fn resource_limited_verification(file_id: FileId, filename: String) -> FileVerification {
298    FileVerification {
299        file_id,
300        filename,
301        status: FileStatus::Damaged(0),
302        valid_slices: Vec::new(),
303        missing_slice_count: 0,
304    }
305}
306
307/// Perform a 16KB quick-check on a file.
308///
309/// Reads the first 16384 bytes (or less if file is shorter) and computes MD5,
310/// comparing against the stored `hash_16k` from the File Description packet.
311pub fn quick_check_16k(
312    par2: &Par2FileSet,
313    file_id: &FileId,
314    access: &dyn FileAccess,
315) -> Option<bool> {
316    let mut scratch = [0u8; QUICK_CHECK_16K_BYTES];
317    quick_check_16k_with_scratch(par2, file_id, access, &mut scratch)
318}
319
320fn quick_check_16k_with_scratch(
321    par2: &Par2FileSet,
322    file_id: &FileId,
323    access: &dyn FileAccess,
324    scratch: &mut [u8; QUICK_CHECK_16K_BYTES],
325) -> Option<bool> {
326    let desc = par2.file_description(file_id)?;
327
328    if !access.file_exists(file_id) {
329        return Some(false);
330    }
331
332    let read_len = if let Some(mut reader) = access.open_sequential_reader(file_id).ok()? {
333        let scratch_len = scratch.len();
334        read_from_sequential_reader(&mut *reader, scratch, scratch_len).ok()?
335    } else {
336        access.read_file_range_into(file_id, 0, scratch).ok()?
337    };
338    let hash = checksum::md5(&scratch[..read_len]);
339    Some(hash == desc.hash_16k)
340}
341
342/// Perform full-file MD5 verification.
343pub fn verify_full_hash(
344    par2: &Par2FileSet,
345    file_id: &FileId,
346    access: &dyn FileAccess,
347) -> Option<bool> {
348    let desc = par2.file_description(file_id)?;
349    let actual_len = access.file_length(file_id)?;
350    if actual_len != desc.length {
351        return Some(false);
352    }
353    verify_full_hash_streaming(desc.hash_full, actual_len, file_id, access)
354}
355
356/// What the strict single-file streaming pass established.
357enum StrictStreamOutcome {
358    /// A slice's CRC32 disagreed with its IFSC entry, so the file's bytes
359    /// provably differ from the protected content. The stream stopped there:
360    /// the whole-file hashes were never finished, and could not have changed
361    /// the verdict (see [`verify_selected_file_ids_resolved`]).
362    SliceCrcMismatch,
363    /// The whole file streamed to the end. Verdicts for the 16 KiB prefix and
364    /// the whole-file MD5, exactly as the unfused pass reported them.
365    Hashes { quick_ok: bool, full_ok: bool },
366}
367
368/// Slice-aligned chunk size for the fused strict stream: the largest whole
369/// number of slices that fits in [`VERIFY_FULL_HASH_CHUNK_BYTES`], so every
370/// chunk ends on a slice boundary and the per-slice CRC32 needs no carry
371/// state between chunks. `None` when a single slice does not fit, in which
372/// case the stream runs unfused at the original chunk size. The buffer is
373/// therefore never larger than the unfused pass's, so the resident-memory
374/// accounting of a verify is unchanged.
375fn fused_chunk_bytes(slice_size: u64) -> Option<usize> {
376    let slice = usize::try_from(slice_size).ok()?;
377    if slice == 0 || slice > VERIFY_FULL_HASH_CHUNK_BYTES {
378        return None;
379    }
380    Some((VERIFY_FULL_HASH_CHUNK_BYTES / slice) * slice)
381}
382
383/// One streaming pass over a file that feeds the 16 KiB and whole-file MD5
384/// chains and, when `slice_checksums` is supplied, CRC32s each slice out of the
385/// same buffer.
386///
387/// The CRC32 rides along on bytes already in cache for the MD5 chain, so it
388/// costs no extra read, no extra buffer, and no extra thread — a clean file
389/// pays only the CRC32 arithmetic. Its value is the early exit: the first slice
390/// whose CRC32 disagrees with its IFSC entry proves the file's content differs
391/// from the protected content, and from there the serial MD5 chain can only
392/// confirm what is already known, so the pass abandons it.
393///
394/// `slice_checksums` must be `Some` only when the entry count matches the
395/// file's slice count and the on-disk length matches the description — the
396/// same preconditions under which [`verify_slices`] indexes those entries.
397fn stream_strict_hashes(
398    par2: &Par2FileSet,
399    file_id: &FileId,
400    access: &dyn FileAccess,
401    slice_checksums: Option<&[SliceChecksum]>,
402) -> Option<StrictStreamOutcome> {
403    let desc = par2.file_description(file_id)?;
404    let actual_len = access.file_length(file_id)?;
405    let slice_size = par2.slice_size;
406    // `fused_chunk_bytes` is `None` for a zero or oversized slice size, so a
407    // fused pass always has a positive, slice-aligned chunk.
408    let fused = slice_checksums.zip(fused_chunk_bytes(slice_size));
409    let chunk_bytes = fused.map_or(VERIFY_FULL_HASH_CHUNK_BYTES, |(_, chunk)| chunk);
410
411    let mut quick_state = checksum::FileHashState::new();
412    let mut full_state = None;
413    let mut buf = vec![0u8; chunk_bytes];
414    let mut total_read = 0u64;
415    let mut reader = access.open_sequential_reader(file_id).ok()?;
416
417    while total_read < actual_len {
418        let want = ((actual_len - total_read) as usize).min(chunk_bytes);
419        let read_len = match reader.as_mut() {
420            // Chunks are filled exactly, not by a single `read`, so the chunk
421            // boundary stays slice-aligned for the CRC32 ride-along.
422            Some(reader) => {
423                read_from_sequential_reader(&mut **reader, &mut buf[..want], want).ok()?
424            }
425            None => access
426                .read_file_range_into(file_id, total_read, &mut buf[..want])
427                .ok()?,
428        };
429        if read_len == 0 {
430            break;
431        }
432        let data = &buf[..read_len];
433        update_quick_and_full_hash_states(&mut quick_state, &mut full_state, data);
434        if let Some((checksums, _)) = fused
435            && chunk_proves_slice_damage(data, total_read, slice_size, actual_len, checksums)
436        {
437            return Some(StrictStreamOutcome::SliceCrcMismatch);
438        }
439        total_read += read_len as u64;
440        if read_len < want {
441            break;
442        }
443    }
444
445    // A file that grew past its reported length is not the described file. The
446    // unfused pass caught that by reading to EOF; the fused pass stops at
447    // `actual_len`, so it probes for a trailing byte instead.
448    let grew = match reader.as_mut() {
449        Some(reader) => {
450            let mut probe = [0u8; 1];
451            read_from_sequential_reader(&mut **reader, &mut probe, 1).ok()? != 0
452        }
453        None => false,
454    };
455    if total_read != actual_len || grew {
456        return Some(StrictStreamOutcome::Hashes {
457            quick_ok: false,
458            full_ok: false,
459        });
460    }
461
462    let quick_hash = quick_state.finalize();
463    let full_hash = full_state.map_or(quick_hash, checksum::FileHashState::finalize);
464    Some(StrictStreamOutcome::Hashes {
465        quick_ok: quick_hash == desc.hash_16k,
466        full_ok: actual_len == desc.length && full_hash == desc.hash_full,
467    })
468}
469
470/// True when some slice wholly contained in this slice-aligned chunk has a
471/// CRC32 that disagrees with its IFSC entry.
472///
473/// Only a window that is either a full slice or the file's final (zero-padded)
474/// slice is judged; a short window anywhere else is the product of a short
475/// read, and padding it would manufacture a mismatch. Padding matches
476/// [`check_slice_span`] and [`verify_slices`] exactly, so a slice this rejects
477/// is one they reject too.
478fn chunk_proves_slice_damage(
479    data: &[u8],
480    chunk_offset: u64,
481    slice_size: u64,
482    file_len: u64,
483    checksums: &[SliceChecksum],
484) -> bool {
485    debug_assert!(chunk_offset.is_multiple_of(slice_size));
486    let slice = slice_size as usize;
487    let first_slice = (chunk_offset / slice_size) as usize;
488    for (index, window) in data.chunks(slice).enumerate() {
489        let Some(expected) = checksums.get(first_slice + index) else {
490            return false;
491        };
492        let window_end = chunk_offset + (index as u64) * slice_size + window.len() as u64;
493        if window.len() != slice && window_end != file_len {
494            return false;
495        }
496        if checksum::crc32_padded(window, slice_size) != expected.crc32 {
497            return true;
498        }
499    }
500    false
501}
502
503fn verify_full_hash_streaming(
504    expected_hash: [u8; 16],
505    actual_len: u64,
506    file_id: &FileId,
507    access: &dyn FileAccess,
508) -> Option<bool> {
509    let mut state = checksum::FileHashState::new();
510    let mut buf = vec![0u8; VERIFY_FULL_HASH_CHUNK_BYTES];
511
512    if let Some(mut reader) = access.open_sequential_reader(file_id).ok()? {
513        let mut total_read = 0u64;
514        loop {
515            let read_len = reader.read(&mut buf).ok()?;
516            if read_len == 0 {
517                break;
518            }
519            state.update(&buf[..read_len]);
520            total_read += read_len as u64;
521        }
522        if total_read != actual_len {
523            return Some(false);
524        }
525    } else {
526        let mut offset = 0u64;
527        while offset < actual_len {
528            let chunk_len = ((actual_len - offset) as usize).min(buf.len());
529            let read_len = access
530                .read_file_range_into(file_id, offset, &mut buf[..chunk_len])
531                .ok()?;
532            if read_len == 0 {
533                return Some(false);
534            }
535            state.update(&buf[..read_len]);
536            offset += read_len as u64;
537        }
538    }
539
540    Some(state.finalize() == expected_hash)
541}
542
543fn update_quick_and_full_hash_states(
544    quick_state: &mut checksum::FileHashState,
545    full_state: &mut Option<checksum::FileHashState>,
546    data: &[u8],
547) {
548    if data.is_empty() {
549        return;
550    }
551    if let Some(full_state) = full_state.as_mut() {
552        full_state.update(data);
553        return;
554    }
555
556    let quick_remaining = QUICK_CHECK_16K_BYTES.saturating_sub(quick_state.bytes_fed() as usize);
557    if data.len() <= quick_remaining {
558        quick_state.update(data);
559        return;
560    }
561
562    quick_state.update(&data[..quick_remaining]);
563    let mut cloned = quick_state.clone();
564    cloned.update(&data[quick_remaining..]);
565    *full_state = Some(cloned);
566}
567
568/// Perform slice-level verification of a single file.
569///
570/// Returns a vector of booleans, one per slice: true = valid, false = damaged.
571pub fn verify_slices(
572    par2: &Par2FileSet,
573    file_id: &FileId,
574    access: &dyn FileAccess,
575) -> Option<Vec<bool>> {
576    let desc = par2.file_description(file_id)?;
577    let checksums = par2.file_checksums(file_id)?;
578    let slice_size = par2.slice_size;
579    let expected_slices = bounded_slice_count(par2, desc.length)?;
580    if checksums.len() != expected_slices {
581        return Some(vec![false; expected_slices]);
582    }
583
584    if !access.file_exists(file_id) {
585        return Some(vec![false; expected_slices]);
586    }
587
588    if let Some(results) =
589        verify_slices_batched_md5(file_id, desc.length, checksums, slice_size, access).ok()?
590    {
591        return Some(results);
592    }
593
594    let mut results = vec![false; expected_slices];
595    let mut buf = vec![0u8; VERIFY_SLICE_CHUNK_BYTES];
596
597    for (i, result) in results.iter_mut().enumerate() {
598        let offset = i as u64 * slice_size;
599        let expected_data_len = desc.length.saturating_sub(offset).min(slice_size);
600        let actual = checksum_file_slice_padded(
601            file_id,
602            offset,
603            expected_data_len,
604            slice_size,
605            access,
606            &mut buf,
607        )
608        .ok()?;
609        *result = actual.crc32 == checksums[i].crc32 && actual.md5 == checksums[i].md5;
610    }
611
612    Some(results)
613}
614
615fn verify_slices_batched_md5(
616    file_id: &FileId,
617    file_len: u64,
618    checksums: &[SliceChecksum],
619    slice_size: u64,
620    access: &dyn FileAccess,
621) -> io::Result<Option<Vec<bool>>> {
622    if checksums.is_empty() {
623        return Ok(Some(Vec::new()));
624    }
625    let Ok(slice_size_usize) = usize::try_from(slice_size) else {
626        return Ok(None);
627    };
628    if slice_size_usize == 0 {
629        return Ok(None);
630    }
631
632    // Lane count is the narrower of what the kernel offers and what the batch
633    // memory budget affords, so a wide host is used fully without the staging
634    // buffers growing past the budget.
635    let max_lanes = (VERIFY_SIMD_BATCH_MEMORY_BYTES / slice_size_usize).min(md5_simd::max_lanes());
636    debug_assert!(max_lanes <= VERIFY_SIMD_MAX_LANES);
637    if max_lanes < 2 {
638        return Ok(None);
639    }
640
641    let mut results = vec![false; checksums.len()];
642    let mut buffers = (0..max_lanes)
643        .map(|_| vec![0u8; slice_size_usize])
644        .collect::<Vec<_>>();
645
646    if let Some(mut reader) = access.open_sequential_reader(file_id)? {
647        return verify_slices_batched_md5_from_reader(
648            &mut *reader,
649            file_len,
650            checksums,
651            slice_size,
652            &mut buffers,
653        )
654        .map(Some);
655    }
656
657    let mut index = 0usize;
658
659    while index < checksums.len() {
660        let lanes = max_lanes.min(checksums.len() - index);
661        let mut read_lens = Vec::with_capacity(lanes);
662        let mut crc32s = Vec::with_capacity(lanes);
663
664        for (lane, buffer) in buffers.iter_mut().take(lanes).enumerate() {
665            let slice_index = index + lane;
666            let offset = slice_index as u64 * slice_size;
667            let expected_data_len = file_len.saturating_sub(offset).min(slice_size);
668            let read_len =
669                read_file_slice_into(file_id, offset, expected_data_len, access, buffer)?;
670            read_lens.push(read_len);
671            crc32s.push(checksum::crc32_padded(&buffer[..read_len], slice_size));
672        }
673
674        let inputs = buffers
675            .iter()
676            .take(lanes)
677            .zip(read_lens.iter())
678            .map(|(buffer, read_len)| &buffer[..*read_len])
679            .collect::<Vec<_>>();
680        let md5s = md5_simd::md5_multi(&inputs, Some(slice_size));
681
682        for lane in 0..lanes {
683            let expected = checksums[index + lane];
684            results[index + lane] = crc32s[lane] == expected.crc32 && md5s[lane] == expected.md5;
685        }
686
687        index += lanes;
688    }
689
690    Ok(Some(results))
691}
692
693fn verify_slices_batched_md5_from_reader(
694    reader: &mut dyn Read,
695    file_len: u64,
696    checksums: &[SliceChecksum],
697    slice_size: u64,
698    buffers: &mut [Vec<u8>],
699) -> io::Result<Vec<bool>> {
700    let mut results = vec![false; checksums.len()];
701    let max_lanes = buffers.len();
702    let mut index = 0usize;
703
704    while index < checksums.len() {
705        let lanes = max_lanes.min(checksums.len() - index);
706        let mut read_lens = Vec::with_capacity(lanes);
707        let mut crc32s = Vec::with_capacity(lanes);
708
709        for (lane, buffer) in buffers.iter_mut().take(lanes).enumerate() {
710            let slice_index = index + lane;
711            let offset = slice_index as u64 * slice_size;
712            let expected_data_len = file_len.saturating_sub(offset).min(slice_size) as usize;
713            let read_len = read_from_sequential_reader(reader, buffer, expected_data_len)?;
714            read_lens.push(read_len);
715            crc32s.push(checksum::crc32_padded(&buffer[..read_len], slice_size));
716        }
717
718        let inputs = buffers
719            .iter()
720            .take(lanes)
721            .zip(read_lens.iter())
722            .map(|(buffer, read_len)| &buffer[..*read_len])
723            .collect::<Vec<_>>();
724        let md5s = md5_simd::md5_multi(&inputs, Some(slice_size));
725
726        for lane in 0..lanes {
727            let expected = checksums[index + lane];
728            results[index + lane] = crc32s[lane] == expected.crc32 && md5s[lane] == expected.md5;
729        }
730
731        index += lanes;
732    }
733
734    Ok(results)
735}
736
737fn read_from_sequential_reader(
738    reader: &mut dyn Read,
739    dst: &mut [u8],
740    expected_len: usize,
741) -> io::Result<usize> {
742    let expected_len = expected_len.min(dst.len());
743    let mut read_len = 0usize;
744    while read_len < expected_len {
745        let n = reader.read(&mut dst[read_len..expected_len])?;
746        if n == 0 {
747            break;
748        }
749        read_len += n;
750    }
751    Ok(read_len)
752}
753
754fn read_file_slice_into(
755    file_id: &FileId,
756    offset: u64,
757    expected_data_len: u64,
758    access: &dyn FileAccess,
759    dst: &mut [u8],
760) -> io::Result<usize> {
761    let mut consumed = 0u64;
762
763    while consumed < expected_data_len {
764        let start = consumed as usize;
765        let remaining_capacity = dst.len().saturating_sub(start);
766        if remaining_capacity == 0 {
767            break;
768        }
769        let take = (expected_data_len - consumed).min(remaining_capacity as u64) as usize;
770        let read_len = access.read_file_range_into(
771            file_id,
772            offset + consumed,
773            &mut dst[start..start + take],
774        )?;
775        if read_len == 0 {
776            break;
777        }
778        consumed += read_len as u64;
779        if read_len < take {
780            break;
781        }
782    }
783
784    Ok(consumed as usize)
785}
786
787fn checksum_file_slice_padded(
788    file_id: &FileId,
789    offset: u64,
790    expected_data_len: u64,
791    slice_size: u64,
792    access: &dyn FileAccess,
793    buf: &mut [u8],
794) -> io::Result<SliceChecksum> {
795    let mut state = checksum::SliceChecksumState::new();
796    let mut consumed = 0u64;
797
798    while consumed < expected_data_len {
799        let take = (expected_data_len - consumed).min(buf.len() as u64) as usize;
800        let read_len = access.read_file_range_into(file_id, offset + consumed, &mut buf[..take])?;
801        if read_len == 0 {
802            break;
803        }
804        state.update(&buf[..read_len]);
805        consumed += read_len as u64;
806        if read_len < take {
807            break;
808        }
809    }
810
811    let (crc32, md5) = state.finalize(Some(slice_size));
812    Ok(SliceChecksum { crc32, md5 })
813}
814
815/// Verify file slices using pre-computed CRC32 values instead of reading from disk.
816///
817/// This is a CRC-only check (no MD5 verification). Useful when the download layer
818/// has already computed CRC32s for each slice during decode, avoiding disk re-reads.
819///
820/// Returns a vector of booleans per slice (true = CRC matches), or `None` if the
821/// file is not in the PAR2 set.
822pub fn verify_slices_from_crcs(
823    par2: &Par2FileSet,
824    file_id: &FileId,
825    slice_crcs: &[u32],
826) -> Option<Vec<bool>> {
827    let checksums = par2.file_checksums(file_id)?;
828
829    let results: Vec<bool> = checksums
830        .iter()
831        .enumerate()
832        .map(|(i, expected)| {
833            slice_crcs
834                .get(i)
835                .map(|&crc| crc == expected.crc32)
836                .unwrap_or(false)
837        })
838        .collect();
839
840    Some(results)
841}
842
843/// Options controlling verification behavior.
844#[derive(Default)]
845// Verification learns new dials over time; a new one should not cost every
846// consumer a major version. Build with `..Default::default()`.
847#[non_exhaustive]
848pub struct VerifyOptions {
849    /// If set, verification will check this token and stop early if cancelled.
850    pub cancel: Option<CancellationToken>,
851    /// If set, called with progress updates after each file is verified.
852    pub progress: Option<ProgressCallback>,
853    /// Per-file slices the caller has already proven, keyed by [`FileId`].
854    ///
855    /// Each vector is one entry per slice of that file, in the set's slice
856    /// order and of exactly the length the set's layout gives that file —
857    /// the same shape as [`FileVerification::valid_slices`], so a caller can
858    /// feed back what an earlier pass produced. `true` means "this slice is
859    /// proven intact and need not be read"; `false` means "say nothing about
860    /// it", which is what an entirely absent entry means for every slice.
861    ///
862    /// Verification then **reads only the unproven slices**, seeking over the
863    /// byte ranges the proven ones cover, and reports a result shaped exactly
864    /// as a full read would have: per-slice accounting is exact, and the file
865    /// is `Complete` only when every one of its slices is accounted for by
866    /// evidence or by a read that matched.
867    ///
868    /// # Trust contract
869    ///
870    /// This is caller-attested and is not re-derived. A wrong attestation
871    /// produces a false `Complete` for the slices it covers — verification
872    /// reads nothing that could contradict it. That is the same trust class
873    /// as the crate's other evidence paths: what a caller vouches for, it
874    /// vouches for. Attest only slices whose bytes you checksummed against
875    /// this set's own per-slice checksums, on a file that is no longer being
876    /// written.
877    ///
878    /// # When it is ignored
879    ///
880    /// Evidence applies only where a per-slice verdict can mean anything: the
881    /// file must exist, its on-disk length must equal the description's, it
882    /// must carry a complete IFSC checksum set, and it must be non-empty. A
883    /// file failing any of those takes the pipeline it always took, evidence
884    /// or no evidence — in particular, evidence about a **missing** file does
885    /// not resurrect it, and evidence about a file whose length no longer
886    /// matches is discarded rather than believed, because the offsets those
887    /// verdicts were about are not the offsets in the file now on disk. A
888    /// vector of the wrong length is ignored in full for the same reason.
889    ///
890    /// An entry that proves nothing (all `false`) is treated as absent, so an
891    /// empty or all-`false` map leaves verification byte-for-byte what it was.
892    pub proven_slices: HashMap<FileId, Vec<bool>>,
893    /// Opt-in fast-verify mode (default `false`). When enabled, an intact
894    /// candidate file whose on-disk length already matches the description is
895    /// proven complete from its per-slice IFSC checksums (CRC32 + MD5, tail
896    /// slice zero-padded) scanned span-parallel at read speed, skipping the
897    /// inherently serial full-file MD5. Off by default, in which case
898    /// verification is byte-identical to the strict full-MD5 pipeline. The
899    /// `WEAVER_PAR2_FAST_VERIFY` environment variable overrides this per verify
900    /// call, taking precedence over whatever is set here.
901    pub fast_verify: bool,
902}
903
904impl VerifyOptions {
905    /// The proven-slice evidence that applies to `file_id` under a layout of
906    /// `slice_count` slices, or `None` when this call has nothing usable to
907    /// say about that file.
908    ///
909    /// A vector of the wrong length describes a different layout than the one
910    /// being verified, and there is no honest way to line the two up, so it is
911    /// dropped whole rather than truncated or padded. A vector that proves
912    /// nothing is dropped too, so that the no-evidence path is reached by
913    /// exactly the same branch whether the caller passed no map, an empty map,
914    /// or a map of all-`false` vectors.
915    fn proven_slices_for(&self, file_id: &FileId, slice_count: usize) -> Option<&[bool]> {
916        if slice_count == 0 {
917            return None;
918        }
919        let proven = self.proven_slices.get(file_id)?;
920        if proven.len() != slice_count {
921            return None;
922        }
923        proven
924            .iter()
925            .any(|proven| *proven)
926            .then_some(proven.as_slice())
927    }
928}
929
930/// [`VerifyOptions::proven_slices_for`] for a caller that has not already
931/// computed the file's slice count. Returns `None` for a file the set does not
932/// describe or whose slice count exceeds the verifier's limits, which are the
933/// same files evidence could not be applied to anyway.
934fn proven_slices_for_file<'a>(
935    par2: &Par2FileSet,
936    options: &'a VerifyOptions,
937    file_id: &FileId,
938) -> Option<&'a [bool]> {
939    let desc = par2.file_description(file_id)?;
940    let slice_count = bounded_slice_count(par2, desc.length)?;
941    options.proven_slices_for(file_id, slice_count)
942}
943
944/// Resolve whether the fast-verify path runs for this call. Precedence: the
945/// `WEAVER_PAR2_FAST_VERIFY` environment variable wins — `"1"` forces the fast
946/// path on, `"0"` forces it off — and any other or absent value falls back to
947/// the caller's `flag`. Read once per verify call so a single process can flip
948/// the behavior via the environment without recompiling.
949fn fast_verify_enabled(flag: bool) -> bool {
950    resolve_fast_verify(
951        std::env::var("WEAVER_PAR2_FAST_VERIFY").ok().as_deref(),
952        flag,
953    )
954}
955
956/// Pure precedence rule behind [`fast_verify_enabled`], split out so the
957/// override semantics can be unit-tested without mutating process environment
958/// state shared by concurrently running tests.
959fn resolve_fast_verify(env_value: Option<&str>, flag: bool) -> bool {
960    match env_value {
961        Some("1") => true,
962        Some("0") => false,
963        _ => flag,
964    }
965}
966
967/// Verify all files in a PAR2 set.
968pub fn verify_all(par2: &Par2FileSet, access: &dyn FileAccess) -> VerificationResult {
969    verify_all_with_options(par2, access, &VerifyOptions::default())
970}
971
972/// Verify only the selected PAR2 file IDs.
973pub fn verify_selected_file_ids(
974    par2: &Par2FileSet,
975    access: &dyn FileAccess,
976    file_ids: &[FileId],
977) -> VerificationResult {
978    verify_selected_file_ids_with_options(par2, access, file_ids, &VerifyOptions::default())
979}
980
981/// Verify all files in a PAR2 set with cancellation and progress support.
982pub fn verify_all_with_options(
983    par2: &Par2FileSet,
984    access: &dyn FileAccess,
985    options: &VerifyOptions,
986) -> VerificationResult {
987    verify_selected_file_ids_with_options(par2, access, &par2.recovery_file_ids, options)
988}
989
990/// Verify selected PAR2 file IDs with one rayon task per file. Each file
991/// runs the serial single-file pipeline unchanged (no cancellation or
992/// progress on this path), so results are identical to
993/// [`verify_selected_file_ids`] with the set-level repairability assessed
994/// once over the combined outcome.
995pub fn verify_selected_file_ids_parallel(
996    par2: &Par2FileSet,
997    access: &(dyn FileAccess + Sync),
998    file_ids: &[FileId],
999) -> VerificationResult {
1000    verify_selected_file_ids_parallel_with_options(
1001        par2,
1002        access,
1003        file_ids,
1004        &VerifyOptions::default(),
1005    )
1006}
1007
1008/// Like [`verify_selected_file_ids_parallel`] but honoring [`VerifyOptions`].
1009/// The options consulted on this path are `fast_verify` and `proven_slices`
1010/// (cancellation and progress are not surfaced here, as documented on the base
1011/// function). Per-slice evidence is applied first and reads only the slices it
1012/// does not already prove; it subsumes the fast arm for the files it covers,
1013/// because both prove completeness from the same IFSC entries. When
1014/// fast verify resolves on (via the flag or `WEAVER_PAR2_FAST_VERIFY`), each
1015/// eligible file is proven complete from its IFSC slice checksums instead of a
1016/// full-file MD5 — span-parallel for a lone file and file-parallel otherwise,
1017/// the same single-axis rule as the post-repair readback. Ineligible files
1018/// fall back to the serial pipeline. With fast verify off this is
1019/// byte-identical to [`verify_selected_file_ids_parallel`].
1020pub fn verify_selected_file_ids_parallel_with_options(
1021    par2: &Par2FileSet,
1022    access: &(dyn FileAccess + Sync),
1023    file_ids: &[FileId],
1024    options: &VerifyOptions,
1025) -> VerificationResult {
1026    use rayon::prelude::*;
1027    let fast_verify = fast_verify_enabled(options.fast_verify);
1028    // Parallelism runs on exactly one axis: span-parallel for a lone file,
1029    // else file-parallel with each file's spans serial (mirrors
1030    // `verify_repaired_file_ids_parallel`).
1031    let span_parallel = file_ids.len() == 1;
1032    let partials: Vec<VerificationResult> = file_ids
1033        .par_iter()
1034        .map(|file_id| {
1035            // Per-slice evidence first: it subsumes the fast arm (both prove
1036            // from the same IFSC entries) and reads strictly less.
1037            if let Some(proven) = proven_slices_for_file(par2, options, file_id)
1038                && let Some(file) = verify_file_sliced_with_evidence(
1039                    par2,
1040                    access,
1041                    file_id,
1042                    span_parallel,
1043                    Some(proven),
1044                )
1045            {
1046                return single_file_result(par2, file);
1047            }
1048            if fast_verify
1049                && let Some(file) = verify_file_sliced(par2, access, file_id, span_parallel)
1050            {
1051                return single_file_result(par2, file);
1052            }
1053            // A lone file has no other rayon axis in flight, so the strict
1054            // pipeline's damaged branch may fan out across this file's spans.
1055            // Otherwise identical to the `verify_selected_file_ids` call this
1056            // replaced, `fast_verify` included — an eligible file was already
1057            // handled above, and an ineligible one reaches the same branch
1058            // either way.
1059            verify_selected_file_ids_resolved(
1060                par2,
1061                access,
1062                span_parallel.then_some(access),
1063                std::slice::from_ref(file_id),
1064                &VerifyOptions::default(),
1065                fast_verify,
1066            )
1067        })
1068        .collect();
1069    combine_partial_results(par2, partials)
1070}
1071
1072/// Post-repair verification for staged files: every byte is read back and
1073/// checked against its IFSC slice checksum (MD5 + CRC32, tail padded),
1074/// with slice spans verified concurrently. Coverage matches the serial
1075/// full-hash pipeline — these are the same per-slice checksums that gate
1076/// block reuse during scanning — but the whole-file MD5 chain (inherently
1077/// serial) is replaced by slice-parallel hashing, so a repaired multi-GB
1078/// file verifies at read speed. `Complete` from slice proof is sound here
1079/// because a staged file's identity is definitional (the repair just built
1080/// it from the set's own layout); the scanner's stricter full-MD5 rule
1081/// exists to establish identity of found files, not to re-check content.
1082/// Files without IFSC data or with zero length take the serial single-file
1083/// pipeline, full-file MD5 included.
1084pub fn verify_repaired_file_ids_parallel(
1085    par2: &Par2FileSet,
1086    access: &(dyn FileAccess + Sync),
1087    file_ids: &[FileId],
1088) -> VerificationResult {
1089    use rayon::prelude::*;
1090    // Parallelism runs on exactly one axis: across files when there are
1091    // several, across a lone file's slice spans otherwise. Nesting the span
1092    // fan-out inside the file fan-out lets a blocked worker steal other
1093    // files' verification frames onto one stack — the same steal-on-block
1094    // recursion that overflowed worker stacks in the candidate scanner.
1095    let span_parallel = file_ids.len() == 1;
1096    let partials: Vec<VerificationResult> = file_ids
1097        .par_iter()
1098        .map(|file_id| {
1099            verify_repaired_file_sliced(par2, access, file_id, span_parallel).unwrap_or_else(|| {
1100                verify_selected_file_ids(par2, access, std::slice::from_ref(file_id))
1101            })
1102        })
1103        .collect();
1104    combine_partial_results(par2, partials)
1105}
1106
1107/// Precomputed layout for slice-proof verification of one eligible file: its
1108/// filename, on-disk length, slice size, slice count, and the work to do.
1109/// Produced by [`sliced_verify_plan`] only for files that pass the shared
1110/// fast-path / post-repair preconditions.
1111struct SlicedVerifyPlan {
1112    filename: String,
1113    length: u64,
1114    slice_size: u64,
1115    slice_count: usize,
1116    work: SlicedVerifyWork,
1117    /// Caller-attested per-slice verdicts, or `None` when this plan reads the
1118    /// whole file. When present it is exactly `slice_count` long, and every
1119    /// `true` entry names a slice the work does not cover.
1120    proven: Option<Vec<bool>>,
1121}
1122
1123/// What one file's slice-proof pass has to read.
1124///
1125/// The two shapes are not interchangeable, and the difference is the whole
1126/// reason evidence pays off instead of costing.
1127///
1128/// Without evidence every slice is read, so the work is contiguous spans: one
1129/// span read fills one buffer that many slices are then hashed out of, in SIMD
1130/// lanes. That is the shape the fast-verify and post-repair readbacks have
1131/// always used, and it is untouched here.
1132///
1133/// With evidence the slices still to read can be scattered anywhere in the
1134/// file, and spanning them the same way would hand `md5_multi` one slice per
1135/// call for a mask that alternates — one lane of an eight-lane engine. That is
1136/// slower than reading the whole file, which would make the option a
1137/// pessimisation on exactly the masks a real host produces. So evidence work is
1138/// a list of slice indices instead, and the lanes are filled from wherever
1139/// those slices are: batching by lane rather than by adjacency. Reading one
1140/// slice per lane at an arbitrary offset is the same I/O discipline
1141/// [`verify_slices_batched_md5`] already uses for a whole file.
1142enum SlicedVerifyWork {
1143    /// `(start, count)` runs that together cover every slice, in order.
1144    Spans(Vec<(usize, usize)>),
1145    /// The slice indices to read, ascending. May be non-contiguous, and is
1146    /// empty for a fully proven file — the zero-read case.
1147    Slices(Vec<usize>),
1148}
1149
1150/// Build the [`SlicedVerifyPlan`] for `file_id`, or `None` when the file is
1151/// ineligible for slice-proof verification: no file description, no or
1152/// incomplete IFSC checksums (the count must equal the expected slice count),
1153/// zero length or slice size, or an on-disk length that does not match the
1154/// description. These are exactly the preconditions the opt-in fast-verify
1155/// path and the post-repair readback share; an ineligible file falls back to
1156/// the serial full-hash pipeline.
1157///
1158/// `proven` folds in caller-attested per-slice evidence: the work then names
1159/// only the unproven slices. The eligibility gate above is deliberately
1160/// unchanged by it — evidence buys a shorter read of a file that was already
1161/// verifiable slice by slice, and buys nothing anywhere else. In particular the
1162/// on-disk length must still equal the description's, which is what makes a
1163/// per-slice verdict addressable at all: the offsets the caller's verdicts were
1164/// about are the offsets in this file only while its length is the described
1165/// one. `proven` must be `slice_count` long; callers reach this through
1166/// [`VerifyOptions::proven_slices_for`], which drops any other shape, and it is
1167/// re-checked here so no future caller can route around that.
1168fn sliced_verify_plan(
1169    par2: &Par2FileSet,
1170    access: &dyn FileAccess,
1171    file_id: &FileId,
1172    proven: Option<&[bool]>,
1173) -> Option<SlicedVerifyPlan> {
1174    let desc = par2.file_description(file_id)?;
1175    let checksums = par2.file_checksums(file_id)?;
1176    let slice_size = par2.slice_size;
1177    if desc.length == 0 || slice_size == 0 {
1178        return None;
1179    }
1180    let expected_slices = bounded_slice_count(par2, desc.length)?;
1181    if expected_slices == 0 || checksums.len() != expected_slices {
1182        return None;
1183    }
1184    if access.file_length(file_id) != Some(desc.length) {
1185        return None;
1186    }
1187    let proven = proven.filter(|proven| proven.len() == expected_slices);
1188
1189    let work = match proven {
1190        None => {
1191            let span_slices = ((VERIFY_SPAN_TARGET_BYTES as u64 / slice_size).max(1) as usize)
1192                .min(expected_slices);
1193            SlicedVerifyWork::Spans(
1194                (0..expected_slices)
1195                    .step_by(span_slices)
1196                    .map(|start| (start, span_slices.min(expected_slices - start)))
1197                    .collect(),
1198            )
1199        }
1200        Some(proven) => SlicedVerifyWork::Slices(
1201            proven
1202                .iter()
1203                .enumerate()
1204                .filter_map(|(index, proven)| (!*proven).then_some(index))
1205                .collect(),
1206        ),
1207    };
1208    Some(SlicedVerifyPlan {
1209        filename: desc.filename.clone(),
1210        length: desc.length,
1211        slice_size,
1212        slice_count: expected_slices,
1213        work,
1214        proven: proven.map(<[bool]>::to_vec),
1215    })
1216}
1217
1218/// How many slices one [`md5_simd::md5_multi`] call takes at this slice size:
1219/// the narrower of what the kernel offers and what the batch memory budget
1220/// affords, and never zero. Identical to the rule
1221/// [`verify_slices_batched_md5`] uses, so a scattered evidence read and a
1222/// whole-file read cost the same per slice and hold the same working set.
1223fn slice_batch_lanes(slice_size: u64) -> usize {
1224    let Ok(slice_size) = usize::try_from(slice_size) else {
1225        return 1;
1226    };
1227    if slice_size == 0 {
1228        return 1;
1229    }
1230    (VERIFY_SIMD_BATCH_MEMORY_BYTES / slice_size)
1231        .min(md5_simd::max_lanes())
1232        .clamp(1, VERIFY_SIMD_MAX_LANES)
1233}
1234
1235/// How many slices one parallel task takes, so a scattered read fans out in
1236/// units comparable to a span rather than one lane batch at a time. Always a
1237/// whole number of lane batches, so no task ends mid-batch.
1238fn slice_task_slices(slice_size: u64, lanes: usize) -> usize {
1239    let per_task = (VERIFY_SPAN_TARGET_BYTES as u64 / slice_size.max(1)).max(1) as usize;
1240    per_task.div_ceil(lanes).max(1) * lanes
1241}
1242
1243/// Weave the read-backs into one per-slice validity vector.
1244///
1245/// The starting point is the caller's evidence, so a proven slice keeps its
1246/// attested verdict, and every slice the work covered is overwritten by what
1247/// the read found. Without evidence the spans tile the file, so this is a
1248/// scatter-shaped `concat`: the result is identical to the full-read vector,
1249/// which is the property `verify_selected_file_ids` with no evidence relies on.
1250fn assemble_valid_slices(plan: &SlicedVerifyPlan, results: Vec<Vec<bool>>) -> Vec<bool> {
1251    let mut valid = match &plan.proven {
1252        Some(proven) => proven.clone(),
1253        None => vec![false; plan.slice_count],
1254    };
1255    match &plan.work {
1256        SlicedVerifyWork::Spans(spans) => {
1257            for (&(start, count), results) in spans.iter().zip(results) {
1258                debug_assert_eq!(results.len(), count, "a span reports one verdict per slice");
1259                let end = (start + count).min(valid.len());
1260                valid[start..end].copy_from_slice(&results[..end - start]);
1261            }
1262        }
1263        SlicedVerifyWork::Slices(indices) => {
1264            for (index, result) in indices.iter().zip(results.into_iter().flatten()) {
1265                valid[*index] = result;
1266            }
1267        }
1268    }
1269    valid
1270}
1271
1272/// Verify one lane batch of slices, wherever in the file they are.
1273///
1274/// Each slice is read into its own lane buffer at its own offset and the
1275/// batch is hashed in one [`md5_simd::md5_multi`] call, so a scattered set of
1276/// slices costs the same per slice as an adjacent one. Padding semantics match
1277/// [`check_slice_span`] exactly — `md5_multi(.., Some(slice_size))` and
1278/// [`checksum::crc32_padded`] zero-pad a short tail slice — so a slice this
1279/// judges is judged the same way the whole-file paths judge it.
1280///
1281/// A read error or a short read fails only the slice it happened to, not the
1282/// batch: a lane batch is an arbitrary grouping, not a contiguous run, so
1283/// failing its neighbours would report damage that was never observed.
1284fn check_slice_batch(
1285    access: &dyn FileAccess,
1286    file_id: &FileId,
1287    checksums: &[SliceChecksum],
1288    plan: &SlicedVerifyPlan,
1289    indices: &[usize],
1290    buffers: &mut Vec<Vec<u8>>,
1291) -> Vec<bool> {
1292    let slice_size = plan.slice_size;
1293    let Ok(slice_len) = usize::try_from(slice_size) else {
1294        return vec![false; indices.len()];
1295    };
1296    while buffers.len() < indices.len() {
1297        buffers.push(vec![0u8; slice_len]);
1298    }
1299
1300    let mut read_lens = Vec::with_capacity(indices.len());
1301    let mut read_complete = Vec::with_capacity(indices.len());
1302    for (lane, &index) in indices.iter().enumerate() {
1303        let offset = index as u64 * slice_size;
1304        let want = plan.length.saturating_sub(offset).min(slice_size);
1305        let buffer = &mut buffers[lane];
1306        if buffer.len() < slice_len {
1307            buffer.resize(slice_len, 0);
1308        }
1309        match read_file_slice_into(file_id, offset, want, access, &mut buffer[..slice_len]) {
1310            Ok(read) => {
1311                read_lens.push(read);
1312                read_complete.push(read as u64 == want);
1313            }
1314            Err(_) => {
1315                read_lens.push(0);
1316                read_complete.push(false);
1317            }
1318        }
1319    }
1320
1321    let inputs: Vec<&[u8]> = buffers
1322        .iter()
1323        .zip(read_lens.iter())
1324        .map(|(buffer, read)| &buffer[..*read])
1325        .collect();
1326    let md5s = md5_simd::md5_multi(&inputs, Some(slice_size));
1327    indices
1328        .iter()
1329        .enumerate()
1330        .map(|(lane, index)| {
1331            let expected = &checksums[*index];
1332            read_complete[lane]
1333                && checksum::crc32_padded(inputs[lane], slice_size) == expected.crc32
1334                && md5s[lane] == expected.md5
1335        })
1336        .collect()
1337}
1338
1339/// Verify a run of slice indices serially, lane batch by lane batch.
1340fn check_slice_indices(
1341    access: &dyn FileAccess,
1342    file_id: &FileId,
1343    checksums: &[SliceChecksum],
1344    plan: &SlicedVerifyPlan,
1345    indices: &[usize],
1346    buffers: &mut Vec<Vec<u8>>,
1347) -> Vec<bool> {
1348    let lanes = slice_batch_lanes(plan.slice_size);
1349    indices
1350        .chunks(lanes)
1351        .flat_map(|batch| check_slice_batch(access, file_id, checksums, plan, batch, buffers))
1352        .collect()
1353}
1354
1355/// Verify one `(start, count)` slice span against its IFSC checksums (CRC32 +
1356/// MD5, tail slice zero-padded to `slice_size`). Returns one bool per slice in
1357/// the span; a read error or short read fails the whole span. Shared verbatim
1358/// by the serial and span-parallel drivers.
1359///
1360/// The span is read into the caller's `scratch` buffer (grown once, reused
1361/// across spans) through the same short-read-looping [`read_file_slice_into`]
1362/// the strict pipeline uses — a bare `read_file_range_into` is one `read(2)`
1363/// and may come up short on a multi-megabyte span, which would report the
1364/// whole span damaged. Its slices are then hashed in SIMD lanes
1365/// ([`md5_simd::md5_multi`], [`md5_simd::max_lanes`] at a time) rather
1366/// than one scalar MD5 per slice, matching the strict path's
1367/// [`verify_slices_batched_md5`]. Padding semantics are unchanged:
1368/// `md5_multi(.., Some(slice_size))` and [`checksum::crc32_padded`] zero-pad a
1369/// short tail slice exactly as `SliceChecksumState::finalize(Some(slice_size))`
1370/// did.
1371fn check_slice_span(
1372    access: &dyn FileAccess,
1373    file_id: &FileId,
1374    checksums: &[SliceChecksum],
1375    plan: &SlicedVerifyPlan,
1376    start: usize,
1377    count: usize,
1378    scratch: &mut Vec<u8>,
1379) -> Vec<bool> {
1380    let slice_size = plan.slice_size;
1381    let offset = start as u64 * slice_size;
1382    let want = (plan.length - offset).min(count as u64 * slice_size);
1383    let Ok(want_len) = usize::try_from(want) else {
1384        return vec![false; count];
1385    };
1386    if scratch.len() < want_len {
1387        scratch.resize(want_len, 0);
1388    }
1389    let Ok(read) = read_file_slice_into(file_id, offset, want, access, &mut scratch[..want_len])
1390    else {
1391        return vec![false; count];
1392    };
1393    if read != want_len {
1394        return vec![false; count];
1395    }
1396    let data = &scratch[..want_len];
1397
1398    let mut valid = Vec::with_capacity(count);
1399    let mut index = 0usize;
1400    let kernel_lanes = md5_simd::max_lanes().min(VERIFY_SIMD_MAX_LANES);
1401    while index < count {
1402        let lanes = kernel_lanes.min(count - index);
1403        let mut inputs: [&[u8]; VERIFY_SIMD_MAX_LANES] = [&[]; VERIFY_SIMD_MAX_LANES];
1404        for (lane, input) in inputs.iter_mut().take(lanes).enumerate() {
1405            let slice_index = (index + lane) as u64;
1406            let lo = (slice_index * slice_size).min(want) as usize;
1407            let hi = ((slice_index + 1) * slice_size).min(want) as usize;
1408            *input = &data[lo..hi];
1409        }
1410        let inputs = &inputs[..lanes];
1411        let md5s = md5_simd::md5_multi(inputs, Some(slice_size));
1412        for (lane, input) in inputs.iter().enumerate() {
1413            let expected = &checksums[start + index + lane];
1414            let crc32 = checksum::crc32_padded(input, slice_size);
1415            valid.push(crc32 == expected.crc32 && md5s[lane] == expected.md5);
1416        }
1417        index += lanes;
1418    }
1419    valid
1420}
1421
1422/// Assemble the per-file outcome from a completed slice-validity vector:
1423/// `Complete` when every slice matched, else `Damaged(n)` carrying the same
1424/// per-slice validity vector the serial pipeline produces (repair planning
1425/// consumes `valid_slices` identically, so the damaged shape must match).
1426fn finish_sliced_verification(
1427    file_id: &FileId,
1428    plan: &SlicedVerifyPlan,
1429    valid_slices: Vec<bool>,
1430) -> FileVerification {
1431    let damaged = valid_slices.iter().filter(|valid| !**valid).count() as u32;
1432    let status = if damaged == 0 {
1433        FileStatus::Complete
1434    } else {
1435        FileStatus::Damaged(damaged)
1436    };
1437    FileVerification {
1438        file_id: *file_id,
1439        filename: plan.filename.clone(),
1440        status,
1441        valid_slices,
1442        missing_slice_count: damaged,
1443    }
1444}
1445
1446/// Per-slice validity for the strict pipeline's damaged branch.
1447///
1448/// Uses the span-parallel scanner when the caller established there is no
1449/// other rayon axis in flight and the file is eligible for it, else the serial
1450/// [`verify_slices`]. Both compare the same per-slice CRC32 and zero-padded
1451/// MD5 against the same IFSC entries, so the vectors agree; only the span
1452/// iteration differs. An ineligible file (missing or short IFSC, length
1453/// mismatch) falls back, which is also where a length mismatch lands.
1454fn strict_slice_validity(
1455    par2: &Par2FileSet,
1456    access: &dyn FileAccess,
1457    span_access: Option<&(dyn FileAccess + Sync)>,
1458    file_id: &FileId,
1459    slice_count: usize,
1460) -> Vec<bool> {
1461    if let Some(span_access) = span_access
1462        && let Some(file) = verify_file_sliced(par2, span_access, file_id, true)
1463        && file.valid_slices.len() == slice_count
1464    {
1465        return file.valid_slices;
1466    }
1467    verify_slices(par2, file_id, access).unwrap_or_else(|| vec![false; slice_count])
1468}
1469
1470/// Wrap a single [`FileVerification`] into a set-level [`VerificationResult`],
1471/// assessing repairability over just that file. Used by the per-file parallel
1472/// drivers that fold their partials with [`combine_partial_results`].
1473fn single_file_result(par2: &Par2FileSet, file: FileVerification) -> VerificationResult {
1474    let damaged = file.missing_slice_count;
1475    let files = vec![file];
1476    let recovery_blocks_available = par2.recovery_block_count();
1477    let repairable = repairability_for_result_with_resource_limit(
1478        &files,
1479        damaged,
1480        recovery_blocks_available,
1481        None,
1482    );
1483    VerificationResult {
1484        files,
1485        recovery_blocks_available,
1486        total_missing_blocks: damaged,
1487        repairable,
1488    }
1489}
1490
1491/// Slice-span verification of one file against its IFSC checksums, spans run
1492/// concurrently when `span_parallel` is set. Returns `None` for ineligible
1493/// files (see [`sliced_verify_plan`]); the caller falls back to the serial
1494/// full-hash pipeline.
1495///
1496/// Why slice proof + length is sound (and why strict full-MD5 stays the
1497/// default): the per-slice IFSC checksums are the very CRC32 + MD5 pairs that
1498/// gate block reuse during scanning, and together they cover every byte of the
1499/// file (the final slice is zero-padded to `slice_size`). With the on-disk
1500/// length equal to the description length, a file whose every slice matches
1501/// CRC32 **and** MD5 can only diverge from the whole-file MD5 if some slice
1502/// suffered a simultaneous MD5 **and** CRC32 collision. The threat model here
1503/// is random media damage / bit rot, against which per-slice MD5+CRC32 is as
1504/// decisive as a whole-file MD5 — so fast verify trades the inherently serial
1505/// full-file MD5 (~0.8 GB/s wall on fast disks) for the same slice checks run
1506/// at read speed. It stays **opt-in**: identity establishment and the
1507/// misplaced/renamed-file scanner keep the stricter whole-file rule, which
1508/// exists to *establish* a found file's identity, not to re-check the content
1509/// of a file already matched by length.
1510fn verify_file_sliced(
1511    par2: &Par2FileSet,
1512    access: &(dyn FileAccess + Sync),
1513    file_id: &FileId,
1514    span_parallel: bool,
1515) -> Option<FileVerification> {
1516    verify_file_sliced_with_evidence(par2, access, file_id, span_parallel, None)
1517}
1518
1519/// [`verify_file_sliced`] over only the slices the caller has not proven.
1520///
1521/// The slices that *are* read are checked against the identical IFSC entries
1522/// with the identical padding, so a slice's verdict does not depend on whether
1523/// its neighbours were skipped; only which slices get read changes.
1524/// `Complete` still means every slice is accounted for — the difference is
1525/// that some are accounted for by the caller's attestation rather than by this
1526/// read. See [`VerifyOptions::proven_slices`] for what that costs if the
1527/// attestation is wrong.
1528fn verify_file_sliced_with_evidence(
1529    par2: &Par2FileSet,
1530    access: &(dyn FileAccess + Sync),
1531    file_id: &FileId,
1532    span_parallel: bool,
1533    proven: Option<&[bool]>,
1534) -> Option<FileVerification> {
1535    use rayon::prelude::*;
1536    let plan = sliced_verify_plan(par2, access, file_id, proven)?;
1537    let checksums = par2.file_checksums(file_id)?;
1538    let results: Vec<Vec<bool>> = match (&plan.work, span_parallel) {
1539        // `map_init` hands each rayon job its own read buffer, reused across
1540        // the units that job takes; `collect` preserves order.
1541        (SlicedVerifyWork::Spans(spans), true) => spans
1542            .par_iter()
1543            .map_init(Vec::new, |scratch, &(start, count)| {
1544                check_slice_span(access, file_id, checksums, &plan, start, count, scratch)
1545            })
1546            .collect(),
1547        (SlicedVerifyWork::Spans(spans), false) => {
1548            let mut scratch = Vec::new();
1549            spans
1550                .iter()
1551                .map(|&(start, count)| {
1552                    check_slice_span(
1553                        access,
1554                        file_id,
1555                        checksums,
1556                        &plan,
1557                        start,
1558                        count,
1559                        &mut scratch,
1560                    )
1561                })
1562                .collect()
1563        }
1564        (SlicedVerifyWork::Slices(indices), true) => {
1565            let lanes = slice_batch_lanes(plan.slice_size);
1566            indices
1567                .par_chunks(slice_task_slices(plan.slice_size, lanes))
1568                .map_init(Vec::new, |buffers, chunk| {
1569                    check_slice_indices(access, file_id, checksums, &plan, chunk, buffers)
1570                })
1571                .collect()
1572        }
1573        (SlicedVerifyWork::Slices(indices), false) => {
1574            let mut buffers = Vec::new();
1575            vec![check_slice_indices(
1576                access,
1577                file_id,
1578                checksums,
1579                &plan,
1580                indices,
1581                &mut buffers,
1582            )]
1583        }
1584    };
1585    let valid_slices = assemble_valid_slices(&plan, results);
1586    Some(finish_sliced_verification(file_id, &plan, valid_slices))
1587}
1588
1589/// Serial (span-sequential) sibling of [`verify_file_sliced`] for callers
1590/// holding a non-`Sync` [`FileAccess`] — namely the serial per-file verify
1591/// loop, which may itself run inside a file-parallel `par_iter` and so must
1592/// not open a second rayon axis. Shares the same eligibility gate and per-span
1593/// checks; only the span iteration differs.
1594fn verify_file_sliced_serial(
1595    par2: &Par2FileSet,
1596    access: &dyn FileAccess,
1597    file_id: &FileId,
1598) -> Option<FileVerification> {
1599    verify_file_sliced_serial_with_evidence(par2, access, file_id, None)
1600}
1601
1602/// [`verify_file_sliced_serial`] over only the slices the caller has not
1603/// proven — the span-serial sibling of
1604/// [`verify_file_sliced_with_evidence`], used by the per-file verify loop
1605/// which may itself be running inside a file-parallel `par_iter`.
1606fn verify_file_sliced_serial_with_evidence(
1607    par2: &Par2FileSet,
1608    access: &dyn FileAccess,
1609    file_id: &FileId,
1610    proven: Option<&[bool]>,
1611) -> Option<FileVerification> {
1612    let plan = sliced_verify_plan(par2, access, file_id, proven)?;
1613    let checksums = par2.file_checksums(file_id)?;
1614    let results: Vec<Vec<bool>> = match &plan.work {
1615        SlicedVerifyWork::Spans(spans) => {
1616            let mut scratch = Vec::new();
1617            spans
1618                .iter()
1619                .map(|&(start, count)| {
1620                    check_slice_span(
1621                        access,
1622                        file_id,
1623                        checksums,
1624                        &plan,
1625                        start,
1626                        count,
1627                        &mut scratch,
1628                    )
1629                })
1630                .collect()
1631        }
1632        SlicedVerifyWork::Slices(indices) => {
1633            let mut buffers = Vec::new();
1634            vec![check_slice_indices(
1635                access,
1636                file_id,
1637                checksums,
1638                &plan,
1639                indices,
1640                &mut buffers,
1641            )]
1642        }
1643    };
1644    let valid_slices = assemble_valid_slices(&plan, results);
1645    Some(finish_sliced_verification(file_id, &plan, valid_slices))
1646}
1647
1648/// Slice-level readback verification of one staged file. Thin wrapper over
1649/// [`verify_file_sliced`] (see there for the completeness argument) that lifts
1650/// the per-file outcome into a set-level result. `Complete` from slice proof
1651/// is additionally sound for a staged file because the repair just built it
1652/// from the set's own layout, so its identity is definitional. Returns `None`
1653/// for ineligible files (no description, no or incomplete IFSC, zero length,
1654/// or a length mismatch) — the caller falls back to the serial pipeline.
1655fn verify_repaired_file_sliced(
1656    par2: &Par2FileSet,
1657    access: &(dyn FileAccess + Sync),
1658    file_id: &FileId,
1659    span_parallel: bool,
1660) -> Option<VerificationResult> {
1661    verify_file_sliced(par2, access, file_id, span_parallel)
1662        .map(|file| single_file_result(par2, file))
1663}
1664
1665/// Fold per-file verification results into one set-level result, summing
1666/// totals and reassessing repairability once over the combined files.
1667fn combine_partial_results(
1668    par2: &Par2FileSet,
1669    partials: Vec<VerificationResult>,
1670) -> VerificationResult {
1671    let mut files = Vec::with_capacity(partials.len());
1672    let mut total_missing_blocks = 0u32;
1673    let mut resource_limit_reason = None;
1674    for partial in partials {
1675        total_missing_blocks = total_missing_blocks.saturating_add(partial.total_missing_blocks);
1676        if let Repairability::ResourceLimited { reason } = &partial.repairable {
1677            resource_limit_reason.get_or_insert_with(|| reason.clone());
1678        }
1679        files.extend(partial.files);
1680    }
1681
1682    let recovery_blocks_available = par2.recovery_block_count();
1683    let repairable = repairability_for_result_with_resource_limit(
1684        &files,
1685        total_missing_blocks,
1686        recovery_blocks_available,
1687        resource_limit_reason,
1688    );
1689    VerificationResult {
1690        files,
1691        recovery_blocks_available,
1692        total_missing_blocks,
1693        repairable,
1694    }
1695}
1696
1697/// Overlay `updated` per-file verifications onto `base` (matched by file
1698/// id), recomputing the totals and set-level repairability. Files absent
1699/// from `updated` keep their `base` entry — the caller asserts their state
1700/// on disk has not changed since `base` was computed.
1701pub fn merge_verification_results(
1702    par2: &Par2FileSet,
1703    base: &VerificationResult,
1704    updated: VerificationResult,
1705) -> VerificationResult {
1706    let mut updated_by_id: HashMap<FileId, FileVerification> = updated
1707        .files
1708        .into_iter()
1709        .map(|file| (file.file_id, file))
1710        .collect();
1711    let files: Vec<FileVerification> = base
1712        .files
1713        .iter()
1714        .map(|file| {
1715            updated_by_id
1716                .remove(&file.file_id)
1717                .unwrap_or_else(|| file.clone())
1718        })
1719        .collect();
1720
1721    let mut total_missing_blocks = 0u32;
1722    for file in &files {
1723        total_missing_blocks = total_missing_blocks.saturating_add(file.missing_slice_count);
1724    }
1725    let resource_limit_reason = match &updated.repairable {
1726        Repairability::ResourceLimited { reason } => Some(reason.clone()),
1727        _ => match &base.repairable {
1728            Repairability::ResourceLimited { reason } => Some(reason.clone()),
1729            _ => None,
1730        },
1731    };
1732
1733    let recovery_blocks_available = par2.recovery_block_count();
1734    let repairable = repairability_for_result_with_resource_limit(
1735        &files,
1736        total_missing_blocks,
1737        recovery_blocks_available,
1738        resource_limit_reason,
1739    );
1740    VerificationResult {
1741        files,
1742        recovery_blocks_available,
1743        total_missing_blocks,
1744        repairable,
1745    }
1746}
1747
1748/// Verify selected PAR2 file IDs with cancellation and progress support.
1749pub fn verify_selected_file_ids_with_options(
1750    par2: &Par2FileSet,
1751    access: &dyn FileAccess,
1752    file_ids: &[FileId],
1753    options: &VerifyOptions,
1754) -> VerificationResult {
1755    // Resolved once per call: env override wins, otherwise the option flag.
1756    let fast_verify = fast_verify_enabled(options.fast_verify);
1757    verify_selected_file_ids_resolved(par2, access, None, file_ids, options, fast_verify)
1758}
1759
1760/// [`verify_selected_file_ids_with_options`] with `fast_verify` already
1761/// resolved. Split out (like [`resolve_fast_verify`]) so tests that assert
1762/// a specific pipeline's I/O discipline can pin the mode instead of
1763/// inheriting an ambient `WEAVER_PAR2_FAST_VERIFY` from the test
1764/// environment.
1765///
1766/// `span_access` is the same object as `access`, supplied only by a caller
1767/// that has established there is no other rayon axis in flight (a lone file).
1768/// It lets the damaged branch's slice scan fan out across spans instead of
1769/// walking them serially; the per-slice results are identical either way.
1770fn verify_selected_file_ids_resolved(
1771    par2: &Par2FileSet,
1772    access: &dyn FileAccess,
1773    span_access: Option<&(dyn FileAccess + Sync)>,
1774    file_ids: &[FileId],
1775    options: &VerifyOptions,
1776    fast_verify: bool,
1777) -> VerificationResult {
1778    let mut files = Vec::new();
1779    let mut total_missing_blocks = 0u32;
1780    let mut resource_limit_reason = None;
1781    let total_files = file_ids.len() as u32;
1782    let mut bytes_processed = 0u64;
1783
1784    for (file_index, file_id) in file_ids.iter().enumerate() {
1785        // Check cancellation before each file.
1786        if let Some(ref cancel) = options.cancel
1787            && cancel.is_cancelled()
1788        {
1789            break;
1790        }
1791
1792        let desc = match par2.file_description(file_id) {
1793            Some(d) => d,
1794            None => continue,
1795        };
1796        let Some(slice_count) = bounded_slice_count(par2, desc.length) else {
1797            resource_limit_reason.get_or_insert_with(|| {
1798                format!("file {} exceeds verifier slice limits", desc.filename)
1799            });
1800            files.push(resource_limited_verification(
1801                *file_id,
1802                desc.filename.clone(),
1803            ));
1804            continue;
1805        };
1806        let slice_count_u32 = slice_count as u32;
1807
1808        if !access.file_exists(file_id) {
1809            total_missing_blocks = total_missing_blocks.saturating_add(slice_count_u32);
1810            files.push(FileVerification {
1811                file_id: *file_id,
1812                filename: desc.filename.clone(),
1813                status: FileStatus::Missing,
1814                valid_slices: vec![false; slice_count],
1815                missing_slice_count: slice_count_u32,
1816            });
1817            continue;
1818        }
1819
1820        let Some(actual_len) = access.file_length(file_id) else {
1821            total_missing_blocks = total_missing_blocks.saturating_add(slice_count_u32);
1822            files.push(FileVerification {
1823                file_id: *file_id,
1824                filename: desc.filename.clone(),
1825                status: FileStatus::Damaged(slice_count_u32),
1826                valid_slices: vec![false; slice_count],
1827                missing_slice_count: slice_count_u32,
1828            });
1829            continue;
1830        };
1831
1832        // Empty file: PAR2 spec says 0-length files have 0 slices.
1833        // Just verify the file exists and has the expected length.
1834        if desc.length == 0 {
1835            let status = if actual_len == 0 {
1836                FileStatus::Complete
1837            } else {
1838                FileStatus::Damaged(0)
1839            };
1840            files.push(FileVerification {
1841                file_id: *file_id,
1842                filename: desc.filename.clone(),
1843                status,
1844                valid_slices: vec![],
1845                missing_slice_count: 0,
1846            });
1847            continue;
1848        }
1849
1850        // Check for files that should have content but are empty on disk.
1851        if actual_len == 0 && desc.length > 0 {
1852            total_missing_blocks = total_missing_blocks.saturating_add(slice_count_u32);
1853            files.push(FileVerification {
1854                file_id: *file_id,
1855                filename: desc.filename.clone(),
1856                status: FileStatus::Damaged(slice_count_u32),
1857                valid_slices: vec![false; slice_count],
1858                missing_slice_count: slice_count_u32,
1859            });
1860            continue;
1861        }
1862
1863        // If IFSC data is missing for this file, we can't do slice-level verification.
1864        // Fall back to full-file hash check only. This can happen with truncated PAR2 files.
1865        if par2.file_checksums(file_id).is_none() {
1866            let full_ok = verify_full_hash(par2, file_id, access).unwrap_or(false);
1867            if full_ok {
1868                files.push(FileVerification {
1869                    file_id: *file_id,
1870                    filename: desc.filename.clone(),
1871                    status: FileStatus::Complete,
1872                    valid_slices: vec![true; slice_count],
1873                    missing_slice_count: 0,
1874                });
1875            } else {
1876                // Can't determine which slices are bad without IFSC data.
1877                // Mark all slices as damaged — repair will treat the whole file as needing recovery.
1878                total_missing_blocks = total_missing_blocks.saturating_add(slice_count_u32);
1879                files.push(FileVerification {
1880                    file_id: *file_id,
1881                    filename: desc.filename.clone(),
1882                    status: FileStatus::Damaged(slice_count_u32),
1883                    valid_slices: vec![false; slice_count],
1884                    missing_slice_count: slice_count_u32,
1885                });
1886            }
1887            bytes_processed += desc.length;
1888            if let Some(ref progress) = options.progress {
1889                progress(ProgressUpdate {
1890                    stage: ProgressStage::Verifying,
1891                    current: file_index as u32 + 1,
1892                    total: total_files,
1893                    bytes_processed,
1894                    total_bytes: None,
1895                    phase: ProgressPhase::Whole,
1896                });
1897            }
1898            continue;
1899        }
1900
1901        if actual_len != desc.length {
1902            let valid = strict_slice_validity(par2, access, span_access, file_id, slice_count);
1903            let damaged = valid.iter().filter(|&&v| !v).count() as u32;
1904            total_missing_blocks = total_missing_blocks.saturating_add(damaged);
1905            files.push(FileVerification {
1906                file_id: *file_id,
1907                filename: desc.filename.clone(),
1908                status: FileStatus::Damaged(damaged),
1909                valid_slices: valid,
1910                missing_slice_count: damaged,
1911            });
1912            continue;
1913        }
1914
1915        // Per-slice evidence (opt-in): the caller has already proven some of
1916        // this file's slices, so read only the ones it has not. The spans that
1917        // are read take the same `check_slice_span` against the same IFSC
1918        // entries the fast and strict arms use, which is why composing this
1919        // with either is not a third verdict rule but the same one over fewer
1920        // slices. The whole-file MD5 arm cannot run once any slice is skipped —
1921        // there is no way to hash bytes that were never read — so completeness
1922        // is decided from slice proof, exactly the shape fast verify already
1923        // reports (see `verify_file_sliced` for why slice proof plus a matching
1924        // length is sound). An entry that proves nothing, or is the wrong shape
1925        // for this file's layout, never reaches here: `proven_slices_for` drops
1926        // it, and the file takes the pipeline it always took.
1927        if let Some(proven) = options.proven_slices_for(file_id, slice_count)
1928            && let Some(evidenced) =
1929                verify_file_sliced_serial_with_evidence(par2, access, file_id, Some(proven))
1930        {
1931            total_missing_blocks =
1932                total_missing_blocks.saturating_add(evidenced.missing_slice_count);
1933            files.push(evidenced);
1934            // Progress counts the file's coverage, not this pass's reads: the
1935            // evidence accounts for the bytes it did not read, and a progress
1936            // stream that stalled on a skipped range would misreport the work
1937            // remaining.
1938            bytes_processed += desc.length;
1939            if let Some(ref progress) = options.progress {
1940                progress(ProgressUpdate {
1941                    stage: ProgressStage::Verifying,
1942                    current: file_index as u32 + 1,
1943                    total: total_files,
1944                    bytes_processed,
1945                    total_bytes: None,
1946                    phase: ProgressPhase::Whole,
1947                });
1948            }
1949            continue;
1950        }
1951
1952        // Fast-verify (opt-in): the on-disk length already matches the
1953        // description here, so prove completeness from the per-slice IFSC
1954        // checksums scanned at read speed instead of the inherently serial
1955        // full-file MD5. All slices valid -> Complete; any slice invalid ->
1956        // Damaged with the same per-slice vector the strict path yields, so no
1957        // full-MD5 fallback adds information (see `verify_file_sliced` for why
1958        // slice proof + length is sound and why strict stays the default). The
1959        // 16k quick check is intentionally skipped: the slice scan already
1960        // covers the file's first bytes. Ineligible files (e.g. an IFSC count
1961        // mismatch) return `None` and fall through to the strict pipeline
1962        // below. This loop can run inside a file-parallel `par_iter`, so it
1963        // uses the span-serial driver to avoid nesting a second rayon axis.
1964        if fast_verify && let Some(fast) = verify_file_sliced_serial(par2, access, file_id) {
1965            total_missing_blocks = total_missing_blocks.saturating_add(fast.missing_slice_count);
1966            files.push(fast);
1967            bytes_processed += desc.length;
1968            if let Some(ref progress) = options.progress {
1969                progress(ProgressUpdate {
1970                    stage: ProgressStage::Verifying,
1971                    current: file_index as u32 + 1,
1972                    total: total_files,
1973                    bytes_processed,
1974                    total_bytes: None,
1975                    phase: ProgressPhase::Whole,
1976                });
1977            }
1978            continue;
1979        }
1980
1981        // One streaming pass computes the 16 KiB and whole-file MD5 chains and
1982        // CRC32s each slice out of the same buffer. Passing the IFSC entries is
1983        // gated on the count matching this file's slice count, which is the
1984        // precondition under which `verify_slices` indexes them; `actual_len ==
1985        // desc.length` is already established above.
1986        let checksums = par2
1987            .file_checksums(file_id)
1988            .filter(|checksums| checksums.len() == slice_count);
1989        let outcome = stream_strict_hashes(par2, file_id, access, checksums).unwrap_or(
1990            StrictStreamOutcome::Hashes {
1991                quick_ok: false,
1992                full_ok: false,
1993            },
1994        );
1995
1996        // Only an intact 16 KiB prefix *and* an intact whole-file MD5 mark a
1997        // file complete; every other outcome is the damaged branch, which
1998        // reports the per-slice validity vector. That collapse is why the
1999        // stream may abandon the MD5 chain once a slice CRC32 has proven the
2000        // content differs: the abandoned chain's only reachable verdict was
2001        // `full_ok == false`, which lands in this same branch. Believing
2002        // otherwise would require an MD5 second-preimage — the assumption the
2003        // `full_ok == true` arm already rests on.
2004        let complete = matches!(
2005            outcome,
2006            StrictStreamOutcome::Hashes {
2007                quick_ok: true,
2008                full_ok: true
2009            }
2010        );
2011        if complete {
2012            files.push(FileVerification {
2013                file_id: *file_id,
2014                filename: desc.filename.clone(),
2015                status: FileStatus::Complete,
2016                valid_slices: vec![true; slice_count],
2017                missing_slice_count: 0,
2018            });
2019        } else {
2020            let valid = strict_slice_validity(par2, access, span_access, file_id, slice_count);
2021            let damaged = valid.iter().filter(|&&v| !v).count() as u32;
2022            total_missing_blocks = total_missing_blocks.saturating_add(damaged);
2023            // Slice checks can identify usable blocks, but only a full
2024            // length+MD5 match is allowed to mark a file complete, so a
2025            // zero-damage slice scan still reports `Damaged(0)` here.
2026            files.push(FileVerification {
2027                file_id: *file_id,
2028                filename: desc.filename.clone(),
2029                status: FileStatus::Damaged(damaged),
2030                valid_slices: valid,
2031                missing_slice_count: damaged,
2032            });
2033        }
2034
2035        bytes_processed += desc.length;
2036        if let Some(ref progress) = options.progress {
2037            progress(ProgressUpdate {
2038                stage: ProgressStage::Verifying,
2039                current: file_index as u32 + 1,
2040                total: total_files,
2041                bytes_processed,
2042                total_bytes: None,
2043                phase: ProgressPhase::Whole,
2044            });
2045        }
2046    }
2047
2048    let recovery_blocks_available = par2.recovery_block_count();
2049    let repairable = repairability_for_result_with_resource_limit(
2050        &files,
2051        total_missing_blocks,
2052        recovery_blocks_available,
2053        resource_limit_reason,
2054    );
2055
2056    VerificationResult {
2057        files,
2058        recovery_blocks_available,
2059        total_missing_blocks,
2060        repairable,
2061    }
2062}
2063
2064#[cfg(test)]
2065mod tests {
2066    use super::*;
2067    use crate::checksum::{self, SliceChecksumState};
2068    use crate::packet::header;
2069    use crate::par2_set::Par2FileSet;
2070    use crate::types::SliceChecksum;
2071    use md5::{Digest, Md5};
2072    use std::collections::HashMap;
2073    use std::io::{self, Cursor};
2074    use std::sync::{
2075        Arc,
2076        atomic::{AtomicUsize, Ordering},
2077    };
2078
2079    /// Helper to build a complete valid packet (header + body).
2080    fn make_full_packet(packet_type: &[u8; 16], body: &[u8], recovery_set_id: [u8; 16]) -> Vec<u8> {
2081        let length = (header::HEADER_SIZE + body.len()) as u64;
2082        let mut hash_input = Vec::new();
2083        hash_input.extend_from_slice(&recovery_set_id);
2084        hash_input.extend_from_slice(packet_type);
2085        hash_input.extend_from_slice(body);
2086        let packet_hash: [u8; 16] = Md5::digest(&hash_input).into();
2087
2088        let mut data = Vec::new();
2089        data.extend_from_slice(header::MAGIC);
2090        data.extend_from_slice(&length.to_le_bytes());
2091        data.extend_from_slice(&packet_hash);
2092        data.extend_from_slice(&recovery_set_id);
2093        data.extend_from_slice(packet_type);
2094        data.extend_from_slice(body);
2095        data
2096    }
2097
2098    /// Build a Par2FileSet and MemoryFileAccess for testing.
2099    /// Creates a single file with known content and matching checksums.
2100    fn setup_test_set(
2101        file_data: &[u8],
2102        slice_size: u64,
2103    ) -> (Par2FileSet, MemoryFileAccess, FileId) {
2104        setup_test_set_with_full_hash(file_data, slice_size, None)
2105    }
2106
2107    /// [`setup_test_set`], optionally describing a different whole-file MD5
2108    /// than the data actually has. That combination cannot arise from real
2109    /// damage (it would need an MD5 collision), but it is the only way to
2110    /// exercise the strict pipeline's "whole-file hash disagrees while every
2111    /// slice checks out" verdict.
2112    fn setup_test_set_with_full_hash(
2113        file_data: &[u8],
2114        slice_size: u64,
2115        full_hash_override: Option<[u8; 16]>,
2116    ) -> (Par2FileSet, MemoryFileAccess, FileId) {
2117        let file_length = file_data.len() as u64;
2118        let hash_full = full_hash_override.unwrap_or_else(|| checksum::md5(file_data));
2119        let hash_16k_data = &file_data[..file_data.len().min(16384)];
2120        let hash_16k = checksum::md5(hash_16k_data);
2121
2122        // Compute file_id: MD5(hash_16k || length || filename)
2123        let filename = b"testfile.dat";
2124        let mut id_input = Vec::new();
2125        id_input.extend_from_slice(&hash_16k);
2126        id_input.extend_from_slice(&file_length.to_le_bytes());
2127        id_input.extend_from_slice(filename);
2128        let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
2129        let file_id = FileId::from_bytes(file_id_bytes);
2130
2131        // Build slice checksums
2132        let num_slices = if file_length == 0 {
2133            0
2134        } else {
2135            file_length.div_ceil(slice_size) as usize
2136        };
2137
2138        let mut checksums = Vec::new();
2139        for i in 0..num_slices {
2140            let offset = i as u64 * slice_size;
2141            let end = ((offset + slice_size) as usize).min(file_data.len());
2142            let slice_data = &file_data[offset as usize..end];
2143
2144            let mut state = SliceChecksumState::new();
2145            state.update(slice_data);
2146            let pad_to = if (slice_data.len() as u64) < slice_size {
2147                Some(slice_size)
2148            } else {
2149                None
2150            };
2151            let (crc, md5) = state.finalize(pad_to);
2152            checksums.push(SliceChecksum { crc32: crc, md5 });
2153        }
2154
2155        // Build main body and RSID
2156        let mut main_body = Vec::new();
2157        main_body.extend_from_slice(&slice_size.to_le_bytes());
2158        main_body.extend_from_slice(&1u32.to_le_bytes());
2159        main_body.extend_from_slice(&file_id_bytes);
2160        let rsid: [u8; 16] = Md5::digest(&main_body).into();
2161
2162        // File description body
2163        let mut fd_body = Vec::new();
2164        fd_body.extend_from_slice(&file_id_bytes);
2165        fd_body.extend_from_slice(&hash_full);
2166        fd_body.extend_from_slice(&hash_16k);
2167        fd_body.extend_from_slice(&file_length.to_le_bytes());
2168        fd_body.extend_from_slice(filename);
2169        // Pad to multiple of 4
2170        while fd_body.len() % 4 != 0 {
2171            fd_body.push(0);
2172        }
2173
2174        // IFSC body
2175        let mut ifsc_body = Vec::new();
2176        ifsc_body.extend_from_slice(&file_id_bytes);
2177        for cs in &checksums {
2178            ifsc_body.extend_from_slice(&cs.md5);
2179            ifsc_body.extend_from_slice(&cs.crc32.to_le_bytes());
2180        }
2181
2182        // Build stream
2183        let mut stream = Vec::new();
2184        stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
2185        stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
2186        stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, &ifsc_body, rsid));
2187
2188        let set = Par2FileSet::from_files(&[&stream]).unwrap();
2189
2190        let mut access = MemoryFileAccess::new();
2191        access.add_file(file_id, file_data.to_vec());
2192
2193        (set, access, file_id)
2194    }
2195
2196    fn setup_test_set_multi(
2197        files: &[(&[u8], &str)],
2198        slice_size: u64,
2199    ) -> (Par2FileSet, MemoryFileAccess, Vec<FileId>) {
2200        let mut file_ids = Vec::new();
2201        let mut fd_bodies = Vec::new();
2202        let mut ifsc_bodies = Vec::new();
2203        let mut access = MemoryFileAccess::new();
2204
2205        for &(file_data, filename) in files {
2206            let file_length = file_data.len() as u64;
2207            let hash_full = checksum::md5(file_data);
2208            let hash_16k = checksum::md5(&file_data[..file_data.len().min(16384)]);
2209
2210            let mut id_input = Vec::new();
2211            id_input.extend_from_slice(&hash_16k);
2212            id_input.extend_from_slice(&file_length.to_le_bytes());
2213            id_input.extend_from_slice(filename.as_bytes());
2214            let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
2215            let file_id = FileId::from_bytes(file_id_bytes);
2216            file_ids.push(file_id);
2217            access.add_file(file_id, file_data.to_vec());
2218
2219            let num_slices = if file_length == 0 {
2220                0
2221            } else {
2222                file_length.div_ceil(slice_size) as usize
2223            };
2224
2225            let mut checksums = Vec::new();
2226            for i in 0..num_slices {
2227                let offset = i as u64 * slice_size;
2228                let end = ((offset + slice_size) as usize).min(file_data.len());
2229                let slice_data = &file_data[offset as usize..end];
2230
2231                let mut state = SliceChecksumState::new();
2232                state.update(slice_data);
2233                let pad_to = if (slice_data.len() as u64) < slice_size {
2234                    Some(slice_size)
2235                } else {
2236                    None
2237                };
2238                let (crc, md5) = state.finalize(pad_to);
2239                checksums.push(SliceChecksum { crc32: crc, md5 });
2240            }
2241
2242            let mut fd_body = Vec::new();
2243            fd_body.extend_from_slice(&file_id_bytes);
2244            fd_body.extend_from_slice(&hash_full);
2245            fd_body.extend_from_slice(&hash_16k);
2246            fd_body.extend_from_slice(&file_length.to_le_bytes());
2247            fd_body.extend_from_slice(filename.as_bytes());
2248            while fd_body.len() % 4 != 0 {
2249                fd_body.push(0);
2250            }
2251            fd_bodies.push(fd_body);
2252
2253            let mut ifsc_body = Vec::new();
2254            ifsc_body.extend_from_slice(&file_id_bytes);
2255            for cs in &checksums {
2256                ifsc_body.extend_from_slice(&cs.md5);
2257                ifsc_body.extend_from_slice(&cs.crc32.to_le_bytes());
2258            }
2259            ifsc_bodies.push(ifsc_body);
2260        }
2261
2262        let mut main_body = Vec::new();
2263        main_body.extend_from_slice(&slice_size.to_le_bytes());
2264        main_body.extend_from_slice(&(file_ids.len() as u32).to_le_bytes());
2265        for file_id in &file_ids {
2266            main_body.extend_from_slice(file_id.as_bytes());
2267        }
2268        let rsid: [u8; 16] = Md5::digest(&main_body).into();
2269
2270        let mut stream = Vec::new();
2271        stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
2272        for fd_body in &fd_bodies {
2273            stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, fd_body, rsid));
2274        }
2275        for ifsc_body in &ifsc_bodies {
2276            stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, ifsc_body, rsid));
2277        }
2278
2279        let set = Par2FileSet::from_files(&[&stream]).unwrap();
2280        (set, access, file_ids)
2281    }
2282
2283    fn setup_oversized_file_set() -> Par2FileSet {
2284        let slice_size = 4u64;
2285        let file_length = (MAX_SLICES_PER_FILE as u64 + 1) * slice_size;
2286        let filename = b"huge.dat";
2287        let hash_full = [0u8; 16];
2288        let hash_16k = [0u8; 16];
2289
2290        let mut id_input = Vec::new();
2291        id_input.extend_from_slice(&hash_16k);
2292        id_input.extend_from_slice(&file_length.to_le_bytes());
2293        id_input.extend_from_slice(filename);
2294        let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
2295
2296        let mut main_body = Vec::new();
2297        main_body.extend_from_slice(&slice_size.to_le_bytes());
2298        main_body.extend_from_slice(&1u32.to_le_bytes());
2299        main_body.extend_from_slice(&file_id_bytes);
2300        let rsid: [u8; 16] = Md5::digest(&main_body).into();
2301
2302        let mut fd_body = Vec::new();
2303        fd_body.extend_from_slice(&file_id_bytes);
2304        fd_body.extend_from_slice(&hash_full);
2305        fd_body.extend_from_slice(&hash_16k);
2306        fd_body.extend_from_slice(&file_length.to_le_bytes());
2307        fd_body.extend_from_slice(filename);
2308        while fd_body.len() % 4 != 0 {
2309            fd_body.push(0);
2310        }
2311
2312        let mut stream = Vec::new();
2313        stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
2314        stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
2315
2316        Par2FileSet::from_files(&[&stream]).unwrap()
2317    }
2318
2319    struct ReadIntoOnlyAccess {
2320        files: HashMap<FileId, Vec<u8>>,
2321    }
2322
2323    impl FileAccess for ReadIntoOnlyAccess {
2324        fn read_file_range(
2325            &self,
2326            _file_id: &FileId,
2327            _offset: u64,
2328            _len: u64,
2329        ) -> io::Result<Vec<u8>> {
2330            panic!("read_file_range should not be used by quick_check_16k")
2331        }
2332
2333        fn read_file_range_into(
2334            &self,
2335            file_id: &FileId,
2336            offset: u64,
2337            dst: &mut [u8],
2338        ) -> io::Result<usize> {
2339            let data = self
2340                .files
2341                .get(file_id)
2342                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))?;
2343            let offset = offset as usize;
2344            if offset >= data.len() {
2345                return Ok(0);
2346            }
2347            let end = (offset + dst.len()).min(data.len());
2348            let read_len = end - offset;
2349            dst[..read_len].copy_from_slice(&data[offset..end]);
2350            Ok(read_len)
2351        }
2352
2353        fn file_exists(&self, file_id: &FileId) -> bool {
2354            self.files.contains_key(file_id)
2355        }
2356
2357        fn file_length(&self, file_id: &FileId) -> Option<u64> {
2358            self.files.get(file_id).map(|data| data.len() as u64)
2359        }
2360
2361        fn read_file(&self, file_id: &FileId) -> io::Result<Vec<u8>> {
2362            self.files
2363                .get(file_id)
2364                .cloned()
2365                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))
2366        }
2367
2368        fn write_file_range(
2369            &mut self,
2370            _file_id: &FileId,
2371            _offset: u64,
2372            _data: &[u8],
2373        ) -> io::Result<()> {
2374            Err(io::Error::new(
2375                io::ErrorKind::Unsupported,
2376                "test access is read-only",
2377            ))
2378        }
2379    }
2380
2381    struct SequentialOnlyAccess {
2382        files: HashMap<FileId, Vec<u8>>,
2383    }
2384
2385    impl FileAccess for SequentialOnlyAccess {
2386        fn read_file_range(
2387            &self,
2388            _file_id: &FileId,
2389            _offset: u64,
2390            _len: u64,
2391        ) -> io::Result<Vec<u8>> {
2392            panic!("read_file_range should not be used when a sequential reader is available")
2393        }
2394
2395        fn read_file_range_into(
2396            &self,
2397            _file_id: &FileId,
2398            _offset: u64,
2399            _dst: &mut [u8],
2400        ) -> io::Result<usize> {
2401            panic!("read_file_range_into should not be used when a sequential reader is available")
2402        }
2403
2404        fn open_sequential_reader(&self, file_id: &FileId) -> io::Result<Option<Box<dyn Read>>> {
2405            let data = self
2406                .files
2407                .get(file_id)
2408                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))?;
2409            Ok(Some(Box::new(Cursor::new(data.clone()))))
2410        }
2411
2412        fn file_exists(&self, file_id: &FileId) -> bool {
2413            self.files.contains_key(file_id)
2414        }
2415
2416        fn file_length(&self, file_id: &FileId) -> Option<u64> {
2417            self.files.get(file_id).map(|data| data.len() as u64)
2418        }
2419
2420        fn read_file(&self, file_id: &FileId) -> io::Result<Vec<u8>> {
2421            self.files
2422                .get(file_id)
2423                .cloned()
2424                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))
2425        }
2426
2427        fn write_file_range(
2428            &mut self,
2429            _file_id: &FileId,
2430            _offset: u64,
2431            _data: &[u8],
2432        ) -> io::Result<()> {
2433            Err(io::Error::new(
2434                io::ErrorKind::Unsupported,
2435                "test access is read-only",
2436            ))
2437        }
2438    }
2439
2440    struct CountingSequentialAccess {
2441        files: HashMap<FileId, Vec<u8>>,
2442        open_calls: Arc<AtomicUsize>,
2443    }
2444
2445    impl FileAccess for CountingSequentialAccess {
2446        fn read_file_range(
2447            &self,
2448            _file_id: &FileId,
2449            _offset: u64,
2450            _len: u64,
2451        ) -> io::Result<Vec<u8>> {
2452            panic!("read_file_range should not be used when a sequential reader is available")
2453        }
2454
2455        fn read_file_range_into(
2456            &self,
2457            _file_id: &FileId,
2458            _offset: u64,
2459            _dst: &mut [u8],
2460        ) -> io::Result<usize> {
2461            panic!("read_file_range_into should not be used when a sequential reader is available")
2462        }
2463
2464        fn open_sequential_reader(&self, file_id: &FileId) -> io::Result<Option<Box<dyn Read>>> {
2465            self.open_calls.fetch_add(1, Ordering::Relaxed);
2466            let data = self
2467                .files
2468                .get(file_id)
2469                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))?;
2470            Ok(Some(Box::new(Cursor::new(data.clone()))))
2471        }
2472
2473        fn file_exists(&self, file_id: &FileId) -> bool {
2474            self.files.contains_key(file_id)
2475        }
2476
2477        fn file_length(&self, file_id: &FileId) -> Option<u64> {
2478            self.files.get(file_id).map(|data| data.len() as u64)
2479        }
2480
2481        fn read_file(&self, _file_id: &FileId) -> io::Result<Vec<u8>> {
2482            Err(io::Error::new(
2483                io::ErrorKind::Unsupported,
2484                "force streaming path in this test",
2485            ))
2486        }
2487
2488        fn write_file_range(
2489            &mut self,
2490            _file_id: &FileId,
2491            _offset: u64,
2492            _data: &[u8],
2493        ) -> io::Result<()> {
2494            Err(io::Error::new(
2495                io::ErrorKind::Unsupported,
2496                "test access is read-only",
2497            ))
2498        }
2499    }
2500
2501    #[test]
2502    fn verify_slices_from_crcs_intact() {
2503        let file_data = vec![0xABu8; 2048];
2504        let (set, _access, file_id) = setup_test_set(&file_data, 1024);
2505
2506        // Compute per-slice CRC32s manually.
2507        let crc0 = checksum::crc32(&file_data[0..1024]);
2508        let crc1 = checksum::crc32(&file_data[1024..2048]);
2509
2510        let result = verify_slices_from_crcs(&set, &file_id, &[crc0, crc1]).unwrap();
2511        assert_eq!(result, vec![true, true]);
2512    }
2513
2514    #[test]
2515    fn verify_slices_from_crcs_damaged() {
2516        let file_data = vec![0xABu8; 2048];
2517        let (set, _access, file_id) = setup_test_set(&file_data, 1024);
2518
2519        let crc0 = checksum::crc32(&file_data[0..1024]);
2520        let wrong_crc = 0xDEADBEEF;
2521
2522        let result = verify_slices_from_crcs(&set, &file_id, &[crc0, wrong_crc]).unwrap();
2523        assert_eq!(result, vec![true, false]);
2524    }
2525
2526    #[test]
2527    fn verify_slices_from_crcs_with_padding() {
2528        // 1500 bytes, 1024 slice size -> last slice is 476 bytes, zero-padded to 1024
2529        let file_data = vec![0xCDu8; 1500];
2530        let (set, _access, file_id) = setup_test_set(&file_data, 1024);
2531
2532        let crc0 = checksum::crc32(&file_data[0..1024]);
2533        // PAR2 checksums include zero-padding on the last slice
2534        let mut padded_last = file_data[1024..1500].to_vec();
2535        padded_last.resize(1024, 0);
2536        let crc1 = checksum::crc32(&padded_last);
2537
2538        let result = verify_slices_from_crcs(&set, &file_id, &[crc0, crc1]).unwrap();
2539        assert_eq!(result, vec![true, true]);
2540    }
2541
2542    #[test]
2543    fn verify_intact_file() {
2544        let file_data = vec![0xABu8; 2048];
2545        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2546
2547        // Quick check
2548        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(true));
2549
2550        // Full hash
2551        assert_eq!(verify_full_hash(&set, &file_id, &access), Some(true));
2552
2553        // Slice verification
2554        let slices = verify_slices(&set, &file_id, &access).unwrap();
2555        assert_eq!(slices.len(), 2);
2556        assert!(slices.iter().all(|&v| v));
2557
2558        // Full verification
2559        let result = verify_all(&set, &access);
2560        assert_eq!(result.files.len(), 1);
2561        assert!(matches!(result.files[0].status, FileStatus::Complete));
2562        assert_eq!(result.total_missing_blocks, 0);
2563        assert!(matches!(result.repairable, Repairability::NotNeeded));
2564    }
2565
2566    #[test]
2567    fn verify_damaged_file() {
2568        let mut file_data = vec![0xABu8; 2048];
2569        let (set, mut access, file_id) = setup_test_set(&file_data, 1024);
2570
2571        // Corrupt the second slice
2572        file_data[1024] ^= 0xFF;
2573        file_data[1025] ^= 0xFF;
2574        access.add_file(file_id, file_data);
2575
2576        // Slice verification should show second slice damaged
2577        let slices = verify_slices(&set, &file_id, &access).unwrap();
2578        assert!(slices[0]); // first slice intact
2579        assert!(!slices[1]); // second slice damaged
2580
2581        // Full verification
2582        let result = verify_all(&set, &access);
2583        assert_eq!(result.files.len(), 1);
2584        assert!(matches!(result.files[0].status, FileStatus::Damaged(1)));
2585        assert_eq!(result.total_missing_blocks, 1);
2586    }
2587
2588    #[test]
2589    fn verify_missing_file() {
2590        let file_data = vec![0xABu8; 2048];
2591        let (set, _, _file_id) = setup_test_set(&file_data, 1024);
2592
2593        // Don't add file to access
2594        let access = MemoryFileAccess::new();
2595
2596        let result = verify_all(&set, &access);
2597        assert_eq!(result.files.len(), 1);
2598        assert!(matches!(result.files[0].status, FileStatus::Missing));
2599        assert_eq!(result.total_missing_blocks, 2);
2600    }
2601
2602    #[test]
2603    fn verify_file_with_partial_last_slice() {
2604        // File is 1500 bytes with 1024-byte slices -> 2 slices, last is 476 bytes
2605        let file_data = vec![0xCDu8; 1500];
2606        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2607
2608        let slices = verify_slices(&set, &file_id, &access).unwrap();
2609        assert_eq!(slices.len(), 2);
2610        assert!(slices[0]);
2611        assert!(slices[1]); // Last partial slice should still verify with padding
2612    }
2613
2614    #[test]
2615    fn quick_check_missing_file() {
2616        let file_data = vec![0xABu8; 100];
2617        let (set, _, file_id) = setup_test_set(&file_data, 1024);
2618        let access = MemoryFileAccess::new();
2619
2620        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(false));
2621    }
2622
2623    #[test]
2624    fn quick_check_short_file_matches_and_detects_corruption() {
2625        let file_data = b"short-par2-file".to_vec();
2626        let (set, mut access, file_id) = setup_test_set(&file_data, 1024);
2627
2628        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(true));
2629
2630        let mut corrupted = file_data.clone();
2631        corrupted[3] ^= 0xFF;
2632        access.add_file(file_id, corrupted);
2633
2634        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(false));
2635    }
2636
2637    #[test]
2638    fn quick_check_exact_16k_boundary_matches_and_detects_corruption() {
2639        let file_data = (0..QUICK_CHECK_16K_BYTES)
2640            .map(|i| (i % 251) as u8)
2641            .collect::<Vec<_>>();
2642        let (set, mut access, file_id) = setup_test_set(&file_data, 4096);
2643
2644        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(true));
2645
2646        let mut corrupted = file_data.clone();
2647        corrupted[QUICK_CHECK_16K_BYTES - 1] ^= 0x55;
2648        access.add_file(file_id, corrupted);
2649
2650        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(false));
2651    }
2652
2653    #[test]
2654    fn quick_check_uses_read_into_path() {
2655        let file_data = vec![0x5Au8; 4096];
2656        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2657        let access = ReadIntoOnlyAccess {
2658            files: access.files,
2659        };
2660
2661        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(true));
2662    }
2663
2664    #[test]
2665    fn quick_check_uses_sequential_reader_when_available() {
2666        let file_data = vec![0x6Bu8; 4096];
2667        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2668        let access = SequentialOnlyAccess {
2669            files: access.files,
2670        };
2671
2672        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(true));
2673    }
2674
2675    #[test]
2676    fn verify_full_hash_uses_sequential_reader_when_available() {
2677        let file_data = (0..(QUICK_CHECK_16K_BYTES + 4096))
2678            .map(|i| (i % 251) as u8)
2679            .collect::<Vec<_>>();
2680        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2681        let access = SequentialOnlyAccess {
2682            files: access.files,
2683        };
2684
2685        assert_eq!(verify_full_hash(&set, &file_id, &access), Some(true));
2686    }
2687
2688    #[test]
2689    fn verify_selected_file_ids_uses_single_sequential_pass_for_healthy_file() {
2690        let file_data = (0..(QUICK_CHECK_16K_BYTES + 4096))
2691            .map(|i| (i % 241) as u8)
2692            .collect::<Vec<_>>();
2693        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2694        let open_calls = Arc::new(AtomicUsize::new(0));
2695        let access = CountingSequentialAccess {
2696            files: access.files,
2697            open_calls: open_calls.clone(),
2698        };
2699
2700        // Pinned strict: the single-sequential-pass discipline is a property
2701        // of the strict pipeline, and must not depend on an ambient
2702        // WEAVER_PAR2_FAST_VERIFY in the test environment (fast verify reads
2703        // spans via `read_file_range_into`, which this mock forbids).
2704        let verification = verify_selected_file_ids_resolved(
2705            &set,
2706            &access,
2707            None,
2708            &[file_id],
2709            &VerifyOptions::default(),
2710            false,
2711        );
2712
2713        assert_eq!(verification.total_missing_blocks, 0);
2714        assert!(matches!(verification.files[0].status, FileStatus::Complete));
2715        assert_eq!(open_calls.load(Ordering::Relaxed), 1);
2716    }
2717
2718    #[test]
2719    fn verify_slices_uses_batched_sequential_reader_when_available() {
2720        let file_data = (0..8192)
2721            .map(|i| (i as u8).wrapping_mul(13).wrapping_add(7))
2722            .collect::<Vec<_>>();
2723        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2724        let access = SequentialOnlyAccess {
2725            files: access.files,
2726        };
2727
2728        let slices = verify_slices(&set, &file_id, &access).unwrap();
2729        assert_eq!(slices, vec![true; 8]);
2730    }
2731
2732    #[test]
2733    fn verifier_falls_back_to_read_into_when_no_sequential_reader_exists() {
2734        let file_data = (0..8192)
2735            .map(|i| (i as u8).wrapping_mul(17).wrapping_add(11))
2736            .collect::<Vec<_>>();
2737        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2738        let access = ReadIntoOnlyAccess {
2739            files: access.files,
2740        };
2741
2742        assert_eq!(verify_full_hash(&set, &file_id, &access), Some(true));
2743        assert_eq!(verify_slices(&set, &file_id, &access), Some(vec![true; 8]));
2744    }
2745
2746    #[test]
2747    fn verify_selected_file_ids_reuses_quick_check_scratch_without_allocating_reads() {
2748        let file_a = vec![0x11u8; 2048];
2749        let file_b = (0..(QUICK_CHECK_16K_BYTES + 257))
2750            .map(|i| (i % 239) as u8)
2751            .collect::<Vec<_>>();
2752        let files = [
2753            (file_a.as_slice(), "alpha.bin"),
2754            (file_b.as_slice(), "beta.bin"),
2755        ];
2756        let (set, access, file_ids) = setup_test_set_multi(&files, 1024);
2757        let access = ReadIntoOnlyAccess {
2758            files: access.files,
2759        };
2760
2761        // Pinned strict: this asserts the strict pipeline's quick-check
2762        // scratch reuse (see the sequential-pass test above for why pinning
2763        // beats inheriting ambient WEAVER_PAR2_FAST_VERIFY).
2764        let verification = verify_selected_file_ids_resolved(
2765            &set,
2766            &access,
2767            None,
2768            &file_ids,
2769            &VerifyOptions::default(),
2770            false,
2771        );
2772
2773        assert_eq!(verification.files.len(), 2);
2774        assert!(
2775            verification
2776                .files
2777                .iter()
2778                .all(|file| matches!(file.status, FileStatus::Complete))
2779        );
2780        assert_eq!(verification.total_missing_blocks, 0);
2781    }
2782
2783    #[test]
2784    fn verify_repairable_assessment() {
2785        let file_data = vec![0xABu8; 4096];
2786        let (mut set, mut access, file_id) = setup_test_set(&file_data, 1024);
2787
2788        // Corrupt data
2789        let mut corrupted = file_data.clone();
2790        corrupted[0] ^= 0xFF;
2791        access.add_file(file_id, corrupted);
2792
2793        // Add some recovery blocks
2794        use crate::par2_set::RecoverySlice;
2795        use bytes::Bytes;
2796        set.recovery_slices.insert(
2797            0,
2798            RecoverySlice {
2799                exponent: 0,
2800                data: Bytes::from(vec![0u8; 1024]).into(),
2801            },
2802        );
2803        set.recovery_slices.insert(
2804            1,
2805            RecoverySlice {
2806                exponent: 1,
2807                data: Bytes::from(vec![0u8; 1024]).into(),
2808            },
2809        );
2810
2811        let result = verify_all(&set, &access);
2812        // 1 damaged slice, 2 recovery blocks available
2813        assert!(matches!(
2814            result.repairable,
2815            Repairability::Repairable {
2816                blocks_needed: 1,
2817                blocks_available: 2
2818            }
2819        ));
2820    }
2821
2822    #[test]
2823    fn verify_insufficient_recovery() {
2824        let file_data = vec![0xABu8; 4096];
2825        let (set, _, _file_id) = setup_test_set(&file_data, 1024);
2826        let access = MemoryFileAccess::new(); // file missing = 4 slices needed, 0 available
2827
2828        let result = verify_all(&set, &access);
2829        assert!(matches!(
2830            result.repairable,
2831            Repairability::Insufficient { .. }
2832        ));
2833    }
2834
2835    #[test]
2836    fn verify_resource_limited_file_does_not_inflate_missing_blocks() {
2837        let set = setup_oversized_file_set();
2838        let access = MemoryFileAccess::new();
2839
2840        let result = verify_all(&set, &access);
2841
2842        assert_eq!(result.total_missing_blocks, 0);
2843        assert_eq!(result.files.len(), 1);
2844        assert_eq!(result.files[0].missing_slice_count, 0);
2845        assert!(result.files[0].valid_slices.is_empty());
2846        assert!(matches!(result.files[0].status, FileStatus::Damaged(0)));
2847        match result.repairable {
2848            Repairability::ResourceLimited { reason } => {
2849                assert!(reason.contains("huge.dat"));
2850            }
2851            other => panic!("expected resource-limited repairability, got {other:?}"),
2852        }
2853    }
2854
2855    #[test]
2856    fn verify_large_file_multiple_slices() {
2857        // 10 slices worth of data
2858        let file_data: Vec<u8> = (0..10240u32).map(|i| (i % 256) as u8).collect();
2859        let (set, access, file_id) = setup_test_set(&file_data, 1024);
2860
2861        let slices = verify_slices(&set, &file_id, &access).unwrap();
2862        assert_eq!(slices.len(), 10);
2863        assert!(slices.iter().all(|&v| v));
2864    }
2865
2866    #[test]
2867    fn verify_corrupted_first_slice_16k_check_fails() {
2868        let file_data = vec![0xABu8; 4096];
2869        let (set, mut access, file_id) = setup_test_set(&file_data, 1024);
2870
2871        // Corrupt the first byte
2872        let mut corrupted = file_data.clone();
2873        corrupted[0] ^= 0xFF;
2874        access.add_file(file_id, corrupted);
2875
2876        assert_eq!(quick_check_16k(&set, &file_id, &access), Some(false));
2877    }
2878
2879    #[test]
2880    fn verify_selected_file_ids_only_counts_requested_files() {
2881        let good = vec![0x11u8; 2048];
2882        let damaged = vec![0x22u8; 2048];
2883        let (set, mut access, file_ids) =
2884            setup_test_set_multi(&[(&good, "good.rar"), (&damaged, "damaged.rar")], 1024);
2885
2886        let mut corrupted = damaged.clone();
2887        corrupted[0] ^= 0xFF;
2888        access.add_file(file_ids[1], corrupted);
2889
2890        let selected = verify_selected_file_ids(&set, &access, &[file_ids[0]]);
2891        assert_eq!(selected.files.len(), 1);
2892        assert_eq!(selected.total_missing_blocks, 0);
2893        assert!(matches!(selected.repairable, Repairability::NotNeeded));
2894
2895        let damaged_only = verify_selected_file_ids(&set, &access, &[file_ids[1]]);
2896        assert_eq!(damaged_only.files.len(), 1);
2897        assert_eq!(damaged_only.total_missing_blocks, 1);
2898        assert!(matches!(
2899            damaged_only.files[0].status,
2900            FileStatus::Damaged(1)
2901        ));
2902    }
2903
2904    #[test]
2905    fn parallel_selected_verify_matches_serial() {
2906        let intact = vec![0x11u8; 4096];
2907        let damaged = vec![0x22u8; 4096];
2908        let truncated = vec![0x33u8; 4096];
2909        let (set, mut access, file_ids) = setup_test_set_multi(
2910            &[
2911                (&intact, "intact.rar"),
2912                (&damaged, "damaged.rar"),
2913                (&truncated, "truncated.rar"),
2914            ],
2915            1024,
2916        );
2917
2918        let mut corrupted = damaged.clone();
2919        corrupted[1500] ^= 0xFF;
2920        corrupted[3000] ^= 0xFF;
2921        access.add_file(file_ids[1], corrupted);
2922        access.add_file(file_ids[2], truncated[..2500].to_vec());
2923
2924        let serial = verify_selected_file_ids(&set, &access, &file_ids);
2925        let parallel = verify_selected_file_ids_parallel(&set, &access, &file_ids);
2926        assert!(serial.total_missing_blocks > 0, "fixture must have damage");
2927        assert_eq!(
2928            format!("{serial:?}"),
2929            format!("{parallel:?}"),
2930            "parallel selected verify must match the serial pipeline exactly"
2931        );
2932
2933        // Merging the post-repair way: overlaying a subset onto a base
2934        // keeps untouched entries and recomputes the totals.
2935        let base = verify_selected_file_ids(&set, &access, &file_ids);
2936        let fixed_subset = {
2937            let mut fixed = MemoryFileAccess::new();
2938            fixed.add_file(file_ids[0], intact.clone());
2939            fixed.add_file(file_ids[1], damaged.clone());
2940            fixed.add_file(file_ids[2], truncated.clone());
2941            verify_selected_file_ids_parallel(&set, &fixed, &file_ids[1..])
2942        };
2943        let merged = merge_verification_results(&set, &base, fixed_subset);
2944        assert_eq!(merged.files.len(), file_ids.len());
2945        assert_eq!(merged.total_missing_blocks, 0);
2946        assert!(
2947            merged
2948                .files
2949                .iter()
2950                .all(|file| matches!(file.status, FileStatus::Complete)),
2951            "merged result must show all files complete: {merged:?}"
2952        );
2953        assert!(matches!(merged.repairable, Repairability::NotNeeded));
2954    }
2955
2956    /// Like [`setup_test_set`] but omits the IFSC packet, so the resulting set
2957    /// has no per-slice checksums (`file_checksums` returns `None`). Used to
2958    /// prove fast verify falls back to the serial full-hash pipeline.
2959    fn setup_test_set_no_ifsc(
2960        file_data: &[u8],
2961        slice_size: u64,
2962    ) -> (Par2FileSet, MemoryFileAccess, FileId) {
2963        let file_length = file_data.len() as u64;
2964        let hash_full = checksum::md5(file_data);
2965        let hash_16k = checksum::md5(&file_data[..file_data.len().min(16384)]);
2966
2967        let filename = b"testfile.dat";
2968        let mut id_input = Vec::new();
2969        id_input.extend_from_slice(&hash_16k);
2970        id_input.extend_from_slice(&file_length.to_le_bytes());
2971        id_input.extend_from_slice(filename);
2972        let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
2973        let file_id = FileId::from_bytes(file_id_bytes);
2974
2975        let mut main_body = Vec::new();
2976        main_body.extend_from_slice(&slice_size.to_le_bytes());
2977        main_body.extend_from_slice(&1u32.to_le_bytes());
2978        main_body.extend_from_slice(&file_id_bytes);
2979        let rsid: [u8; 16] = Md5::digest(&main_body).into();
2980
2981        let mut fd_body = Vec::new();
2982        fd_body.extend_from_slice(&file_id_bytes);
2983        fd_body.extend_from_slice(&hash_full);
2984        fd_body.extend_from_slice(&hash_16k);
2985        fd_body.extend_from_slice(&file_length.to_le_bytes());
2986        fd_body.extend_from_slice(filename);
2987        while fd_body.len() % 4 != 0 {
2988            fd_body.push(0);
2989        }
2990
2991        let mut stream = Vec::new();
2992        stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
2993        stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
2994
2995        let set = Par2FileSet::from_files(&[&stream]).unwrap();
2996        let mut access = MemoryFileAccess::new();
2997        access.add_file(file_id, file_data.to_vec());
2998        (set, access, file_id)
2999    }
3000
3001    fn strict_opts() -> VerifyOptions {
3002        VerifyOptions::default()
3003    }
3004
3005    fn fast_opts() -> VerifyOptions {
3006        VerifyOptions {
3007            fast_verify: true,
3008            ..Default::default()
3009        }
3010    }
3011
3012    #[test]
3013    fn resolve_fast_verify_precedence() {
3014        // Env override wins over the flag: "1" forces on, "0" forces off.
3015        assert!(resolve_fast_verify(Some("1"), false));
3016        assert!(!resolve_fast_verify(Some("0"), true));
3017        // Absent or unrecognized env falls back to the flag.
3018        assert!(resolve_fast_verify(None, true));
3019        assert!(!resolve_fast_verify(None, false));
3020        assert!(resolve_fast_verify(Some("yes"), true));
3021        assert!(!resolve_fast_verify(Some(""), false));
3022    }
3023
3024    #[test]
3025    fn fast_verify_intact_matches_strict() {
3026        // Partial last slice (1500 % 1024) exercises tail zero-padding.
3027        let file_data = vec![0xA7u8; 1500];
3028        let (set, access, file_id) = setup_test_set(&file_data, 1024);
3029
3030        let strict =
3031            verify_selected_file_ids_with_options(&set, &access, &[file_id], &strict_opts());
3032        let fast = verify_selected_file_ids_with_options(&set, &access, &[file_id], &fast_opts());
3033
3034        assert!(matches!(fast.files[0].status, FileStatus::Complete));
3035        assert_eq!(fast.files[0].valid_slices, vec![true; 2]);
3036        assert_eq!(fast.total_missing_blocks, 0);
3037        assert!(matches!(fast.repairable, Repairability::NotNeeded));
3038        assert_eq!(
3039            format!("{strict:?}"),
3040            format!("{fast:?}"),
3041            "fast intact result must match strict byte for byte"
3042        );
3043    }
3044
3045    #[test]
3046    fn fast_verify_uses_only_non_allocating_reads() {
3047        // The span reader must go through `read_file_slice_into` (looping
3048        // `read_file_range_into` on a reused scratch buffer), never the
3049        // allocating `read_file_range` this mock forbids. Pinned fast so an
3050        // ambient WEAVER_PAR2_FAST_VERIFY=0 cannot skip the path under test.
3051        // 17 full slices + a 460-byte tail exercises several SIMD MD5 lane
3052        // batches within one span plus the padded-tail lane.
3053        let file_data = (0..(QUICK_CHECK_16K_BYTES + 1500))
3054            .map(|i| (i % 251) as u8)
3055            .collect::<Vec<_>>();
3056        let (set, access, file_id) = setup_test_set(&file_data, 1024);
3057        let access = ReadIntoOnlyAccess {
3058            files: access.files,
3059        };
3060
3061        let result = verify_selected_file_ids_resolved(
3062            &set,
3063            &access,
3064            None,
3065            &[file_id],
3066            &VerifyOptions::default(),
3067            true,
3068        );
3069
3070        assert!(matches!(result.files[0].status, FileStatus::Complete));
3071        assert_eq!(result.total_missing_blocks, 0);
3072        assert_eq!(result.files[0].valid_slices, vec![true; 18]);
3073    }
3074
3075    #[test]
3076    fn fast_verify_damaged_matches_strict() {
3077        // 20 full 1024-byte slices. The 16k quick check covers the first 16
3078        // slices, so damage at offset 100 trips the gate (strict reaches its
3079        // slice scan via the quick-hash-fail route) while damage at offset
3080        // 18_000 does not (strict reaches it via the full-hash-fail route).
3081        // Fast and strict must agree in both cases.
3082        let base: Vec<u8> = (0..20480u32).map(|i| (i % 256) as u8).collect();
3083
3084        for corrupt_offset in [100usize, 18_000usize] {
3085            let (set, mut access, file_id) = setup_test_set(&base, 1024);
3086            let mut corrupted = base.clone();
3087            corrupted[corrupt_offset] ^= 0xFF;
3088            access.add_file(file_id, corrupted);
3089
3090            let strict =
3091                verify_selected_file_ids_with_options(&set, &access, &[file_id], &strict_opts());
3092            let fast =
3093                verify_selected_file_ids_with_options(&set, &access, &[file_id], &fast_opts());
3094
3095            assert!(
3096                matches!(fast.files[0].status, FileStatus::Damaged(1)),
3097                "offset {corrupt_offset}: unexpected status {:?}",
3098                fast.files[0].status
3099            );
3100            assert_eq!(fast.total_missing_blocks, 1, "offset {corrupt_offset}");
3101            let damaged_slice = corrupt_offset / 1024;
3102            assert!(
3103                !fast.files[0].valid_slices[damaged_slice],
3104                "offset {corrupt_offset}: slice {damaged_slice} should be damaged"
3105            );
3106            assert_eq!(
3107                fast.files[0].valid_slices.iter().filter(|v| !**v).count(),
3108                1,
3109                "offset {corrupt_offset}: exactly one slice damaged"
3110            );
3111            assert_eq!(
3112                format!("{strict:?}"),
3113                format!("{fast:?}"),
3114                "fast and strict must agree for corruption at offset {corrupt_offset}"
3115            );
3116        }
3117    }
3118
3119    #[test]
3120    fn fast_verify_falls_back_without_ifsc() {
3121        let file_data = vec![0x33u8; 4096];
3122        let (set, access, file_id) = setup_test_set_no_ifsc(&file_data, 1024);
3123
3124        let strict =
3125            verify_selected_file_ids_with_options(&set, &access, &[file_id], &strict_opts());
3126        let fast = verify_selected_file_ids_with_options(&set, &access, &[file_id], &fast_opts());
3127
3128        assert!(matches!(fast.files[0].status, FileStatus::Complete));
3129        assert_eq!(fast.total_missing_blocks, 0);
3130        assert_eq!(
3131            format!("{strict:?}"),
3132            format!("{fast:?}"),
3133            "without IFSC, fast verify must fall back to the serial pipeline"
3134        );
3135    }
3136
3137    #[test]
3138    fn fast_verify_length_mismatch_falls_back() {
3139        let file_data = vec![0x5Cu8; 4096];
3140        let (set, mut access, file_id) = setup_test_set(&file_data, 1024);
3141        // Truncate on disk so the on-disk length no longer matches the
3142        // description: the fast path is ineligible and both modes take the
3143        // serial pipeline.
3144        access.add_file(file_id, file_data[..2500].to_vec());
3145
3146        let strict =
3147            verify_selected_file_ids_with_options(&set, &access, &[file_id], &strict_opts());
3148        let fast = verify_selected_file_ids_with_options(&set, &access, &[file_id], &fast_opts());
3149
3150        assert!(matches!(fast.files[0].status, FileStatus::Damaged(_)));
3151        assert_eq!(
3152            format!("{strict:?}"),
3153            format!("{fast:?}"),
3154            "length mismatch must fall back identically to strict"
3155        );
3156    }
3157
3158    #[test]
3159    fn fast_verify_flag_off_matches_strict_expectation() {
3160        // One intact + one damaged file, flag off, must reproduce the
3161        // pre-change strict pipeline outcome exactly.
3162        let intact = vec![0x11u8; 4096];
3163        let damaged = vec![0x22u8; 4096];
3164        let (set, mut access, file_ids) =
3165            setup_test_set_multi(&[(&intact, "intact.rar"), (&damaged, "damaged.rar")], 1024);
3166        let mut corrupted = damaged.clone();
3167        corrupted[0] ^= 0xFF;
3168        access.add_file(file_ids[1], corrupted);
3169
3170        let off = verify_selected_file_ids_with_options(
3171            &set,
3172            &access,
3173            &file_ids,
3174            &VerifyOptions::default(),
3175        );
3176        assert!(matches!(off.files[0].status, FileStatus::Complete));
3177        assert!(matches!(off.files[1].status, FileStatus::Damaged(1)));
3178        assert_eq!(off.total_missing_blocks, 1);
3179
3180        // Flag-off must equal the dedicated strict entry point byte for byte.
3181        let strict = verify_selected_file_ids(&set, &access, &file_ids);
3182        assert_eq!(format!("{off:?}"), format!("{strict:?}"));
3183    }
3184
3185    #[test]
3186    fn fast_verify_parallel_matches_strict() {
3187        let intact = vec![0x11u8; 4096];
3188        let damaged = vec![0x22u8; 4096];
3189        let truncated = vec![0x33u8; 4096];
3190        let (set, mut access, file_ids) = setup_test_set_multi(
3191            &[
3192                (&intact, "intact.rar"),
3193                (&damaged, "damaged.rar"),
3194                (&truncated, "truncated.rar"),
3195            ],
3196            1024,
3197        );
3198        let mut corrupted = damaged.clone();
3199        corrupted[1500] ^= 0xFF;
3200        access.add_file(file_ids[1], corrupted);
3201        access.add_file(file_ids[2], truncated[..2500].to_vec());
3202
3203        // Multi-file: file-parallel, each file's spans serial.
3204        let strict = verify_selected_file_ids_parallel(&set, &access, &file_ids);
3205        let fast =
3206            verify_selected_file_ids_parallel_with_options(&set, &access, &file_ids, &fast_opts());
3207        assert!(fast.total_missing_blocks > 0, "fixture must have damage");
3208        assert_eq!(
3209            format!("{strict:?}"),
3210            format!("{fast:?}"),
3211            "parallel fast verify must match parallel strict verify"
3212        );
3213
3214        // Single file: exercises the span-parallel fast path.
3215        for id in &file_ids {
3216            let strict_one =
3217                verify_selected_file_ids_parallel(&set, &access, std::slice::from_ref(id));
3218            let fast_one = verify_selected_file_ids_parallel_with_options(
3219                &set,
3220                &access,
3221                std::slice::from_ref(id),
3222                &fast_opts(),
3223            );
3224            assert_eq!(
3225                format!("{strict_one:?}"),
3226                format!("{fast_one:?}"),
3227                "single-file span-parallel fast verify must match strict"
3228            );
3229        }
3230    }
3231
3232    #[test]
3233    fn refresh_repairability_recomputes_after_missing_count_changes() {
3234        let mut result = VerificationResult {
3235            files: Vec::new(),
3236            recovery_blocks_available: 40,
3237            total_missing_blocks: 1380,
3238            repairable: Repairability::Insufficient {
3239                blocks_needed: 1380,
3240                blocks_available: 40,
3241                deficit: 1340,
3242            },
3243        };
3244
3245        result.total_missing_blocks = 40;
3246        result.refresh_repairability();
3247
3248        assert!(matches!(
3249            result.repairable,
3250            Repairability::Repairable {
3251                blocks_needed: 40,
3252                blocks_available: 40
3253            }
3254        ));
3255    }
3256
3257    fn deterministic_file(len: usize) -> Vec<u8> {
3258        (0..len).map(|i| (i % 251) as u8).collect()
3259    }
3260
3261    /// The strict pipeline's damaged verdict must be exactly the per-slice
3262    /// vector `verify_slices` computes, wherever the damage sits — including
3263    /// the zero-padded tail slice, and including a slice size too large for
3264    /// the fused stream's slice-aligned chunk (which disables the CRC32
3265    /// ride-along and runs the whole-file hash to completion).
3266    #[test]
3267    fn strict_verify_reports_verify_slices_vector_for_every_damage_position() {
3268        for slice_size in [1024u64, (VERIFY_FULL_HASH_CHUNK_BYTES as u64) + 512] {
3269            let len = (slice_size as usize) * 5 + 300;
3270            let pristine = deterministic_file(len);
3271            let slice_count = (len as u64).div_ceil(slice_size) as usize;
3272            for damaged_slice in 0..slice_count {
3273                let mut data = pristine.clone();
3274                let at = (damaged_slice as u64 * slice_size) as usize + 7;
3275                data[at] ^= 0xff;
3276                let (set, _, file_id) = setup_test_set(&pristine, slice_size);
3277                let mut access = MemoryFileAccess::new();
3278                access.add_file(file_id, data);
3279
3280                let expected = verify_slices(&set, &file_id, &access).expect("slice vector");
3281                assert!(!expected[damaged_slice], "slice {damaged_slice} must fail");
3282                let expected_damaged = expected.iter().filter(|valid| !**valid).count() as u32;
3283
3284                for span_access in [None, Some(&access as &(dyn FileAccess + Sync))] {
3285                    let result = verify_selected_file_ids_resolved(
3286                        &set,
3287                        &access,
3288                        span_access,
3289                        &[file_id],
3290                        &VerifyOptions::default(),
3291                        false,
3292                    );
3293                    let file = &result.files[0];
3294                    assert!(
3295                        matches!(file.status, FileStatus::Damaged(count) if count == expected_damaged),
3296                        "slice_size {slice_size} damaged slice {damaged_slice}: {:?}",
3297                        file.status
3298                    );
3299                    assert_eq!(file.valid_slices, expected);
3300                    assert_eq!(file.missing_slice_count, expected_damaged);
3301                    assert_eq!(result.total_missing_blocks, expected_damaged);
3302                }
3303            }
3304        }
3305    }
3306
3307    /// An intact file still needs the whole-file MD5 to be called complete,
3308    /// and the fused stream must run it to the end to say so.
3309    #[test]
3310    fn strict_verify_still_requires_the_whole_file_hash_to_report_complete() {
3311        let slice_size = 1024u64;
3312        let data = deterministic_file((slice_size as usize) * 4 + 11);
3313        let (set, access, file_id) = setup_test_set(&data, slice_size);
3314
3315        for span_access in [None, Some(&access as &(dyn FileAccess + Sync))] {
3316            let result = verify_selected_file_ids_resolved(
3317                &set,
3318                &access,
3319                span_access,
3320                &[file_id],
3321                &VerifyOptions::default(),
3322                false,
3323            );
3324            assert!(matches!(result.files[0].status, FileStatus::Complete));
3325            assert_eq!(result.files[0].valid_slices, vec![true; 5]);
3326            assert_eq!(result.total_missing_blocks, 0);
3327        }
3328    }
3329
3330    /// Every slice checks out but the described whole-file MD5 does not: the
3331    /// strict rule is that only a full length+MD5 match may mark a file
3332    /// complete, so this reports `Damaged(0)` with an all-valid slice vector.
3333    /// The fused stream must not short-circuit its way past that.
3334    #[test]
3335    fn strict_verify_reports_damaged_zero_when_only_the_whole_file_hash_disagrees() {
3336        // Over 16 KiB: below that the format requires hash_16k == hash_full,
3337        // so the two hashes cannot be made to disagree.
3338        let slice_size = 4096u64;
3339        let data = deterministic_file((slice_size as usize) * 6);
3340        let (set, access, file_id) =
3341            setup_test_set_with_full_hash(&data, slice_size, Some([0x5a; 16]));
3342
3343        for span_access in [None, Some(&access as &(dyn FileAccess + Sync))] {
3344            let result = verify_selected_file_ids_resolved(
3345                &set,
3346                &access,
3347                span_access,
3348                &[file_id],
3349                &VerifyOptions::default(),
3350                false,
3351            );
3352            assert!(matches!(result.files[0].status, FileStatus::Damaged(0)));
3353            assert_eq!(result.files[0].valid_slices, vec![true; 6]);
3354            assert_eq!(result.total_missing_blocks, 0);
3355        }
3356    }
3357
3358    /// `strict_slice_validity` may pick either scanner, so the span-parallel
3359    /// scanner and `verify_slices` must agree slice for slice.
3360    #[test]
3361    fn span_parallel_slice_scan_matches_verify_slices() {
3362        let slice_size = 1024u64;
3363        let pristine = deterministic_file((slice_size as usize) * 9 + 617);
3364        let mut data = pristine.clone();
3365        for damaged_slice in [0usize, 3, 9] {
3366            let at = (damaged_slice as u64 * slice_size) as usize + 1;
3367            data[at] ^= 0xff;
3368        }
3369        let (set, _, file_id) = setup_test_set(&pristine, slice_size);
3370        let mut access = MemoryFileAccess::new();
3371        access.add_file(file_id, data);
3372
3373        let serial = verify_slices(&set, &file_id, &access).expect("slice vector");
3374        let spanned = verify_file_sliced(&set, &access, &file_id, true).expect("span scan");
3375        assert_eq!(spanned.valid_slices, serial);
3376        assert_eq!(
3377            strict_slice_validity(&set, &access, Some(&access), &file_id, serial.len()),
3378            serial
3379        );
3380    }
3381
3382    /// Measures what the CRC32 ride-along costs an intact file — the one case
3383    /// where its work buys nothing (every slice matches, so the early exit
3384    /// never fires). In-memory access, so the arithmetic has no I/O to hide
3385    /// behind: this is the overhead's worst case. Run by hand:
3386    ///
3387    /// ```sh
3388    /// cargo test --release -p par2-rs --lib -- --ignored perf_intact_ride_along
3389    /// ```
3390    #[test]
3391    #[ignore = "perf measurement, run by hand in release mode"]
3392    fn perf_intact_ride_along_overhead() {
3393        let slice_size = 512 * 1024u64;
3394        let len = 512 * 1024 * 1024usize;
3395        let data = deterministic_file(len);
3396        let (set, access, file_id) = setup_test_set_with_full_hash(&data, slice_size, None);
3397        let checksums = set.file_checksums(&file_id).expect("checksums");
3398
3399        let mut timings: [Vec<f64>; 2] = [Vec::new(), Vec::new()];
3400        // Alternate arms so thermal drift hits both equally.
3401        for _round in 0..5 {
3402            for (arm, slice_checksums) in [(0usize, None), (1usize, Some(checksums))] {
3403                let started = std::time::Instant::now();
3404                let outcome = stream_strict_hashes(&set, &file_id, &access, slice_checksums)
3405                    .expect("stream outcome");
3406                let elapsed = started.elapsed().as_secs_f64();
3407                assert!(matches!(
3408                    outcome,
3409                    StrictStreamOutcome::Hashes {
3410                        quick_ok: true,
3411                        full_ok: true
3412                    }
3413                ));
3414                timings[arm].push(elapsed);
3415            }
3416        }
3417        let best = |samples: &[f64]| samples.iter().copied().fold(f64::INFINITY, f64::min);
3418        let plain = best(&timings[0]);
3419        let fused = best(&timings[1]);
3420        let mb = len as f64 / (1024.0 * 1024.0);
3421        println!(
3422            "intact strict stream, {len} bytes, slice {slice_size}: \
3423             plain {:.0} MB/s, ride-along {:.0} MB/s, overhead {:+.2}%",
3424            mb / plain,
3425            mb / fused,
3426            (fused / plain - 1.0) * 100.0
3427        );
3428    }
3429
3430    /// The fused stream must not read past the described length, and must
3431    /// still notice a file that grew behind the length it was told.
3432    #[test]
3433    fn fused_chunk_size_is_slice_aligned_and_bounded() {
3434        assert_eq!(fused_chunk_bytes(0), None);
3435        assert_eq!(
3436            fused_chunk_bytes((VERIFY_FULL_HASH_CHUNK_BYTES as u64) + 1),
3437            None
3438        );
3439        for slice_size in [2u64, 1024, 65536, VERIFY_FULL_HASH_CHUNK_BYTES as u64] {
3440            let chunk = fused_chunk_bytes(slice_size).expect("chunk");
3441            assert!(chunk > 0);
3442            assert!(chunk <= VERIFY_FULL_HASH_CHUNK_BYTES);
3443            assert_eq!(chunk as u64 % slice_size, 0);
3444        }
3445    }
3446
3447    // --- Per-slice evidence intake ----------------------------------------
3448
3449    /// A [`FileAccess`] that counts the bytes its reads actually deliver, so a
3450    /// test can assert on what a pipeline *read* and not only on what it
3451    /// concluded. It changes nothing else about the access it wraps: the
3452    /// evidence path's whole claim is that it reads less for the same verdict,
3453    /// and only a counter can hold it to that.
3454    struct CountingAccess {
3455        inner: MemoryFileAccess,
3456        bytes_read: AtomicUsize,
3457    }
3458
3459    impl CountingAccess {
3460        fn new(inner: MemoryFileAccess) -> Self {
3461            Self {
3462                inner,
3463                bytes_read: AtomicUsize::new(0),
3464            }
3465        }
3466
3467        fn take_bytes_read(&self) -> usize {
3468            self.bytes_read.swap(0, Ordering::Relaxed)
3469        }
3470    }
3471
3472    impl FileAccess for CountingAccess {
3473        fn read_file_range(&self, file_id: &FileId, offset: u64, len: u64) -> io::Result<Vec<u8>> {
3474            let data = self.inner.read_file_range(file_id, offset, len)?;
3475            self.bytes_read.fetch_add(data.len(), Ordering::Relaxed);
3476            Ok(data)
3477        }
3478
3479        fn read_file_range_into(
3480            &self,
3481            file_id: &FileId,
3482            offset: u64,
3483            dst: &mut [u8],
3484        ) -> io::Result<usize> {
3485            let read = self.inner.read_file_range_into(file_id, offset, dst)?;
3486            self.bytes_read.fetch_add(read, Ordering::Relaxed);
3487            Ok(read)
3488        }
3489
3490        fn file_exists(&self, file_id: &FileId) -> bool {
3491            self.inner.file_exists(file_id)
3492        }
3493
3494        fn file_length(&self, file_id: &FileId) -> Option<u64> {
3495            self.inner.file_length(file_id)
3496        }
3497
3498        fn read_file(&self, file_id: &FileId) -> io::Result<Vec<u8>> {
3499            let data = self.inner.read_file(file_id)?;
3500            self.bytes_read.fetch_add(data.len(), Ordering::Relaxed);
3501            Ok(data)
3502        }
3503
3504        fn write_file_range(
3505            &mut self,
3506            file_id: &FileId,
3507            offset: u64,
3508            data: &[u8],
3509        ) -> io::Result<()> {
3510            self.inner.write_file_range(file_id, offset, data)
3511        }
3512    }
3513
3514    fn evidence_opts(proven: HashMap<FileId, Vec<bool>>) -> VerifyOptions {
3515        VerifyOptions {
3516            proven_slices: proven,
3517            ..Default::default()
3518        }
3519    }
3520
3521    fn deterministic_bytes(len: usize, seed: u8) -> Vec<u8> {
3522        (0..len)
3523            .map(|index| ((index as u8).wrapping_mul(31)).wrapping_add(seed))
3524            .collect()
3525    }
3526
3527    /// One corpus covering the shapes a verify has to get right: an intact
3528    /// file, a damaged one, one with a short tail slice, and one that is not
3529    /// there at all. Returns the set, an access holding only the files that
3530    /// exist, and the ids in corpus order.
3531    fn evidence_corpus(slice_size: u64) -> (Par2FileSet, MemoryFileAccess, Vec<FileId>) {
3532        let intact = deterministic_bytes(4 * slice_size as usize, 1);
3533        let damaged_source = deterministic_bytes(4 * slice_size as usize, 2);
3534        let short_tail = deterministic_bytes(3 * slice_size as usize - 7, 3);
3535        let absent = deterministic_bytes(2 * slice_size as usize, 4);
3536
3537        let (set, _, ids) = setup_test_set_multi(
3538            &[
3539                (&intact, "intact.bin"),
3540                (&damaged_source, "damaged.bin"),
3541                (&short_tail, "short-tail.bin"),
3542                (&absent, "absent.bin"),
3543            ],
3544            slice_size,
3545        );
3546
3547        // Damage slice 2 of `damaged.bin` and slice 0 of `short-tail.bin`, and
3548        // never add `absent.bin` at all.
3549        let mut damaged = damaged_source.clone();
3550        let start = 2 * slice_size as usize;
3551        damaged[start..start + slice_size as usize].fill(0);
3552        let mut short_tail_damaged = short_tail.clone();
3553        short_tail_damaged[..slice_size as usize].fill(0xFF);
3554
3555        let mut access = MemoryFileAccess::new();
3556        access.add_file(ids[0], intact);
3557        access.add_file(ids[1], damaged);
3558        access.add_file(ids[2], short_tail_damaged);
3559
3560        (set, access, ids)
3561    }
3562
3563    /// Per-slice evidence for every slice a full read found valid — the
3564    /// attestation a host that had genuinely proven those slices would make.
3565    fn evidence_from(result: &VerificationResult) -> HashMap<FileId, Vec<bool>> {
3566        result
3567            .files
3568            .iter()
3569            .map(|file| (file.file_id, file.valid_slices.clone()))
3570            .collect()
3571    }
3572
3573    /// Property (a): with nothing proven, verification is what it always was.
3574    /// An absent map, an empty map, an all-`false` map and a map whose vectors
3575    /// are the wrong shape must every one of them reach the same branch and
3576    /// produce the same result, byte for byte.
3577    #[test]
3578    fn absent_evidence_leaves_verification_unchanged() {
3579        let slice_size = 512u64;
3580        let (set, access, ids) = evidence_corpus(slice_size);
3581        let baseline = verify_selected_file_ids_with_options(&set, &access, &ids, &strict_opts());
3582        assert!(baseline.total_missing_blocks > 0, "{baseline:#?}");
3583
3584        let nothing_proven: HashMap<FileId, Vec<bool>> = baseline
3585            .files
3586            .iter()
3587            .map(|file| (file.file_id, vec![false; file.valid_slices.len()]))
3588            .collect();
3589        let wrong_shape: HashMap<FileId, Vec<bool>> = baseline
3590            .files
3591            .iter()
3592            .map(|file| (file.file_id, vec![true; file.valid_slices.len() + 1]))
3593            .collect();
3594
3595        for (label, proven) in [
3596            ("empty map", HashMap::new()),
3597            ("all-false vectors", nothing_proven),
3598            ("wrong-length vectors", wrong_shape),
3599        ] {
3600            let with_evidence =
3601                verify_selected_file_ids_with_options(&set, &access, &ids, &evidence_opts(proven));
3602            assert_eq!(
3603                format!("{with_evidence:?}"),
3604                format!("{baseline:?}"),
3605                "{label} must leave verification unchanged"
3606            );
3607        }
3608    }
3609
3610    /// Property (b): correct evidence for the slices a full read would have
3611    /// found valid produces the same verdicts from strictly fewer bytes read.
3612    #[test]
3613    fn correct_evidence_keeps_the_verdicts_and_reads_fewer_bytes() {
3614        let slice_size = 512u64;
3615        let (set, memory, ids) = evidence_corpus(slice_size);
3616        let access = CountingAccess::new(memory);
3617
3618        let baseline = verify_selected_file_ids_with_options(&set, &access, &ids, &strict_opts());
3619        let baseline_bytes = access.take_bytes_read();
3620        assert!(baseline_bytes > 0, "the unevidenced pass reads the corpus");
3621
3622        let evidenced = verify_selected_file_ids_with_options(
3623            &set,
3624            &access,
3625            &ids,
3626            &evidence_opts(evidence_from(&baseline)),
3627        );
3628        let evidenced_bytes = access.take_bytes_read();
3629
3630        assert_eq!(
3631            format!("{evidenced:?}"),
3632            format!("{baseline:?}"),
3633            "correct evidence must not change a single verdict"
3634        );
3635        assert!(
3636            evidenced_bytes < baseline_bytes,
3637            "evidence must save reads: {evidenced_bytes} vs {baseline_bytes} bytes"
3638        );
3639    }
3640
3641    /// Property (c): damage in a slice the evidence says nothing about is
3642    /// still found, and the whole result — statuses, per-slice vectors, totals
3643    /// and repairability — matches the full read exactly. Every damage
3644    /// position is walked, because an off-by-one in span reassembly would only
3645    /// show at some of them.
3646    #[test]
3647    fn damage_in_an_unproven_slice_matches_the_full_read_at_every_position() {
3648        let slice_size = 256u64;
3649        let slice_count = 6usize;
3650        let clean = deterministic_bytes(slice_count * slice_size as usize, 9);
3651        let (set, _, ids) = setup_test_set_multi(&[(&clean, "walked.bin")], slice_size);
3652        let file_id = ids[0];
3653
3654        for damaged_index in 0..slice_count {
3655            let mut damaged = clean.clone();
3656            let start = damaged_index * slice_size as usize;
3657            damaged[start..start + slice_size as usize].fill(0x5A);
3658            let mut memory = MemoryFileAccess::new();
3659            memory.add_file(file_id, damaged);
3660            let access = CountingAccess::new(memory);
3661
3662            let baseline =
3663                verify_selected_file_ids_with_options(&set, &access, &ids, &strict_opts());
3664            let baseline_bytes = access.take_bytes_read();
3665            assert_eq!(
3666                baseline.files[0].missing_slice_count, 1,
3667                "slice {damaged_index} must read as damaged"
3668            );
3669
3670            // Prove everything the full read found valid — that is, every
3671            // slice except the damaged one — and leave the damaged one to be
3672            // read.
3673            let evidenced = verify_selected_file_ids_with_options(
3674                &set,
3675                &access,
3676                &ids,
3677                &evidence_opts(evidence_from(&baseline)),
3678            );
3679            let evidenced_bytes = access.take_bytes_read();
3680
3681            assert_eq!(
3682                format!("{evidenced:?}"),
3683                format!("{baseline:?}"),
3684                "slice {damaged_index}: the evidenced result must match the full read exactly"
3685            );
3686            assert_eq!(
3687                evidenced_bytes, slice_size as usize,
3688                "slice {damaged_index}: only the unproven slice should be read \
3689                 (full read was {baseline_bytes} bytes)"
3690            );
3691        }
3692    }
3693
3694    /// Property (d): a file that is not there cannot be attested into
3695    /// existence. The existence check runs before evidence is consulted, so
3696    /// all-`true` evidence for a missing file leaves it `Missing` with every
3697    /// slice invalid, exactly as with no evidence at all.
3698    #[test]
3699    fn evidence_cannot_resurrect_a_missing_file() {
3700        let slice_size = 512u64;
3701        let data = deterministic_bytes(3 * slice_size as usize, 5);
3702        let (set, _, ids) = setup_test_set_multi(&[(&data, "gone.bin")], slice_size);
3703        let access = CountingAccess::new(MemoryFileAccess::new());
3704
3705        let baseline = verify_selected_file_ids_with_options(&set, &access, &ids, &strict_opts());
3706        let proven: HashMap<FileId, Vec<bool>> = [(ids[0], vec![true; 3])].into_iter().collect();
3707        let evidenced =
3708            verify_selected_file_ids_with_options(&set, &access, &ids, &evidence_opts(proven));
3709
3710        assert!(matches!(evidenced.files[0].status, FileStatus::Missing));
3711        assert_eq!(evidenced.files[0].valid_slices, vec![false; 3]);
3712        assert_eq!(evidenced.total_missing_blocks, 3);
3713        assert_eq!(format!("{evidenced:?}"), format!("{baseline:?}"));
3714        assert_eq!(access.take_bytes_read(), 0);
3715    }
3716
3717    /// A file whose on-disk length no longer matches the description is not
3718    /// the file those per-slice verdicts were about — the offsets have moved
3719    /// under them — so the evidence is discarded and the file takes the
3720    /// pipeline it always took.
3721    #[test]
3722    fn evidence_for_a_length_mismatched_file_is_ignored() {
3723        let slice_size = 512u64;
3724        let data = deterministic_bytes(3 * slice_size as usize, 6);
3725        let (set, _, ids) = setup_test_set_multi(&[(&data, "truncated.bin")], slice_size);
3726        let mut memory = MemoryFileAccess::new();
3727        memory.add_file(ids[0], data[..data.len() - 100].to_vec());
3728        let access = CountingAccess::new(memory);
3729
3730        let baseline = verify_selected_file_ids_with_options(&set, &access, &ids, &strict_opts());
3731        let proven: HashMap<FileId, Vec<bool>> = [(ids[0], vec![true; 3])].into_iter().collect();
3732        let evidenced =
3733            verify_selected_file_ids_with_options(&set, &access, &ids, &evidence_opts(proven));
3734
3735        assert!(matches!(baseline.files[0].status, FileStatus::Damaged(_)));
3736        assert_eq!(
3737            format!("{evidenced:?}"),
3738            format!("{baseline:?}"),
3739            "a length mismatch must discard the evidence, not believe it"
3740        );
3741    }
3742
3743    /// The degenerate end of the range: a file whose every slice is proven is
3744    /// `Complete` without a byte being read. The whole-file MD5 arm cannot run
3745    /// once any slice is skipped, so completeness comes from slice proof — the
3746    /// same shape fast verify already reports.
3747    #[test]
3748    fn a_fully_proven_file_is_complete_without_a_read() {
3749        let slice_size = 512u64;
3750        let data = deterministic_bytes(4 * slice_size as usize, 7);
3751        let (set, memory, ids) = setup_test_set_multi(&[(&data, "proven.bin")], slice_size);
3752        let access = CountingAccess::new(memory);
3753
3754        let proven: HashMap<FileId, Vec<bool>> = [(ids[0], vec![true; 4])].into_iter().collect();
3755        let evidenced =
3756            verify_selected_file_ids_with_options(&set, &access, &ids, &evidence_opts(proven));
3757
3758        assert!(matches!(evidenced.files[0].status, FileStatus::Complete));
3759        assert_eq!(evidenced.files[0].valid_slices, vec![true; 4]);
3760        assert_eq!(evidenced.total_missing_blocks, 0);
3761        assert!(matches!(evidenced.repairable, Repairability::NotNeeded));
3762        assert_eq!(
3763            access.take_bytes_read(),
3764            0,
3765            "a fully proven file must cost no reads"
3766        );
3767    }
3768
3769    /// Evidence composes with the fast arm the way it composes with the strict
3770    /// one: the proven slices are skipped, the rest go through the same
3771    /// per-slice checks, and the verdict is the same either way. It also
3772    /// composes with the file-parallel driver, which consults the same option.
3773    #[test]
3774    fn evidence_composes_with_fast_verify_and_the_parallel_driver() {
3775        let slice_size = 512u64;
3776        let (set, memory, ids) = evidence_corpus(slice_size);
3777        let baseline = verify_selected_file_ids_with_options(&set, &memory, &ids, &strict_opts());
3778        let proven = evidence_from(&baseline);
3779
3780        let mut fast_with_evidence = fast_opts();
3781        fast_with_evidence.proven_slices = proven.clone();
3782        let fast = verify_selected_file_ids_with_options(&set, &memory, &ids, &fast_with_evidence);
3783        assert_eq!(
3784            format!("{fast:?}"),
3785            format!("{baseline:?}"),
3786            "evidence under fast verify must reach the same verdicts"
3787        );
3788
3789        let access = CountingAccess::new(memory);
3790        let parallel_baseline =
3791            verify_selected_file_ids_parallel_with_options(&set, &access, &ids, &strict_opts());
3792        let parallel_bytes = access.take_bytes_read();
3793        let parallel = verify_selected_file_ids_parallel_with_options(
3794            &set,
3795            &access,
3796            &ids,
3797            &evidence_opts(proven),
3798        );
3799        let parallel_evidenced_bytes = access.take_bytes_read();
3800        assert_eq!(
3801            format!("{parallel:?}"),
3802            format!("{parallel_baseline:?}"),
3803            "the parallel driver must honour evidence without changing verdicts"
3804        );
3805        assert!(
3806            parallel_evidenced_bytes < parallel_bytes,
3807            "the parallel driver must also save reads: \
3808             {parallel_evidenced_bytes} vs {parallel_bytes}"
3809        );
3810    }
3811
3812    /// A plan built with evidence must name exactly the unproven slices, in
3813    /// order, and a plan built without it must still tile the whole file in
3814    /// spans — the shape every existing caller depends on.
3815    #[test]
3816    fn the_plan_names_exactly_the_unproven_slices() {
3817        let slice_size = 512u64;
3818        let data = deterministic_bytes(6 * slice_size as usize, 11);
3819        let (set, access, ids) = setup_test_set_multi(&[(&data, "planned.bin")], slice_size);
3820        let file_id = ids[0];
3821
3822        let full = sliced_verify_plan(&set, &access, &file_id, None).expect("plan");
3823        match full.work {
3824            SlicedVerifyWork::Spans(spans) => {
3825                let covered: usize = spans.iter().map(|(_, count)| count).sum();
3826                assert_eq!(covered, 6, "spans must tile every slice: {spans:?}");
3827                assert_eq!(spans[0].0, 0);
3828            }
3829            SlicedVerifyWork::Slices(_) => panic!("a plan with no evidence reads spans"),
3830        }
3831        assert!(full.proven.is_none());
3832
3833        for mask in [
3834            vec![false; 6],
3835            vec![true, false, true, false, true, false],
3836            vec![true, true, false, false, false, true],
3837            vec![true; 6],
3838        ] {
3839            let plan = sliced_verify_plan(&set, &access, &file_id, Some(&mask)).expect("plan");
3840            let expected: Vec<usize> = mask
3841                .iter()
3842                .enumerate()
3843                .filter_map(|(index, proven)| (!*proven).then_some(index))
3844                .collect();
3845            match plan.work {
3846                SlicedVerifyWork::Slices(indices) => assert_eq!(indices, expected, "{mask:?}"),
3847                SlicedVerifyWork::Spans(_) => panic!("an evidenced plan reads named slices"),
3848            }
3849            assert_eq!(plan.proven.as_deref(), Some(mask.as_slice()));
3850        }
3851    }
3852
3853    /// A lane batch is an arbitrary grouping, so a slice whose read fails must
3854    /// fail alone. Reading past the end of a truncated file is the reachable
3855    /// version of that, and the slices batched alongside it must keep the
3856    /// verdicts their own bytes earned.
3857    #[test]
3858    fn a_failed_read_fails_only_its_own_slice_in_a_batch() {
3859        let slice_size = 512u64;
3860        let slice_count = 6usize;
3861        let data = deterministic_bytes(slice_count * slice_size as usize, 13);
3862        let (set, _, ids) = setup_test_set_multi(&[(&data, "cut-short.bin")], slice_size);
3863        let file_id = ids[0];
3864
3865        // The access reports the described length but only holds the first
3866        // four slices, so reads of slices 4 and 5 come up short.
3867        struct ShortAccess {
3868            data: Vec<u8>,
3869            reported_len: u64,
3870            file_id: FileId,
3871        }
3872        impl FileAccess for ShortAccess {
3873            fn read_file_range(
3874                &self,
3875                _file_id: &FileId,
3876                offset: u64,
3877                len: u64,
3878            ) -> io::Result<Vec<u8>> {
3879                let start = (offset as usize).min(self.data.len());
3880                let end = (start + len as usize).min(self.data.len());
3881                Ok(self.data[start..end].to_vec())
3882            }
3883            fn read_file_range_into(
3884                &self,
3885                _file_id: &FileId,
3886                offset: u64,
3887                dst: &mut [u8],
3888            ) -> io::Result<usize> {
3889                let start = (offset as usize).min(self.data.len());
3890                let end = (start + dst.len()).min(self.data.len());
3891                dst[..end - start].copy_from_slice(&self.data[start..end]);
3892                Ok(end - start)
3893            }
3894            fn file_exists(&self, file_id: &FileId) -> bool {
3895                *file_id == self.file_id
3896            }
3897            fn file_length(&self, _file_id: &FileId) -> Option<u64> {
3898                Some(self.reported_len)
3899            }
3900            fn read_file(&self, _file_id: &FileId) -> io::Result<Vec<u8>> {
3901                Ok(self.data.clone())
3902            }
3903            fn write_file_range(
3904                &mut self,
3905                _file_id: &FileId,
3906                _offset: u64,
3907                _data: &[u8],
3908            ) -> io::Result<()> {
3909                Err(io::Error::new(io::ErrorKind::Unsupported, "read-only"))
3910            }
3911        }
3912
3913        let access = ShortAccess {
3914            data: data[..4 * slice_size as usize].to_vec(),
3915            reported_len: data.len() as u64,
3916            file_id,
3917        };
3918        let proven: HashMap<FileId, Vec<bool>> =
3919            [(file_id, vec![false; slice_count])].into_iter().collect();
3920        let result =
3921            verify_selected_file_ids_with_options(&set, &access, &ids, &evidence_opts(proven));
3922
3923        assert_eq!(
3924            result.files[0].valid_slices,
3925            vec![true, true, true, true, false, false],
3926            "a short read must condemn only the slices it actually cut short"
3927        );
3928        assert_eq!(result.files[0].missing_slice_count, 2);
3929    }
3930}