Skip to main content

par2_rs/
session.rs

1//! Streaming verification session for incremental PAR2 verification during download.
2//!
3//! [`VerificationSession`] tracks verification state as data arrives, allowing
4//! the scheduler to query file status and repairability at any time without
5//! waiting for the full download to complete.
6
7use std::collections::{BTreeMap, HashMap};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicUsize, Ordering};
10
11use thiserror::Error;
12
13use crate::checksum::SliceChecksumState;
14use crate::packet::Packet;
15use crate::par2_set::Par2FileSet;
16use crate::types::{FileId, RecoverySetId, SliceChecksum};
17use crate::verify::{FileStatus, FileVerification, Repairability, VerificationResult};
18
19const DEFAULT_BUFFERED_BOUNDARY_BYTES: usize = 64 * 1024 * 1024;
20const MAX_PARTIAL_RANGES_PER_SLICE: usize = 4096;
21const PARTIAL_RANGE_ACCOUNTING_BYTES: usize = std::mem::size_of::<(u64, Vec<u8>)>() + 64;
22
23/// A caller-shareable cap for buffered, incomplete slice data.
24///
25/// Whole, aligned slices are checksummed immediately and do not consume this
26/// budget. Cloning the budget makes its limit apply across all of the sessions
27/// using that clone.
28#[derive(Clone, Debug)]
29pub struct VerificationMemoryBudget {
30    inner: Arc<VerificationMemoryBudgetInner>,
31}
32
33#[derive(Debug)]
34struct VerificationMemoryBudgetInner {
35    max_buffered_bytes: usize,
36    buffered_bytes: AtomicUsize,
37}
38
39impl VerificationMemoryBudget {
40    /// Create a budget shared by any sessions given a clone of this value.
41    pub fn new(max_buffered_bytes: usize) -> Self {
42        Self {
43            inner: Arc::new(VerificationMemoryBudgetInner {
44                max_buffered_bytes,
45                buffered_bytes: AtomicUsize::new(0),
46            }),
47        }
48    }
49
50    /// Maximum number of buffered boundary bytes allowed across all users.
51    pub fn max_buffered_bytes(&self) -> usize {
52        self.inner.max_buffered_bytes
53    }
54
55    /// Number of bytes currently reserved by incomplete slices.
56    pub fn buffered_bytes(&self) -> usize {
57        self.inner.buffered_bytes.load(Ordering::Acquire)
58    }
59
60    /// Number of additional boundary bytes that can currently be buffered.
61    pub fn available_bytes(&self) -> usize {
62        self.max_buffered_bytes()
63            .saturating_sub(self.buffered_bytes())
64    }
65
66    fn try_reserve(&self, bytes: usize) -> bool {
67        if bytes == 0 {
68            return true;
69        }
70
71        let limit = self.inner.max_buffered_bytes;
72        let mut current = self.inner.buffered_bytes.load(Ordering::Acquire);
73        loop {
74            if bytes > limit.saturating_sub(current) {
75                return false;
76            }
77
78            match self.inner.buffered_bytes.compare_exchange_weak(
79                current,
80                current + bytes,
81                Ordering::AcqRel,
82                Ordering::Acquire,
83            ) {
84                Ok(_) => return true,
85                Err(observed) => current = observed,
86            }
87        }
88    }
89
90    fn release(&self, bytes: usize) {
91        if bytes != 0 {
92            self.inner.buffered_bytes.fetch_sub(bytes, Ordering::AcqRel);
93        }
94    }
95}
96
97impl Default for VerificationMemoryBudget {
98    fn default() -> Self {
99        Self::new(DEFAULT_BUFFERED_BOUNDARY_BYTES)
100    }
101}
102
103/// Configuration for a [`VerificationSession`].
104#[derive(Clone, Debug, Default)]
105pub struct VerificationSessionOptions {
106    memory_budget: VerificationMemoryBudget,
107}
108
109impl VerificationSessionOptions {
110    /// Create options with the default 64 MiB boundary-buffer budget.
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// Set the caller-shareable budget used for incomplete slice data.
116    pub fn with_memory_budget(mut self, memory_budget: VerificationMemoryBudget) -> Self {
117        self.memory_budget = memory_budget;
118        self
119    }
120
121    /// The budget this session will use for incomplete slice data.
122    pub fn memory_budget(&self) -> &VerificationMemoryBudget {
123        &self.memory_budget
124    }
125}
126
127/// A completed PAR2 slice verdict, suitable for passing to a repair session.
128///
129/// Evidence deliberately identifies PAR2 coordinates only. It never exposes a
130/// filesystem path or assumes where the downloaded bytes were stored.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum SliceEvidenceStrength {
133    /// The slice was compared using the PAR2 IFSC CRC32 only.
134    Crc32Only,
135    /// The slice was compared using both the PAR2 IFSC CRC32 and MD5.
136    Crc32AndMd5,
137}
138
139/// Why an in-stream CRC32 attestation cannot be trusted.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
141pub enum InStreamCrc32ProofError {
142    #[error("in-stream CRC32 covered no bytes")]
143    EmptyCoverage,
144    #[error("in-stream CRC32 did not cover the whole slice")]
145    IncompleteSliceCoverage,
146    #[error("in-stream CRC32 was not derived from the bytes the source will serve")]
147    UnverifiedSourceBytes,
148    #[error("in-stream CRC32 has no independent second-grid CRC32 coverage")]
149    NoIndependentCrc32Coverage,
150}
151
152/// Validated attestation that a caller derived a slice's PAR2 CRC32 in stream.
153///
154/// This is the counterpart to [`crate::ContiguousAssemblyProof`] for a single
155/// slice: it does not itself verify anything, it records that the caller
156/// asserted every property that makes a CRC32-only slice verdict admissible,
157/// and refuses to exist when any of them is false.
158///
159/// # What a proven attestation asserts
160///
161/// - The CRC32 covered the slice's full extent — every byte from the slice's
162///   own offset in the file the recovery set describes, zero-padded to the
163///   block size exactly as PAR2 checksums a short final slice.
164/// - Those bytes are the bytes the repair source will serve, already durable,
165///   not a speculative or in-flight buffer.
166/// - The same span is independently covered by a second CRC32 cut on an
167///   unrelated grid — for a Usenet download path, the article-aligned yEnc
168///   `pcrc32` beside the block-aligned PAR2 CRC32.
169///
170/// # What it does not assert
171///
172/// No MD5 was computed, so this is not slice *identity*: it is the statement
173/// that a 32-bit checksum over the slice's bytes agreed with the recovery
174/// set's IFSC entry. A verdict admitted this way seeds a repair *input*; it
175/// never promotes a file to a whole-file match, and repair still recomputes
176/// the IFSC CRC32 **and MD5** over every byte it consumes, so an attestation
177/// that turns out to be wrong fails the repair loudly rather than producing
178/// wrong output. Settle-time verification of slices with no verdict is
179/// likewise untouched.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct InStreamCrc32Proof {
182    covered_length: u64,
183}
184
185impl InStreamCrc32Proof {
186    /// Validate and record an in-stream CRC32 attestation.
187    ///
188    /// `covered_length` is the number of the file's real bytes the derivation
189    /// covered for this slice — the full block size, or the short remainder for
190    /// a final slice before PAR2's zero padding. The three flags are the
191    /// caller's assertions described on the type; every one must hold.
192    pub fn try_new(
193        covered_length: u64,
194        slice_fully_covered: bool,
195        derived_from_durable_bytes: bool,
196        independently_crc32_covered: bool,
197    ) -> Result<Self, InStreamCrc32ProofError> {
198        if covered_length == 0 {
199            return Err(InStreamCrc32ProofError::EmptyCoverage);
200        }
201        if !slice_fully_covered {
202            return Err(InStreamCrc32ProofError::IncompleteSliceCoverage);
203        }
204        if !derived_from_durable_bytes {
205            return Err(InStreamCrc32ProofError::UnverifiedSourceBytes);
206        }
207        if !independently_crc32_covered {
208            return Err(InStreamCrc32ProofError::NoIndependentCrc32Coverage);
209        }
210
211        Ok(Self { covered_length })
212    }
213
214    /// Real file bytes the attested derivation covered for this slice.
215    pub fn covered_length(&self) -> u64 {
216        self.covered_length
217    }
218}
219
220/// A completed PAR2 slice verdict, suitable for passing to a repair session.
221///
222/// Evidence deliberately identifies PAR2 coordinates only. It never exposes a
223/// filesystem path or assumes where the downloaded bytes were stored.
224///
225/// A session produces this itself from bytes it hashed
226/// ([`VerificationSession::slice_evidence`]). A caller that hashed the bytes
227/// during its own single pass over them — never handing them to par2-rs at all
228/// — mints one with [`SliceEvidence::from_in_stream_crc32`].
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub struct SliceEvidence {
231    recovery_set_id: RecoverySetId,
232    file_id: FileId,
233    slice_index: u32,
234    valid: bool,
235    strength: SliceEvidenceStrength,
236    /// Present only for externally attested verdicts. This is what separates a
237    /// CRC32-only verdict a repair session may act on from one it may not.
238    in_stream: Option<InStreamCrc32Proof>,
239}
240
241impl SliceEvidence {
242    /// Recovery set whose metadata produced this verdict.
243    pub fn recovery_set_id(&self) -> RecoverySetId {
244        self.recovery_set_id
245    }
246
247    /// File to which this PAR2 slice belongs.
248    pub fn file_id(&self) -> FileId {
249        self.file_id
250    }
251
252    /// Zero-based PAR2 slice index within [`Self::file_id`].
253    pub fn slice_index(&self) -> u32 {
254        self.slice_index
255    }
256
257    /// Whether this slice's CRC32 and MD5 matched the PAR2 IFSC entry.
258    pub fn is_valid(&self) -> bool {
259        self.valid
260    }
261
262    /// Hash strength used to produce this verdict.
263    ///
264    /// Externally attested verdicts report [`SliceEvidenceStrength::Crc32Only`],
265    /// because that is what was actually computed. Use
266    /// [`Self::in_stream_proof`] to tell them apart from an unattested CRC32
267    /// comparison.
268    pub fn strength(&self) -> SliceEvidenceStrength {
269        self.strength
270    }
271
272    /// Mint a verdict the caller derived itself, in stream, from a slice's
273    /// PAR2 CRC32.
274    ///
275    /// This exists for a caller that already hashes every payload byte for its
276    /// own reasons and can cut that hash on the recovery set's block grid. It
277    /// hands par2-rs the *conclusion* — this slice's CRC32 did or did not agree
278    /// with the recovery set's IFSC entry — without ever handing over the
279    /// bytes, so nothing is read, buffered or hashed twice.
280    ///
281    /// `valid` is the result of that comparison. `proof` is the caller's
282    /// attestation, and [`InStreamCrc32Proof`] documents exactly what it does
283    /// and does not assert — in short, that a CRC32 covering the whole slice's
284    /// durable bytes agreed with the IFSC entry, and *not* that the slice's
285    /// identity was established, which needs MD5.
286    ///
287    /// The comparison itself is the caller's: par2-rs is not given the derived
288    /// CRC32 and does not re-run the check. That is the point — the recovery
289    /// set's expected CRC32 is public in its IFSC packet, so a caller that has
290    /// read the set can compare against it as well as this crate can, and
291    /// asking it to ship the value back for a redundant comparison would prove
292    /// nothing the attestation does not already carry.
293    ///
294    /// # Where this lands
295    ///
296    /// A **valid** verdict seeds a repair input for that one slice, the same
297    /// seat a slice hashed by [`VerificationSession`] takes. It never promotes
298    /// a file to a whole-file match — only a complete-file hash does that — and
299    /// repair re-derives both the IFSC CRC32 and MD5 over every byte it
300    /// consumes, so a mistaken attestation fails the repair loudly instead of
301    /// producing wrong output.
302    ///
303    /// An **invalid** verdict routes into the session's ordinary contradiction
304    /// handling, which retires the *source* the verdict named — for a source
305    /// served by a handle, that is the whole file, because file identity is the
306    /// only thing such a source has to be named by. A caller holding good
307    /// verdicts for a file's other slices should therefore seed those and
308    /// simply not seed the damaged one, leaving it unresolved for repair or for
309    /// a read-back pass, rather than seeding a contradiction that retires the
310    /// good slices alongside it.
311    pub fn from_in_stream_crc32(
312        recovery_set_id: RecoverySetId,
313        file_id: FileId,
314        slice_index: u32,
315        valid: bool,
316        proof: InStreamCrc32Proof,
317    ) -> Self {
318        Self {
319            recovery_set_id,
320            file_id,
321            slice_index,
322            valid,
323            strength: SliceEvidenceStrength::Crc32Only,
324            in_stream: Some(proof),
325        }
326    }
327
328    /// The in-stream attestation carried by this verdict, when it was minted by
329    /// [`Self::from_in_stream_crc32`] rather than hashed by a session.
330    pub fn in_stream_proof(&self) -> Option<&InStreamCrc32Proof> {
331        self.in_stream.as_ref()
332    }
333
334    /// Whether a repair session may act on this verdict.
335    ///
336    /// True for a slice this crate hashed with both the IFSC CRC32 and MD5, and
337    /// for an externally attested in-stream CRC32 verdict. False for a bare
338    /// CRC32 comparison with nothing vouching for where its bytes came from —
339    /// [`VerificationSession::verify_from_slice_crcs`] produces those, and a
340    /// caller-supplied CRC32 with no attestation cannot say whether it
341    /// describes the bytes a repair would later read.
342    pub fn may_seed_repair_input(&self) -> bool {
343        self.strength == SliceEvidenceStrength::Crc32AndMd5 || self.in_stream.is_some()
344    }
345
346    #[cfg(test)]
347    pub(crate) fn for_test(
348        recovery_set_id: RecoverySetId,
349        file_id: FileId,
350        slice_index: u32,
351        valid: bool,
352        strength: SliceEvidenceStrength,
353    ) -> Self {
354        Self {
355            recovery_set_id,
356            file_id,
357            slice_index,
358            valid,
359            strength,
360            in_stream: None,
361        }
362    }
363}
364
365/// A byte range the caller should read to settle an incomplete or ambiguous
366/// slice. The range is expressed in the logical file, not a filesystem path.
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct SettleRead {
369    file_id: FileId,
370    slice_index: u32,
371    offset: u64,
372    length: u64,
373}
374
375impl SettleRead {
376    fn new(file_id: FileId, slice_index: usize, offset: u64, length: u64) -> Self {
377        Self {
378            file_id,
379            slice_index: u32::try_from(slice_index).unwrap_or(u32::MAX),
380            offset,
381            length,
382        }
383    }
384
385    /// File containing the range to be read.
386    pub fn file_id(&self) -> FileId {
387        self.file_id
388    }
389
390    /// Zero-based PAR2 slice index containing this read.
391    pub fn slice_index(&self) -> u32 {
392        self.slice_index
393    }
394
395    /// Logical file offset to read.
396    pub fn offset(&self) -> u64 {
397        self.offset
398    }
399
400    /// Number of bytes to read.
401    pub fn length(&self) -> u64 {
402        self.length
403    }
404}
405
406/// High-level disposition for a call to [`VerificationSession::feed_range`].
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum FeedDisposition {
409    /// One or more slices were verified immediately or after completing a buffer.
410    Verified,
411    /// Boundary data was retained while waiting for the requested settle reads.
412    Buffered,
413    /// The range only repeated bytes already retained or verified.
414    Duplicate,
415    /// PAR2 metadata or slice checksums have not arrived yet.
416    MetadataPending,
417    /// The supplied file ID is not part of the currently known PAR2 set.
418    UnknownFile,
419    /// The supplied byte range is outside the declared file length.
420    OutOfRange,
421    /// Retaining a boundary range would exceed the shared memory budget.
422    BudgetExhausted,
423    /// A previously buffered byte and this feed disagree.
424    ConflictingOverlap,
425    /// A settled slice was supplied only partially, so its identity cannot be
426    /// checked without reading the complete slice again.
427    NeedsSettleRead,
428}
429
430/// Detailed result of an arbitrary-range feed.
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub struct FeedOutcome {
433    disposition: FeedDisposition,
434    evidence: Vec<SliceEvidence>,
435    settle_reads: Vec<SettleRead>,
436}
437
438impl FeedOutcome {
439    fn new(disposition: FeedDisposition) -> Self {
440        Self {
441            disposition,
442            evidence: Vec::new(),
443            settle_reads: Vec::new(),
444        }
445    }
446
447    /// Summary disposition for this feed.
448    pub fn disposition(&self) -> FeedDisposition {
449        self.disposition
450    }
451
452    /// Slices whose verdict became known during this feed.
453    pub fn evidence(&self) -> &[SliceEvidence] {
454        &self.evidence
455    }
456
457    /// Missing or full-slice reads needed to settle the affected slices.
458    pub fn settle_reads(&self) -> &[SettleRead] {
459        &self.settle_reads
460    }
461}
462
463/// Stable spelling for callers that prefer to name the operation explicitly.
464pub type FeedRangeOutcome = FeedOutcome;
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467struct SliceFingerprint {
468    crc32: u32,
469    md5: [u8; 16],
470}
471
472impl SliceFingerprint {
473    fn from_data(data: &[u8], slice_size: u64) -> Self {
474        let mut state = SliceChecksumState::new();
475        state.update(data);
476        let pad_to = ((data.len() as u64) < slice_size).then_some(slice_size);
477        let (crc32, md5) = state.finalize(pad_to);
478        Self { crc32, md5 }
479    }
480
481    fn is_valid_for(self, expected: &SliceChecksum) -> bool {
482        self.crc32 == expected.crc32 && self.md5 == expected.md5
483    }
484}
485
486/// Sparse byte storage for a slice which arrived in more than one range.
487struct PartialSlice {
488    expected_len: u64,
489    ranges: BTreeMap<u64, Vec<u8>>,
490    buffered_bytes: usize,
491    reserved_bytes: usize,
492}
493
494impl PartialSlice {
495    fn new(expected_len: u64) -> Self {
496        Self {
497            expected_len,
498            ranges: BTreeMap::new(),
499            buffered_bytes: 0,
500            reserved_bytes: 0,
501        }
502    }
503
504    fn matches(&self, start: u64, data: &[u8]) -> bool {
505        let end = start + data.len() as u64;
506        self.ranges.range(..end).all(|(&range_start, existing)| {
507            let range_end = range_start + existing.len() as u64;
508            let overlap_start = range_start.max(start);
509            let overlap_end = range_end.min(end);
510            overlap_start >= overlap_end
511                || existing
512                    [(overlap_start - range_start) as usize..(overlap_end - range_start) as usize]
513                    == data[(overlap_start - start) as usize..(overlap_end - start) as usize]
514        })
515    }
516
517    fn uncovered_ranges(&self, start: u64, end: u64) -> Vec<(u64, u64)> {
518        let mut cursor = start;
519        let mut uncovered = Vec::new();
520
521        for (&range_start, existing) in self.ranges.range(..end) {
522            let range_end = range_start + existing.len() as u64;
523            if range_end <= cursor {
524                continue;
525            }
526            if range_start > cursor {
527                let gap_end = range_start.min(end);
528                uncovered.push((cursor, gap_end));
529                cursor = gap_end;
530            }
531            cursor = cursor.max(range_end.min(end));
532            if cursor == end {
533                break;
534            }
535        }
536
537        if cursor < end {
538            uncovered.push((cursor, end));
539        }
540        uncovered
541    }
542
543    /// Insert only previously unseen bytes. Returns the new byte count, or
544    /// `None` when the shared budget cannot reserve the requested storage.
545    fn insert(
546        &mut self,
547        start: u64,
548        data: &[u8],
549        budget: &VerificationMemoryBudget,
550    ) -> Option<usize> {
551        let end = start + data.len() as u64;
552        let uncovered = self.uncovered_ranges(start, end);
553        if self.ranges.len().saturating_add(uncovered.len()) > MAX_PARTIAL_RANGES_PER_SLICE {
554            return None;
555        }
556        let added = uncovered
557            .iter()
558            .map(|(from, to)| usize::try_from(to - from).unwrap_or(usize::MAX))
559            .sum::<usize>();
560        let reservation = added.saturating_add(
561            uncovered
562                .len()
563                .saturating_mul(PARTIAL_RANGE_ACCOUNTING_BYTES),
564        );
565
566        if !budget.try_reserve(reservation) {
567            return None;
568        }
569
570        for (from, to) in uncovered {
571            self.ranges.insert(
572                from,
573                data[(from - start) as usize..(to - start) as usize].to_vec(),
574            );
575        }
576        self.buffered_bytes += added;
577        self.reserved_bytes = self.reserved_bytes.saturating_add(reservation);
578        Some(added)
579    }
580
581    fn is_complete(&self) -> bool {
582        self.buffered_bytes as u64 == self.expected_len
583    }
584
585    fn fingerprint(&self, slice_size: u64) -> Option<SliceFingerprint> {
586        if !self.is_complete() {
587            return None;
588        }
589
590        let mut state = SliceChecksumState::new();
591        let mut offset = 0;
592        for (&range_start, data) in &self.ranges {
593            if range_start != offset {
594                return None;
595            }
596            state.update(data);
597            offset += data.len() as u64;
598        }
599        if offset != self.expected_len {
600            return None;
601        }
602
603        let pad_to = (self.expected_len < slice_size).then_some(slice_size);
604        let (crc32, md5) = state.finalize(pad_to);
605        Some(SliceFingerprint { crc32, md5 })
606    }
607
608    fn settle_reads(
609        &self,
610        file_id: FileId,
611        slice_index: usize,
612        slice_offset: u64,
613    ) -> Vec<SettleRead> {
614        self.uncovered_ranges(0, self.expected_len)
615            .into_iter()
616            .map(|(start, end)| {
617                SettleRead::new(file_id, slice_index, slice_offset + start, end - start)
618            })
619            .collect()
620    }
621}
622
623/// Per-file verification state tracking.
624struct FileVerificationState {
625    /// Boundary slices kept only until their sparse ranges cover the slice.
626    partial_slices: HashMap<usize, PartialSlice>,
627    /// Per-slice verification result. `None` means not yet finalized,
628    /// `Some(true)` = valid, `Some(false)` = damaged.
629    verified_slices: Vec<Option<bool>>,
630    /// Checksum fingerprints retained after immediate hashing so an identical
631    /// full-slice retry is cheap and a conflicting full-slice retry is explicit.
632    fingerprints: Vec<Option<SliceFingerprint>>,
633    /// Total unique bytes accepted into still-pending or finalized slices.
634    bytes_received: u64,
635    /// Expected file length from the PAR2 file description.
636    file_length: u64,
637    /// PAR2 slice size.
638    slice_size: u64,
639}
640
641impl FileVerificationState {
642    fn new(file_length: u64, slice_size: u64) -> Self {
643        let num_slices = if file_length == 0 || slice_size == 0 {
644            0
645        } else {
646            file_length.div_ceil(slice_size) as usize
647        };
648
649        Self {
650            partial_slices: HashMap::new(),
651            verified_slices: vec![None; num_slices],
652            fingerprints: vec![None; num_slices],
653            bytes_received: 0,
654            file_length,
655            slice_size,
656        }
657    }
658
659    fn slice_offset(&self, slice_index: usize) -> Option<u64> {
660        (slice_index as u64).checked_mul(self.slice_size)
661    }
662
663    fn slice_len(&self, slice_index: usize) -> Option<u64> {
664        let offset = self.slice_offset(slice_index)?;
665        (offset < self.file_length).then(|| (self.file_length - offset).min(self.slice_size))
666    }
667
668    fn evidence(
669        &self,
670        recovery_set_id: RecoverySetId,
671        file_id: FileId,
672        slice_index: usize,
673        valid: bool,
674    ) -> SliceEvidence {
675        SliceEvidence {
676            recovery_set_id,
677            file_id,
678            slice_index: u32::try_from(slice_index).unwrap_or(u32::MAX),
679            valid,
680            strength: if self
681                .fingerprints
682                .get(slice_index)
683                .is_some_and(Option::is_some)
684            {
685                SliceEvidenceStrength::Crc32AndMd5
686            } else {
687                SliceEvidenceStrength::Crc32Only
688            },
689            // A session hashed these bytes itself; there is no external claim
690            // to record, and its own CRC32-only verdicts stay inadmissible.
691            in_stream: None,
692        }
693    }
694
695    fn full_slice_read(&self, file_id: FileId, slice_index: usize) -> SettleRead {
696        SettleRead::new(
697            file_id,
698            slice_index,
699            self.slice_offset(slice_index).unwrap_or(self.file_length),
700            self.slice_len(slice_index).unwrap_or(0),
701        )
702    }
703
704    fn discard_partial(&mut self, slice_index: usize, budget: &VerificationMemoryBudget) -> usize {
705        self.partial_slices
706            .remove(&slice_index)
707            .map(|partial| {
708                budget.release(partial.reserved_bytes);
709                partial.buffered_bytes
710            })
711            .unwrap_or(0)
712    }
713
714    /// Count how many slices have been verified as valid.
715    fn verified_count(&self) -> usize {
716        self.verified_slices
717            .iter()
718            .filter(|v| **v == Some(true))
719            .count()
720    }
721
722    /// Count how many slices have been verified as damaged.
723    fn damaged_count(&self) -> usize {
724        self.verified_slices
725            .iter()
726            .filter(|v| **v == Some(false))
727            .count()
728    }
729
730    /// Count how many slices are still pending (not yet finalized).
731    fn pending_count(&self) -> usize {
732        self.verified_slices.iter().filter(|v| v.is_none()).count()
733    }
734
735    /// Total number of slices.
736    fn total_slices(&self) -> usize {
737        self.verified_slices.len()
738    }
739}
740
741enum SliceFeed {
742    Verified(SliceEvidence),
743    Buffered(Vec<SettleRead>),
744    Duplicate(Vec<SettleRead>),
745    BudgetExhausted(SettleRead),
746    Conflict(SettleRead),
747    NeedsSettleRead(SettleRead),
748}
749
750/// Streaming verification session that tracks PAR2 verification state as data
751/// arrives during download.
752///
753/// Usage:
754/// 1. Call [`Self::add_par2_data`] when PAR2 metadata packets arrive.
755/// 2. Call [`Self::feed_data`] as decoded file data arrives (slice-aligned).
756/// 3. Query [`Self::file_status`], [`Self::repairability`], or
757///    [`Self::is_complete`] at any time.
758/// 4. Call [`Self::verification_result`] once all data has been fed.
759pub struct VerificationSession {
760    par2_set: Option<Arc<Par2FileSet>>,
761    file_states: HashMap<FileId, FileVerificationState>,
762    /// Packets received before the PAR2 set was complete. These are buffered
763    /// so that `add_par2_data` can be called incrementally.
764    buffered_packets: Vec<Packet>,
765    memory_budget: VerificationMemoryBudget,
766}
767
768impl VerificationSession {
769    /// Create a new empty verification session.
770    pub fn new() -> Self {
771        Self::with_options(VerificationSessionOptions::default())
772    }
773
774    /// Create a session using the supplied buffering policy.
775    pub fn with_options(options: VerificationSessionOptions) -> Self {
776        Self {
777            par2_set: None,
778            file_states: HashMap::new(),
779            buffered_packets: Vec::new(),
780            memory_budget: options.memory_budget,
781        }
782    }
783
784    /// Create a session using a caller-shareable boundary-buffer budget.
785    pub fn with_memory_budget(memory_budget: VerificationMemoryBudget) -> Self {
786        Self::with_options(VerificationSessionOptions::new().with_memory_budget(memory_budget))
787    }
788
789    /// The budget used for incomplete slice data in this session.
790    pub fn memory_budget(&self) -> &VerificationMemoryBudget {
791        &self.memory_budget
792    }
793
794    fn initialize_file_states(&mut self) {
795        let Some(par2_set) = self.par2_set.as_ref() else {
796            return;
797        };
798
799        for file_id in &par2_set.recovery_file_ids {
800            if !self.file_states.contains_key(file_id)
801                && let Some(desc) = par2_set.file_description(file_id)
802            {
803                self.file_states.insert(
804                    *file_id,
805                    FileVerificationState::new(desc.length, par2_set.slice_size),
806                );
807            }
808        }
809    }
810
811    /// Called when PAR2 metadata arrives. May be called multiple times as
812    /// packets from different .par2 volumes arrive.
813    ///
814    /// Once a valid `Par2FileSet` can be built from the accumulated packets,
815    /// per-file verification state is initialized.
816    pub fn add_par2_data(&mut self, packets: &[Packet]) {
817        if let Some(par2_set) = self.par2_set.as_mut() {
818            // Once a set exists, merge only the new packets. Rebuilding from
819            // `buffered_packets` would clone every earlier recovery payload on
820            // every PAR2 volume arrival.
821            let _ = Arc::make_mut(par2_set).merge_packets(packets.to_vec());
822            self.initialize_file_states();
823            return;
824        }
825
826        self.buffered_packets.extend(packets.iter().cloned());
827
828        // Try to build a Par2FileSet from all accumulated packets.
829        match Par2FileSet::from_packets(self.buffered_packets.clone()) {
830            Ok(set) => {
831                self.par2_set = Some(Arc::new(set));
832                // The packets are now represented by the set. Keep no second
833                // copy, especially because recovery payloads can be large.
834                self.buffered_packets.clear();
835                self.initialize_file_states();
836            }
837            Err(_) => {
838                // Not enough packets yet (e.g., no main packet). Keep buffering.
839            }
840        }
841    }
842
843    /// Feed decoded file data for a specific file.
844    ///
845    /// This compatibility wrapper accepts the historical aligned input and
846    /// intentionally discards the detailed outcome. New callers should use
847    /// [`feed_range`](Self::feed_range) to receive evidence and settle reads.
848    ///
849    /// If PAR2 metadata has not yet arrived, the data is silently ignored.
850    /// (The assembly layer should re-feed data after PAR2 metadata arrives
851    /// if needed, or the caller can handle this at a higher level.)
852    pub fn feed_data(&mut self, file_id: &FileId, offset: u64, data: &[u8]) {
853        let _ = self.feed_range(file_id, offset, data);
854    }
855
856    /// Feed an arbitrary logical byte range for a PAR2 file.
857    ///
858    /// Aligned complete slices are checksummed immediately without retaining
859    /// the slice payload. Only incomplete boundary slices are buffered. The
860    /// returned [`FeedOutcome`] reports newly settled [`SliceEvidence`] and
861    /// precise [`SettleRead`] ranges needed to complete or disambiguate a slice.
862    pub fn feed_range(&mut self, file_id: &FileId, offset: u64, data: &[u8]) -> FeedOutcome {
863        let par2_set = match &self.par2_set {
864            Some(set) => Arc::clone(set),
865            None => return FeedOutcome::new(FeedDisposition::MetadataPending),
866        };
867
868        let Some(desc) = par2_set.file_description(file_id) else {
869            return FeedOutcome::new(FeedDisposition::UnknownFile);
870        };
871        let Some(checksums) = par2_set.file_checksums(file_id) else {
872            return FeedOutcome::new(FeedDisposition::MetadataPending);
873        };
874        if par2_set.slice_size == 0 {
875            return FeedOutcome::new(FeedDisposition::OutOfRange);
876        }
877
878        let range_end = match offset.checked_add(data.len() as u64) {
879            Some(end) if offset <= desc.length && end <= desc.length => end,
880            _ => return FeedOutcome::new(FeedDisposition::OutOfRange),
881        };
882        if data.is_empty() {
883            return FeedOutcome::new(FeedDisposition::Duplicate);
884        }
885
886        // Ensure file state exists. This also supports non-recovery files for
887        // callers that choose to verify their available IFSC metadata.
888        if !self.file_states.contains_key(file_id) {
889            self.file_states.insert(
890                *file_id,
891                FileVerificationState::new(desc.length, par2_set.slice_size),
892            );
893        }
894
895        let first_slice = usize::try_from(offset / par2_set.slice_size)
896            .map_err(|_| ())
897            .ok();
898        let last_slice = usize::try_from((range_end - 1) / par2_set.slice_size)
899            .map_err(|_| ())
900            .ok();
901        let (Some(first_slice), Some(last_slice)) = (first_slice, last_slice) else {
902            return FeedOutcome::new(FeedDisposition::OutOfRange);
903        };
904
905        let state = self
906            .file_states
907            .get_mut(file_id)
908            .expect("state inserted above");
909        let mut outcome = FeedOutcome::new(FeedDisposition::Duplicate);
910        let mut saw_verified = false;
911        let mut saw_buffered = false;
912        let mut saw_budget_exhausted = false;
913        let mut saw_conflict = false;
914        let mut saw_needs_settle_read = false;
915
916        for slice_index in first_slice..=last_slice {
917            let Some(slice_offset) = state.slice_offset(slice_index) else {
918                saw_needs_settle_read = true;
919                continue;
920            };
921            let Some(slice_len) = state.slice_len(slice_index) else {
922                saw_needs_settle_read = true;
923                continue;
924            };
925            let Some(expected) = checksums.get(slice_index) else {
926                return FeedOutcome::new(FeedDisposition::MetadataPending);
927            };
928
929            let piece_start = offset.max(slice_offset);
930            let piece_end = range_end.min(slice_offset + slice_len);
931            let piece = &data[(piece_start - offset) as usize..(piece_end - offset) as usize];
932            let whole_slice = piece_start == slice_offset && piece.len() as u64 == slice_len;
933
934            match Self::feed_slice_range(
935                state,
936                &self.memory_budget,
937                par2_set.recovery_set_id,
938                *file_id,
939                slice_index,
940                piece_start - slice_offset,
941                piece,
942                whole_slice,
943                expected,
944            ) {
945                SliceFeed::Verified(evidence) => {
946                    saw_verified = true;
947                    outcome.evidence.push(evidence);
948                }
949                SliceFeed::Buffered(reads) => {
950                    saw_buffered = true;
951                    Self::append_settle_reads(&mut outcome.settle_reads, reads);
952                }
953                SliceFeed::Duplicate(reads) => {
954                    Self::append_settle_reads(&mut outcome.settle_reads, reads);
955                }
956                SliceFeed::BudgetExhausted(read) => {
957                    saw_budget_exhausted = true;
958                    Self::append_settle_reads(&mut outcome.settle_reads, [read]);
959                }
960                SliceFeed::Conflict(read) => {
961                    saw_conflict = true;
962                    Self::append_settle_reads(&mut outcome.settle_reads, [read]);
963                }
964                SliceFeed::NeedsSettleRead(read) => {
965                    saw_needs_settle_read = true;
966                    Self::append_settle_reads(&mut outcome.settle_reads, [read]);
967                }
968            }
969        }
970
971        outcome.disposition = if saw_conflict {
972            FeedDisposition::ConflictingOverlap
973        } else if saw_budget_exhausted {
974            FeedDisposition::BudgetExhausted
975        } else if saw_needs_settle_read {
976            FeedDisposition::NeedsSettleRead
977        } else if saw_verified {
978            FeedDisposition::Verified
979        } else if saw_buffered {
980            FeedDisposition::Buffered
981        } else {
982            FeedDisposition::Duplicate
983        };
984        outcome
985    }
986
987    /// Alias for callers that use `feed_data` terminology for arbitrary ranges.
988    pub fn feed_data_range(&mut self, file_id: &FileId, offset: u64, data: &[u8]) -> FeedOutcome {
989        self.feed_range(file_id, offset, data)
990    }
991
992    fn append_settle_reads(
993        target: &mut Vec<SettleRead>,
994        reads: impl IntoIterator<Item = SettleRead>,
995    ) {
996        for read in reads {
997            if !target.contains(&read) {
998                target.push(read);
999            }
1000        }
1001    }
1002
1003    #[allow(clippy::too_many_arguments)]
1004    fn feed_slice_range(
1005        state: &mut FileVerificationState,
1006        memory_budget: &VerificationMemoryBudget,
1007        recovery_set_id: RecoverySetId,
1008        file_id: FileId,
1009        slice_index: usize,
1010        slice_data_offset: u64,
1011        data: &[u8],
1012        whole_slice: bool,
1013        expected: &SliceChecksum,
1014    ) -> SliceFeed {
1015        let full_read = state.full_slice_read(file_id, slice_index);
1016
1017        if let Some(fingerprint) = state.fingerprints[slice_index] {
1018            if whole_slice {
1019                return if fingerprint == SliceFingerprint::from_data(data, state.slice_size) {
1020                    SliceFeed::Duplicate(Vec::new())
1021                } else {
1022                    SliceFeed::Conflict(full_read.clone())
1023                };
1024            }
1025            return SliceFeed::NeedsSettleRead(full_read.clone());
1026        }
1027        if state.verified_slices[slice_index].is_some() {
1028            // `verify_from_slice_crcs` can settle a CRC-only slice. A partial
1029            // byte retry cannot prove it is the same data because MD5 was not
1030            // retained, so request one trusted full-slice read.
1031            return SliceFeed::NeedsSettleRead(full_read.clone());
1032        }
1033
1034        let slice_len = state.slice_len(slice_index).unwrap_or(0);
1035        if whole_slice {
1036            let partial_bytes = match state.partial_slices.get(&slice_index) {
1037                Some(partial) if !partial.matches(0, data) => {
1038                    state.discard_partial(slice_index, memory_budget);
1039                    return SliceFeed::Conflict(full_read.clone());
1040                }
1041                Some(partial) => partial.buffered_bytes as u64,
1042                None => 0,
1043            };
1044            if partial_bytes != 0 {
1045                state.discard_partial(slice_index, memory_budget);
1046            }
1047
1048            let fingerprint = SliceFingerprint::from_data(data, state.slice_size);
1049            let valid = fingerprint.is_valid_for(expected);
1050            state.verified_slices[slice_index] = Some(valid);
1051            state.fingerprints[slice_index] = Some(fingerprint);
1052            state.bytes_received += slice_len.saturating_sub(partial_bytes);
1053            return SliceFeed::Verified(state.evidence(
1054                recovery_set_id,
1055                file_id,
1056                slice_index,
1057                valid,
1058            ));
1059        }
1060
1061        if state
1062            .partial_slices
1063            .get(&slice_index)
1064            .is_some_and(|partial| !partial.matches(slice_data_offset, data))
1065        {
1066            state.discard_partial(slice_index, memory_budget);
1067            return SliceFeed::Conflict(full_read.clone());
1068        }
1069
1070        let had_partial = state.partial_slices.contains_key(&slice_index);
1071        let inserted = state
1072            .partial_slices
1073            .entry(slice_index)
1074            .or_insert_with(|| PartialSlice::new(slice_len))
1075            .insert(slice_data_offset, data, memory_budget);
1076        let Some(inserted) = inserted else {
1077            if !had_partial {
1078                state.partial_slices.remove(&slice_index);
1079            }
1080            return SliceFeed::BudgetExhausted(full_read.clone());
1081        };
1082        state.bytes_received += inserted as u64;
1083
1084        let complete = state
1085            .partial_slices
1086            .get(&slice_index)
1087            .is_some_and(PartialSlice::is_complete);
1088        if complete {
1089            let partial = state
1090                .partial_slices
1091                .remove(&slice_index)
1092                .expect("complete partial slice exists");
1093            let fingerprint = partial
1094                .fingerprint(state.slice_size)
1095                .expect("complete partial slice covers every byte");
1096            memory_budget.release(partial.reserved_bytes);
1097            let valid = fingerprint.is_valid_for(expected);
1098            state.verified_slices[slice_index] = Some(valid);
1099            state.fingerprints[slice_index] = Some(fingerprint);
1100            return SliceFeed::Verified(state.evidence(
1101                recovery_set_id,
1102                file_id,
1103                slice_index,
1104                valid,
1105            ));
1106        }
1107
1108        let reads = state
1109            .partial_slices
1110            .get(&slice_index)
1111            .expect("partial slice remains when incomplete")
1112            .settle_reads(
1113                file_id,
1114                slice_index,
1115                state.slice_offset(slice_index).unwrap_or(state.file_length),
1116            );
1117        if inserted == 0 {
1118            SliceFeed::Duplicate(reads)
1119        } else {
1120            SliceFeed::Buffered(reads)
1121        }
1122    }
1123
1124    /// Query the current status of a specific file.
1125    pub fn file_status(&self, file_id: &FileId) -> Option<FileStatus> {
1126        let state = self.file_states.get(file_id)?;
1127
1128        if state.total_slices() == 0 {
1129            return Some(FileStatus::Complete);
1130        }
1131
1132        // If all slices are verified as valid, the file is complete.
1133        if state.verified_count() == state.total_slices() {
1134            return Some(FileStatus::Complete);
1135        }
1136
1137        let damaged = state.damaged_count() as u32;
1138        if damaged > 0 {
1139            return Some(FileStatus::Damaged(damaged));
1140        }
1141
1142        // Still pending -- report as complete only if everything known is valid.
1143        if state.pending_count() == state.total_slices() {
1144            // No data received yet.
1145            return Some(FileStatus::Missing);
1146        }
1147
1148        // Partially verified, no damage found yet.
1149        // Report damaged with 0 to indicate "in progress" -- but the pending
1150        // slices could still be bad. Return Missing for files with no verified
1151        // slices, or Damaged(0) for partially verified.
1152        if state.bytes_received == 0 {
1153            Some(FileStatus::Missing)
1154        } else {
1155            Some(FileStatus::Damaged(0))
1156        }
1157    }
1158
1159    /// Estimate repairability given the current state.
1160    ///
1161    /// This accounts for both verified-damaged slices and pending (unverified)
1162    /// slices, treating pending slices optimistically (assuming they will pass).
1163    pub fn repairability(&self) -> Repairability {
1164        let par2_set = match &self.par2_set {
1165            Some(set) => set,
1166            None => return Repairability::NotNeeded,
1167        };
1168
1169        let mut total_damaged: u32 = 0;
1170        let mut total_missing: u32 = 0;
1171
1172        for file_id in &par2_set.recovery_file_ids {
1173            if let Some(state) = self.file_states.get(file_id) {
1174                total_damaged += state.damaged_count() as u32;
1175                // Files that haven't received any data are considered missing.
1176                if state.bytes_received == 0 {
1177                    total_missing += state.total_slices() as u32;
1178                }
1179            } else {
1180                // File state not initialized yet; treat as missing.
1181                if let Some(desc) = par2_set.file_description(file_id) {
1182                    total_missing += par2_set.slice_count_for_file(desc.length);
1183                }
1184            }
1185        }
1186
1187        let blocks_needed = total_damaged + total_missing;
1188        let blocks_available = par2_set.recovery_block_count();
1189
1190        if blocks_needed == 0 {
1191            Repairability::NotNeeded
1192        } else if blocks_needed <= blocks_available {
1193            Repairability::Repairable {
1194                blocks_needed,
1195                blocks_available,
1196            }
1197        } else {
1198            Repairability::Insufficient {
1199                blocks_needed,
1200                blocks_available,
1201                deficit: blocks_needed - blocks_available,
1202            }
1203        }
1204    }
1205
1206    /// Check if all files have been verified successfully.
1207    pub fn is_complete(&self) -> bool {
1208        let par2_set = match &self.par2_set {
1209            Some(set) => set,
1210            None => return false,
1211        };
1212
1213        for file_id in &par2_set.recovery_file_ids {
1214            match self.file_states.get(file_id) {
1215                Some(state) => {
1216                    if state.verified_count() != state.total_slices() {
1217                        return false;
1218                    }
1219                }
1220                None => return false,
1221            }
1222        }
1223
1224        true
1225    }
1226
1227    /// Produce a full [`VerificationResult`] from the current session state.
1228    ///
1229    /// This can be passed to [`plan_repair`](crate::repair::plan_repair) if
1230    /// repair is needed. Returns `None` if PAR2 metadata has not been loaded.
1231    pub fn verification_result(&self) -> Option<VerificationResult> {
1232        let par2_set = self.par2_set.as_ref()?;
1233
1234        let mut files = Vec::new();
1235        let mut total_missing_blocks = 0u32;
1236
1237        for file_id in &par2_set.recovery_file_ids {
1238            let desc = match par2_set.file_description(file_id) {
1239                Some(d) => d,
1240                None => continue,
1241            };
1242
1243            let state = match self.file_states.get(file_id) {
1244                Some(s) => s,
1245                None => {
1246                    // No state at all: file is missing.
1247                    let slice_count = par2_set.slice_count_for_file(desc.length);
1248                    total_missing_blocks += slice_count;
1249                    files.push(FileVerification {
1250                        file_id: *file_id,
1251                        filename: desc.filename.clone(),
1252                        status: FileStatus::Missing,
1253                        valid_slices: vec![false; slice_count as usize],
1254                        missing_slice_count: slice_count,
1255                    });
1256                    continue;
1257                }
1258            };
1259
1260            let valid_slices: Vec<bool> = state
1261                .verified_slices
1262                .iter()
1263                .map(|v| v.unwrap_or(false))
1264                .collect();
1265            let missing_count = valid_slices.iter().filter(|&&v| !v).count() as u32;
1266            total_missing_blocks += missing_count;
1267
1268            let status = if missing_count == 0 {
1269                FileStatus::Complete
1270            } else if state.bytes_received == 0 {
1271                FileStatus::Missing
1272            } else {
1273                FileStatus::Damaged(missing_count)
1274            };
1275
1276            files.push(FileVerification {
1277                file_id: *file_id,
1278                filename: desc.filename.clone(),
1279                status,
1280                valid_slices,
1281                missing_slice_count: missing_count,
1282            });
1283        }
1284
1285        let recovery_blocks_available = par2_set.recovery_block_count();
1286        let repairable = if total_missing_blocks == 0 {
1287            Repairability::NotNeeded
1288        } else if total_missing_blocks <= recovery_blocks_available {
1289            Repairability::Repairable {
1290                blocks_needed: total_missing_blocks,
1291                blocks_available: recovery_blocks_available,
1292            }
1293        } else {
1294            Repairability::Insufficient {
1295                blocks_needed: total_missing_blocks,
1296                blocks_available: recovery_blocks_available,
1297                deficit: total_missing_blocks - recovery_blocks_available,
1298            }
1299        };
1300
1301        Some(VerificationResult {
1302            files,
1303            recovery_blocks_available,
1304            total_missing_blocks,
1305            repairable,
1306        })
1307    }
1308
1309    /// Verify a file's slices using pre-computed CRC32 values from download.
1310    ///
1311    /// Each entry in `slice_crcs` must be the CRC32 of the (possibly zero-padded)
1312    /// slice data. This is a CRC-only check (no MD5); it avoids re-reading files
1313    /// from disk when the download layer already computed per-slice CRCs.
1314    ///
1315    /// Returns per-slice validity, or `None` if PAR2 metadata isn't loaded or
1316    /// the file is unknown.
1317    pub fn verify_from_slice_crcs(
1318        &mut self,
1319        file_id: &FileId,
1320        slice_crcs: &[u32],
1321    ) -> Option<Vec<bool>> {
1322        let par2_set = self.par2_set.as_ref()?;
1323        let checksums = par2_set.file_checksums(file_id)?;
1324
1325        // Ensure file state exists.
1326        if !self.file_states.contains_key(file_id) {
1327            let desc = par2_set.file_description(file_id)?;
1328            self.file_states.insert(
1329                *file_id,
1330                FileVerificationState::new(desc.length, par2_set.slice_size),
1331            );
1332        }
1333
1334        let state = self.file_states.get_mut(file_id)?;
1335        let mut results = Vec::with_capacity(checksums.len());
1336        for (i, expected) in checksums.iter().enumerate() {
1337            let valid = slice_crcs
1338                .get(i)
1339                .map(|&crc| crc == expected.crc32)
1340                .unwrap_or(false);
1341            // Update the verified_slices state (only if not already verified).
1342            if state.verified_slices[i].is_none() {
1343                state.discard_partial(i, &self.memory_budget);
1344                state.verified_slices[i] = Some(valid);
1345                if valid {
1346                    // Mark bytes as received so file isn't treated as "missing".
1347                    state.bytes_received = state.bytes_received.max(1);
1348                }
1349            }
1350            results.push(valid);
1351        }
1352
1353        Some(results)
1354    }
1355
1356    /// Return every settled slice verdict held by this session.
1357    ///
1358    /// The result is deterministic by file ID and slice index, and contains
1359    /// only PAR2 coordinates plus validity so it can be handed to a later
1360    /// repair stage without coupling it to filesystem paths.
1361    pub fn slice_evidence(&self) -> Vec<SliceEvidence> {
1362        let Some(recovery_set_id) = self.par2_set.as_ref().map(|set| set.recovery_set_id) else {
1363            return Vec::new();
1364        };
1365        let mut evidence = self
1366            .file_states
1367            .iter()
1368            .flat_map(|(file_id, state)| {
1369                state
1370                    .verified_slices
1371                    .iter()
1372                    .enumerate()
1373                    .filter_map(|(slice_index, valid)| {
1374                        valid.map(|valid| {
1375                            state.evidence(recovery_set_id, *file_id, slice_index, valid)
1376                        })
1377                    })
1378            })
1379            .collect::<Vec<_>>();
1380        evidence.sort_unstable_by(|left, right| {
1381            left.file_id()
1382                .as_bytes()
1383                .cmp(right.file_id().as_bytes())
1384                .then_with(|| left.slice_index().cmp(&right.slice_index()))
1385        });
1386        evidence
1387    }
1388
1389    /// Get a reference to the underlying PAR2 file set, if loaded.
1390    pub fn par2_set(&self) -> Option<&Arc<Par2FileSet>> {
1391        self.par2_set.as_ref()
1392    }
1393}
1394
1395impl Default for VerificationSession {
1396    fn default() -> Self {
1397        Self::new()
1398    }
1399}
1400
1401impl Drop for VerificationSession {
1402    fn drop(&mut self) {
1403        for state in self.file_states.values() {
1404            for partial in state.partial_slices.values() {
1405                self.memory_budget.release(partial.reserved_bytes);
1406            }
1407        }
1408    }
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414    use crate::checksum;
1415    use crate::packet::header;
1416    use crate::par2_set::RecoverySlice;
1417    use crate::types::SliceChecksum;
1418    use bytes::Bytes;
1419    use md5::{Digest, Md5};
1420
1421    /// Helper to build a complete valid packet (header + body).
1422    fn make_full_packet(packet_type: &[u8; 16], body: &[u8], recovery_set_id: [u8; 16]) -> Vec<u8> {
1423        let length = (header::HEADER_SIZE + body.len()) as u64;
1424        let mut hash_input = Vec::new();
1425        hash_input.extend_from_slice(&recovery_set_id);
1426        hash_input.extend_from_slice(packet_type);
1427        hash_input.extend_from_slice(body);
1428        let packet_hash: [u8; 16] = Md5::digest(&hash_input).into();
1429
1430        let mut data = Vec::new();
1431        data.extend_from_slice(header::MAGIC);
1432        data.extend_from_slice(&length.to_le_bytes());
1433        data.extend_from_slice(&packet_hash);
1434        data.extend_from_slice(&recovery_set_id);
1435        data.extend_from_slice(packet_type);
1436        data.extend_from_slice(body);
1437        data
1438    }
1439
1440    /// Build PAR2 packets for a single file. Returns (packets_bytes, file_id, rsid).
1441    fn build_par2_packets(file_data: &[u8], slice_size: u64) -> (Vec<u8>, FileId, [u8; 16]) {
1442        let file_length = file_data.len() as u64;
1443        let hash_full = checksum::md5(file_data);
1444        let hash_16k_data = &file_data[..file_data.len().min(16384)];
1445        let hash_16k = checksum::md5(hash_16k_data);
1446
1447        let filename = b"testfile.dat";
1448        let mut id_input = Vec::new();
1449        id_input.extend_from_slice(&hash_16k);
1450        id_input.extend_from_slice(&file_length.to_le_bytes());
1451        id_input.extend_from_slice(filename);
1452        let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
1453        let file_id = FileId::from_bytes(file_id_bytes);
1454
1455        let num_slices = if file_length == 0 {
1456            0
1457        } else {
1458            file_length.div_ceil(slice_size) as usize
1459        };
1460
1461        let mut checksums = Vec::new();
1462        for i in 0..num_slices {
1463            let offset = i as u64 * slice_size;
1464            let end = ((offset + slice_size) as usize).min(file_data.len());
1465            let slice_data = &file_data[offset as usize..end];
1466            let mut state = SliceChecksumState::new();
1467            state.update(slice_data);
1468            let pad_to = if (slice_data.len() as u64) < slice_size {
1469                Some(slice_size)
1470            } else {
1471                None
1472            };
1473            let (crc, md5) = state.finalize(pad_to);
1474            checksums.push(SliceChecksum { crc32: crc, md5 });
1475        }
1476
1477        let mut main_body = Vec::new();
1478        main_body.extend_from_slice(&slice_size.to_le_bytes());
1479        main_body.extend_from_slice(&1u32.to_le_bytes());
1480        main_body.extend_from_slice(&file_id_bytes);
1481        let rsid: [u8; 16] = Md5::digest(&main_body).into();
1482
1483        let mut fd_body = Vec::new();
1484        fd_body.extend_from_slice(&file_id_bytes);
1485        fd_body.extend_from_slice(&hash_full);
1486        fd_body.extend_from_slice(&hash_16k);
1487        fd_body.extend_from_slice(&file_length.to_le_bytes());
1488        fd_body.extend_from_slice(filename);
1489        while fd_body.len() % 4 != 0 {
1490            fd_body.push(0);
1491        }
1492
1493        let mut ifsc_body = Vec::new();
1494        ifsc_body.extend_from_slice(&file_id_bytes);
1495        for cs in &checksums {
1496            ifsc_body.extend_from_slice(&cs.md5);
1497            ifsc_body.extend_from_slice(&cs.crc32.to_le_bytes());
1498        }
1499
1500        let mut stream = Vec::new();
1501        stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
1502        stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
1503        stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, &ifsc_body, rsid));
1504
1505        (stream, file_id, rsid)
1506    }
1507
1508    fn parse_packets(data: &[u8]) -> Vec<Packet> {
1509        crate::packet::scan_packets(data, 0)
1510            .unwrap()
1511            .into_iter()
1512            .map(|(p, _)| p)
1513            .collect()
1514    }
1515
1516    #[test]
1517    fn session_feed_correct_data_all_pass() {
1518        let slice_size = 1024u64;
1519        let file_data: Vec<u8> = (0..2048u32).map(|i| (i % 256) as u8).collect();
1520        let (par2_bytes, file_id, _rsid) = build_par2_packets(&file_data, slice_size);
1521
1522        let mut session = VerificationSession::new();
1523
1524        // Add PAR2 metadata first.
1525        let packets = parse_packets(&par2_bytes);
1526        session.add_par2_data(&packets);
1527        assert!(session.par2_set().is_some());
1528
1529        // Feed data slice by slice.
1530        session.feed_data(&file_id, 0, &file_data[0..1024]);
1531        session.feed_data(&file_id, 1024, &file_data[1024..2048]);
1532
1533        // All slices should pass.
1534        assert!(session.is_complete());
1535        assert!(matches!(
1536            session.file_status(&file_id),
1537            Some(FileStatus::Complete)
1538        ));
1539        assert!(matches!(session.repairability(), Repairability::NotNeeded));
1540
1541        // verification_result should show all valid.
1542        let result = session.verification_result().unwrap();
1543        assert_eq!(result.total_missing_blocks, 0);
1544        assert!(result.files[0].valid_slices.iter().all(|&v| v));
1545    }
1546
1547    #[test]
1548    fn session_detects_corrupted_slice() {
1549        let slice_size = 1024u64;
1550        let file_data: Vec<u8> = (0..2048u32).map(|i| (i % 256) as u8).collect();
1551        let (par2_bytes, file_id, _rsid) = build_par2_packets(&file_data, slice_size);
1552
1553        let mut session = VerificationSession::new();
1554        let packets = parse_packets(&par2_bytes);
1555        session.add_par2_data(&packets);
1556
1557        // Feed correct first slice.
1558        session.feed_data(&file_id, 0, &file_data[0..1024]);
1559
1560        // Feed corrupted second slice.
1561        let mut corrupted = file_data[1024..2048].to_vec();
1562        corrupted[0] ^= 0xFF;
1563        session.feed_data(&file_id, 1024, &corrupted);
1564
1565        // Should not be complete.
1566        assert!(!session.is_complete());
1567
1568        // File should be damaged.
1569        assert!(matches!(
1570            session.file_status(&file_id),
1571            Some(FileStatus::Damaged(1))
1572        ));
1573
1574        let result = session.verification_result().unwrap();
1575        assert_eq!(result.total_missing_blocks, 1);
1576        assert!(result.files[0].valid_slices[0]); // first slice valid
1577        assert!(!result.files[0].valid_slices[1]); // second slice damaged
1578    }
1579
1580    #[test]
1581    fn session_handles_data_before_metadata() {
1582        let slice_size = 1024u64;
1583        let file_data: Vec<u8> = (0..2048u32).map(|i| (i % 256) as u8).collect();
1584        let (par2_bytes, file_id, _rsid) = build_par2_packets(&file_data, slice_size);
1585
1586        let mut session = VerificationSession::new();
1587
1588        // Feed data before PAR2 metadata -- should be silently ignored.
1589        session.feed_data(&file_id, 0, &file_data[0..1024]);
1590        session.feed_data(&file_id, 1024, &file_data[1024..2048]);
1591
1592        // No PAR2 set yet.
1593        assert!(session.par2_set().is_none());
1594        assert!(!session.is_complete());
1595        assert!(session.verification_result().is_none());
1596
1597        // Now add metadata.
1598        let packets = parse_packets(&par2_bytes);
1599        session.add_par2_data(&packets);
1600
1601        // Data was ignored, so nothing is verified yet.
1602        assert!(!session.is_complete());
1603
1604        // Re-feed data after metadata.
1605        session.feed_data(&file_id, 0, &file_data[0..1024]);
1606        session.feed_data(&file_id, 1024, &file_data[1024..2048]);
1607
1608        assert!(session.is_complete());
1609    }
1610
1611    #[test]
1612    fn session_repairability_mid_stream() {
1613        let slice_size = 1024u64;
1614        let file_data: Vec<u8> = (0..4096u32).map(|i| (i % 256) as u8).collect();
1615        let (par2_bytes, file_id, _rsid) = build_par2_packets(&file_data, slice_size);
1616
1617        let mut session = VerificationSession::new();
1618        let packets = parse_packets(&par2_bytes);
1619        session.add_par2_data(&packets);
1620
1621        // Before any data: all 4 slices are "missing" (no bytes received).
1622        match session.repairability() {
1623            Repairability::Insufficient {
1624                blocks_needed: 4, ..
1625            } => {}
1626            other => panic!("expected Insufficient with 4 blocks needed, got {other:?}"),
1627        }
1628
1629        // Feed first slice correctly.
1630        session.feed_data(&file_id, 0, &file_data[0..1024]);
1631
1632        // Now 3 slices haven't received data = missing.
1633        // But the repairability only counts files with 0 bytes as "missing".
1634        // After feeding 1 slice, bytes_received > 0, so no longer "missing" --
1635        // only actually damaged slices count.
1636
1637        // Feed corrupted second slice.
1638        let mut corrupted = file_data[1024..2048].to_vec();
1639        corrupted[0] ^= 0xFF;
1640        session.feed_data(&file_id, 1024, &corrupted);
1641
1642        // 1 damaged, file has data so not counted as fully missing.
1643        match session.repairability() {
1644            Repairability::Insufficient {
1645                blocks_needed: 1,
1646                blocks_available: 0,
1647                ..
1648            } => {}
1649            other => panic!("expected Insufficient with 1 block needed, got {other:?}"),
1650        }
1651    }
1652
1653    #[test]
1654    fn session_integration_with_repair_plan() {
1655        use crate::gf;
1656        use crate::repair::plan_repair;
1657
1658        let slice_size = 64u64;
1659        let file_data: Vec<u8> = (0..256u32).map(|i| (i % 256) as u8).collect();
1660        let (par2_bytes, file_id, _rsid) = build_par2_packets(&file_data, slice_size);
1661
1662        let mut session = VerificationSession::new();
1663        let packets = parse_packets(&par2_bytes);
1664        session.add_par2_data(&packets);
1665
1666        // Add recovery blocks to the par2_set.
1667        {
1668            let set = Arc::make_mut(session.par2_set.as_mut().unwrap());
1669            let num_slices = 4; // 256 / 64
1670            let constants = gf::input_slice_constants(num_slices);
1671            let ss = slice_size as usize;
1672            let word_count = ss / 2;
1673
1674            let mut padded = file_data.clone();
1675            padded.resize(num_slices * ss, 0);
1676
1677            for r in 0..2u32 {
1678                let mut recovery = vec![0u8; ss];
1679                for (i, &constant) in constants.iter().enumerate() {
1680                    let factor = gf::pow(constant, r);
1681                    for w in 0..word_count {
1682                        let input_word = u16::from_le_bytes([
1683                            padded[i * ss + w * 2],
1684                            padded[i * ss + w * 2 + 1],
1685                        ]);
1686                        let contribution = gf::mul(input_word, factor);
1687                        let rec_word = u16::from_le_bytes([recovery[w * 2], recovery[w * 2 + 1]]);
1688                        let new_val = gf::add(rec_word, contribution);
1689                        let bytes = new_val.to_le_bytes();
1690                        recovery[w * 2] = bytes[0];
1691                        recovery[w * 2 + 1] = bytes[1];
1692                    }
1693                }
1694                set.recovery_slices.insert(
1695                    r,
1696                    RecoverySlice {
1697                        exponent: r,
1698                        data: Bytes::from(recovery).into(),
1699                    },
1700                );
1701            }
1702        }
1703
1704        // Feed correct slices 0, 1, 3; corrupt slice 2.
1705        session.feed_data(&file_id, 0, &file_data[0..64]);
1706        session.feed_data(&file_id, 64, &file_data[64..128]);
1707
1708        let mut corrupted = file_data[128..192].to_vec();
1709        corrupted[0] ^= 0xFF;
1710        session.feed_data(&file_id, 128, &corrupted);
1711
1712        session.feed_data(&file_id, 192, &file_data[192..256]);
1713
1714        // Get verification result and pass to plan_repair.
1715        let result = session.verification_result().unwrap();
1716        assert_eq!(result.total_missing_blocks, 1);
1717
1718        let plan = plan_repair(session.par2_set().unwrap(), &result).unwrap();
1719        assert_eq!(plan.missing_slices.len(), 1);
1720        assert_eq!(plan.missing_slices[0], (file_id, 2));
1721    }
1722
1723    #[test]
1724    fn session_partial_last_slice() {
1725        let slice_size = 1024u64;
1726        // File is 1500 bytes -> 2 slices, last is 476 bytes.
1727        let file_data: Vec<u8> = (0..1500u32).map(|i| (i % 256) as u8).collect();
1728        let (par2_bytes, file_id, _rsid) = build_par2_packets(&file_data, slice_size);
1729
1730        let mut session = VerificationSession::new();
1731        let packets = parse_packets(&par2_bytes);
1732        session.add_par2_data(&packets);
1733
1734        session.feed_data(&file_id, 0, &file_data[0..1024]);
1735        session.feed_data(&file_id, 1024, &file_data[1024..1500]);
1736
1737        assert!(session.is_complete());
1738        let result = session.verification_result().unwrap();
1739        assert_eq!(result.total_missing_blocks, 0);
1740    }
1741
1742    #[test]
1743    fn session_verify_from_slice_crcs() {
1744        let slice_size = 1024u64;
1745        let file_data: Vec<u8> = (0..2048u32).map(|i| (i % 256) as u8).collect();
1746        let (par2_bytes, file_id, _rsid) = build_par2_packets(&file_data, slice_size);
1747
1748        let mut session = VerificationSession::new();
1749        let packets = parse_packets(&par2_bytes);
1750        session.add_par2_data(&packets);
1751
1752        // Compute per-slice CRC32s (these would come from yEnc decode in practice).
1753        let crc0 = checksum::crc32(&file_data[0..1024]);
1754        let crc1 = checksum::crc32(&file_data[1024..2048]);
1755
1756        let result = session
1757            .verify_from_slice_crcs(&file_id, &[crc0, crc1])
1758            .unwrap();
1759        assert_eq!(result, vec![true, true]);
1760
1761        // Session should now show file as complete.
1762        assert!(session.is_complete());
1763        assert!(matches!(
1764            session.file_status(&file_id),
1765            Some(FileStatus::Complete)
1766        ));
1767        assert!(
1768            session
1769                .slice_evidence()
1770                .iter()
1771                .all(|evidence| evidence.strength() == SliceEvidenceStrength::Crc32Only)
1772        );
1773    }
1774
1775    #[test]
1776    fn session_verify_from_slice_crcs_partial_damage() {
1777        let slice_size = 1024u64;
1778        let file_data: Vec<u8> = (0..2048u32).map(|i| (i % 256) as u8).collect();
1779        let (par2_bytes, file_id, _rsid) = build_par2_packets(&file_data, slice_size);
1780
1781        let mut session = VerificationSession::new();
1782        let packets = parse_packets(&par2_bytes);
1783        session.add_par2_data(&packets);
1784
1785        let crc0 = checksum::crc32(&file_data[0..1024]);
1786        let wrong_crc = 0xDEADBEEF;
1787
1788        let result = session
1789            .verify_from_slice_crcs(&file_id, &[crc0, wrong_crc])
1790            .unwrap();
1791        assert_eq!(result, vec![true, false]);
1792
1793        assert!(!session.is_complete());
1794        assert!(matches!(
1795            session.file_status(&file_id),
1796            Some(FileStatus::Damaged(1))
1797        ));
1798    }
1799
1800    #[test]
1801    fn session_empty_before_metadata() {
1802        let session = VerificationSession::new();
1803        assert!(!session.is_complete());
1804        assert!(session.verification_result().is_none());
1805        assert!(matches!(session.repairability(), Repairability::NotNeeded));
1806    }
1807
1808    #[test]
1809    fn feed_range_settles_out_of_order_boundaries_without_buffering_full_slices() {
1810        let slice_size = 8u64;
1811        let file_data: Vec<u8> = (0..24u8).collect();
1812        let (par2_bytes, file_id, _) = build_par2_packets(&file_data, slice_size);
1813        let mut session = VerificationSession::new();
1814        session.add_par2_data(&parse_packets(&par2_bytes));
1815
1816        // Feed the tail of slice 1 before its prefix, then settle it. The
1817        // buffered range reports the exact three-byte prefix still needed.
1818        let pending = session.feed_range(&file_id, 11, &file_data[11..16]);
1819        assert_eq!(pending.disposition(), FeedDisposition::Buffered);
1820        assert_eq!(pending.settle_reads().len(), 1);
1821        assert_eq!(pending.settle_reads()[0].offset(), 8);
1822        assert_eq!(pending.settle_reads()[0].length(), 3);
1823
1824        let settled = session.feed_range(&file_id, 8, &file_data[8..11]);
1825        assert_eq!(settled.disposition(), FeedDisposition::Verified);
1826        assert_eq!(settled.evidence().len(), 1);
1827        assert_eq!(settled.evidence()[0].slice_index(), 1);
1828        assert!(settled.evidence()[0].is_valid());
1829
1830        // The two boundary pieces of slice 0 arrive in the opposite order.
1831        assert_eq!(
1832            session
1833                .feed_range(&file_id, 3, &file_data[3..8])
1834                .disposition(),
1835            FeedDisposition::Buffered
1836        );
1837        let settled = session.feed_range(&file_id, 0, &file_data[..3]);
1838        assert_eq!(settled.disposition(), FeedDisposition::Verified);
1839        assert_eq!(settled.evidence()[0].slice_index(), 0);
1840
1841        // Slice 2 is aligned and complete, so it is immediately hashed rather
1842        // than retained in the boundary buffer.
1843        let direct = session.feed_range(&file_id, 16, &file_data[16..24]);
1844        assert_eq!(direct.disposition(), FeedDisposition::Verified);
1845        assert_eq!(direct.evidence()[0].slice_index(), 2);
1846        assert!(session.is_complete());
1847    }
1848
1849    #[test]
1850    fn feed_range_distinguishes_identical_and_conflicting_overlaps() {
1851        let slice_size = 8u64;
1852        let file_data: Vec<u8> = (0..8u8).collect();
1853        let (par2_bytes, file_id, _) = build_par2_packets(&file_data, slice_size);
1854        let reserved = 4 + PARTIAL_RANGE_ACCOUNTING_BYTES;
1855        let budget = VerificationMemoryBudget::new(reserved);
1856        let mut session = VerificationSession::with_memory_budget(budget.clone());
1857        session.add_par2_data(&parse_packets(&par2_bytes));
1858
1859        assert_eq!(
1860            session
1861                .feed_range(&file_id, 0, &file_data[..4])
1862                .disposition(),
1863            FeedDisposition::Buffered
1864        );
1865        assert_eq!(budget.buffered_bytes(), reserved);
1866
1867        // Repeating data already in the sparse buffer neither consumes budget
1868        // again nor creates a competing slice result.
1869        let duplicate = session.feed_range(&file_id, 2, &file_data[2..4]);
1870        assert_eq!(duplicate.disposition(), FeedDisposition::Duplicate);
1871        assert_eq!(budget.buffered_bytes(), reserved);
1872
1873        let mut conflicting = file_data[3..5].to_vec();
1874        conflicting[0] ^= 0xFF;
1875        let conflict = session.feed_range(&file_id, 3, &conflicting);
1876        assert_eq!(conflict.disposition(), FeedDisposition::ConflictingOverlap);
1877        assert_eq!(budget.buffered_bytes(), 0);
1878        assert_eq!(conflict.settle_reads().len(), 1);
1879        assert_eq!(conflict.settle_reads()[0].offset(), 0);
1880        assert_eq!(conflict.settle_reads()[0].length(), slice_size);
1881
1882        let settled = session.feed_range(&file_id, 0, &file_data);
1883        assert_eq!(settled.disposition(), FeedDisposition::Verified);
1884        assert!(settled.evidence()[0].is_valid());
1885    }
1886
1887    #[test]
1888    fn feed_range_reports_metadata_and_out_of_range_input() {
1889        let slice_size = 8u64;
1890        let file_data: Vec<u8> = (0..8u8).collect();
1891        let (par2_bytes, file_id, _) = build_par2_packets(&file_data, slice_size);
1892        let mut session = VerificationSession::new();
1893
1894        assert_eq!(
1895            session.feed_range(&file_id, 0, &file_data).disposition(),
1896            FeedDisposition::MetadataPending
1897        );
1898
1899        session.add_par2_data(&parse_packets(&par2_bytes));
1900        assert_eq!(
1901            session
1902                .feed_range(&file_id, file_data.len() as u64, &[1])
1903                .disposition(),
1904            FeedDisposition::OutOfRange
1905        );
1906        assert_eq!(
1907            session.feed_range(&file_id, u64::MAX, &[1]).disposition(),
1908            FeedDisposition::OutOfRange
1909        );
1910    }
1911
1912    #[test]
1913    fn feed_range_pads_a_partial_last_slice_after_arbitrary_ranges() {
1914        let slice_size = 8u64;
1915        let file_data: Vec<u8> = (0..13u8).collect();
1916        let (par2_bytes, file_id, _) = build_par2_packets(&file_data, slice_size);
1917        let mut session = VerificationSession::new();
1918        session.add_par2_data(&parse_packets(&par2_bytes));
1919
1920        assert_eq!(
1921            session
1922                .feed_range(&file_id, 0, &file_data[..8])
1923                .disposition(),
1924            FeedDisposition::Verified
1925        );
1926        assert_eq!(
1927            session
1928                .feed_range(&file_id, 10, &file_data[10..])
1929                .disposition(),
1930            FeedDisposition::Buffered
1931        );
1932        let last = session.feed_range(&file_id, 8, &file_data[8..10]);
1933        assert_eq!(last.disposition(), FeedDisposition::Verified);
1934        assert_eq!(last.evidence()[0].slice_index(), 1);
1935        assert!(last.evidence()[0].is_valid());
1936        assert!(session.is_complete());
1937    }
1938
1939    #[test]
1940    fn shared_boundary_budget_is_released_on_settlement_and_drop() {
1941        let slice_size = 8u64;
1942        let file_data: Vec<u8> = (0..8u8).collect();
1943        let (par2_bytes, file_id, _) = build_par2_packets(&file_data, slice_size);
1944        let packets = parse_packets(&par2_bytes);
1945        let reserved = 4 + PARTIAL_RANGE_ACCOUNTING_BYTES;
1946        let budget = VerificationMemoryBudget::new(reserved);
1947        let options = VerificationSessionOptions::new().with_memory_budget(budget.clone());
1948        let mut first = VerificationSession::with_options(options.clone());
1949        let mut second = VerificationSession::with_options(options);
1950        first.add_par2_data(&packets);
1951        second.add_par2_data(&packets);
1952
1953        assert_eq!(
1954            first.feed_range(&file_id, 0, &file_data[..4]).disposition(),
1955            FeedDisposition::Buffered
1956        );
1957        assert_eq!(budget.buffered_bytes(), reserved);
1958        assert_eq!(
1959            second
1960                .feed_range(&file_id, 0, &file_data[..4])
1961                .disposition(),
1962            FeedDisposition::BudgetExhausted
1963        );
1964
1965        // An aligned full retry settles first's partial slice directly and
1966        // releases its reservation before hashing.
1967        assert_eq!(
1968            first.feed_range(&file_id, 0, &file_data).disposition(),
1969            FeedDisposition::Verified
1970        );
1971        assert_eq!(budget.buffered_bytes(), 0);
1972        assert_eq!(
1973            second
1974                .feed_range(&file_id, 0, &file_data[..4])
1975                .disposition(),
1976            FeedDisposition::Buffered
1977        );
1978        assert_eq!(budget.buffered_bytes(), reserved);
1979
1980        drop(second);
1981        assert_eq!(budget.buffered_bytes(), 0);
1982    }
1983
1984    #[test]
1985    fn fragmented_range_metadata_counts_toward_shared_budget() {
1986        let slice_size = 8u64;
1987        let file_data: Vec<u8> = (0..8u8).collect();
1988        let (par2_bytes, file_id, _) = build_par2_packets(&file_data, slice_size);
1989        let budget = VerificationMemoryBudget::new(2 + PARTIAL_RANGE_ACCOUNTING_BYTES * 2 - 1);
1990        let mut session = VerificationSession::with_memory_budget(budget.clone());
1991        session.add_par2_data(&parse_packets(&par2_bytes));
1992
1993        assert_eq!(
1994            session
1995                .feed_range(&file_id, 0, &file_data[..1])
1996                .disposition(),
1997            FeedDisposition::Buffered
1998        );
1999        assert_eq!(
2000            session
2001                .feed_range(&file_id, 2, &file_data[2..3])
2002                .disposition(),
2003            FeedDisposition::BudgetExhausted
2004        );
2005        assert_eq!(budget.buffered_bytes(), 1 + PARTIAL_RANGE_ACCOUNTING_BYTES);
2006    }
2007
2008    #[test]
2009    fn add_par2_data_merges_new_packets_without_rebuilding_an_existing_set() {
2010        let slice_size = 8u64;
2011        let file_data: Vec<u8> = (0..8u8).collect();
2012        let (par2_bytes, _file_id, _) = build_par2_packets(&file_data, slice_size);
2013        let packets = parse_packets(&par2_bytes);
2014        let mut session = VerificationSession::new();
2015        session.add_par2_data(&packets);
2016
2017        let before = Arc::as_ptr(session.par2_set.as_ref().unwrap());
2018        // Re-merging an already represented main packet must keep the existing
2019        // set allocation instead of rebuilding every accumulated packet.
2020        session.add_par2_data(&packets[..1]);
2021        assert_eq!(before, Arc::as_ptr(session.par2_set.as_ref().unwrap()));
2022    }
2023
2024    #[test]
2025    fn in_stream_proof_refuses_every_incomplete_attestation() {
2026        let proof = InStreamCrc32Proof::try_new(4096, true, true, true)
2027            .expect("a complete attestation should prove");
2028        assert_eq!(proof.covered_length(), 4096);
2029
2030        assert!(matches!(
2031            InStreamCrc32Proof::try_new(0, true, true, true),
2032            Err(InStreamCrc32ProofError::EmptyCoverage)
2033        ));
2034        assert!(matches!(
2035            InStreamCrc32Proof::try_new(4096, false, true, true),
2036            Err(InStreamCrc32ProofError::IncompleteSliceCoverage)
2037        ));
2038        assert!(matches!(
2039            InStreamCrc32Proof::try_new(4096, true, false, true),
2040            Err(InStreamCrc32ProofError::UnverifiedSourceBytes)
2041        ));
2042        assert!(matches!(
2043            InStreamCrc32Proof::try_new(4096, true, true, false),
2044            Err(InStreamCrc32ProofError::NoIndependentCrc32Coverage)
2045        ));
2046    }
2047
2048    /// The attestation is what admits a verdict, not the hash strength: an
2049    /// in-stream verdict still reports honestly that only a CRC32 was computed.
2050    #[test]
2051    fn in_stream_evidence_reports_crc32_only_strength_and_is_admissible() {
2052        let set_id = RecoverySetId::from_bytes([0x51; 16]);
2053        let file_id = FileId::from_bytes([0x52; 16]);
2054        let proof = InStreamCrc32Proof::try_new(1024, true, true, true).unwrap();
2055        let evidence = SliceEvidence::from_in_stream_crc32(set_id, file_id, 7, true, proof);
2056
2057        assert_eq!(evidence.recovery_set_id(), set_id);
2058        assert_eq!(evidence.file_id(), file_id);
2059        assert_eq!(evidence.slice_index(), 7);
2060        assert!(evidence.is_valid());
2061        assert_eq!(evidence.strength(), SliceEvidenceStrength::Crc32Only);
2062        assert_eq!(evidence.in_stream_proof(), Some(&proof));
2063        assert!(evidence.may_seed_repair_input());
2064
2065        // An invalid verdict is equally well formed: contradiction is a result,
2066        // not a failure to attest.
2067        let damaged = SliceEvidence::from_in_stream_crc32(set_id, file_id, 7, false, proof);
2068        assert!(!damaged.is_valid());
2069        assert!(damaged.may_seed_repair_input());
2070    }
2071
2072    /// A CRC32 the session merely compared, with nothing vouching for where its
2073    /// bytes came from, stays inadmissible — this is the case
2074    /// `verify_from_slice_crcs` produces.
2075    #[test]
2076    fn session_hashed_crc32_only_evidence_carries_no_attestation() {
2077        let slice_size = 8u64;
2078        let file_data: Vec<u8> = (0..16u8).collect();
2079        let (par2_bytes, file_id, _) = build_par2_packets(&file_data, slice_size);
2080        let mut session = VerificationSession::new();
2081        session.add_par2_data(&parse_packets(&par2_bytes));
2082
2083        let crcs: Vec<u32> = file_data
2084            .chunks(slice_size as usize)
2085            .map(|slice| {
2086                let mut state = SliceChecksumState::new();
2087                state.update(slice);
2088                state.finalize(Some(slice_size)).0
2089            })
2090            .collect();
2091        session
2092            .verify_from_slice_crcs(&file_id, &crcs)
2093            .expect("slice CRCs settle the file");
2094
2095        let evidence = session.slice_evidence();
2096        assert!(!evidence.is_empty());
2097        for entry in evidence {
2098            assert_eq!(entry.strength(), SliceEvidenceStrength::Crc32Only);
2099            assert_eq!(entry.in_stream_proof(), None);
2100            assert!(!entry.may_seed_repair_input());
2101        }
2102    }
2103}