Skip to main content

par2_rs/
repair_session.rs

1//! Retained PAR2 repair orchestration.
2//!
3//! Unlike [`crate::repairer::Par2Repairer`], this API keeps parsed packet
4//! metadata and verified source locations across assessment and repair. It
5//! deliberately retains no open file handles or mapped data; repair reopens
6//! and validates every source as bytes are copied or consumed.
7//!
8//! # Sources that are not files
9//!
10//! A session reads its sources either from the filesystem — the default, and
11//! byte-for-byte the behaviour this API has always had — or through a
12//! [`FileAccess`] handle supplied by
13//! [`Par2RepairSessionOptions::with_source_access`]. The second form exists
14//! for sets whose sources have no paths at all: bytes still arriving over a
15//! network, or served out of somewhere that never became a file.
16//!
17//! The two arms differ in more than plumbing:
18//!
19//! - An access-backed session performs **no source scanning**. There is no
20//!   directory to walk, so [`Par2RepairSession::analyze`] resolves exactly what
21//!   evidence has named and leaves the rest unresolved.
22//! - Evidence for an access-backed source is named by [`FileId`]
23//!   ([`Par2RepairSession::add_slice_evidence_for_file`]), never by path.
24//! - Committed-file evidence stays physical-only; see
25//!   [`Par2RepairSession::add_committed_file`].
26//!
27//! Repair *outputs* are always real files in either arm. Only clean-source
28//! reads virtualize.
29
30use std::collections::{HashMap, HashSet};
31use std::fs;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34
35use thiserror::Error;
36
37use crate::error::Par2Error;
38use crate::evidence::{CommittedFileEvidence, FileStatFingerprint};
39use crate::packet::{Packet, scan_packets_from_path_with_set_ids};
40use crate::par2_set::{MergeResult, Par2FileSet};
41use crate::repair::{DEFAULT_REPAIR_MEMORY_LIMIT, repair_matrix_resource_limit_reason};
42use crate::repairer::{
43    EvidenceScanTrust, PacketDiagnostics, Par2RepairOutcome, Par2RepairStatus, Par2Repairer,
44    Par2RepairerOptions, RepairInstall, RepairState, RepairVerificationAccess, ScanDiagnostics,
45    SourceLocation, stat_fingerprint,
46};
47use crate::session::SliceEvidence;
48use crate::types::{CancellationToken, FileId, ProgressCallback};
49use crate::verify::{self, FileAccess, FileStatus, Repairability, VerificationResult};
50
51/// Default upper bound for memory owned by a retained repair session.
52pub const DEFAULT_RETAINED_STATE_LIMIT: usize = 64 * 1024 * 1024;
53
54/// Options used to open a [`Par2RepairSession`].
55///
56/// Build one with [`Par2RepairSessionOptions::new`] (filesystem sources) or
57/// [`Par2RepairSessionOptions::with_source_access`] (sources served by a
58/// [`FileAccess`] handle), then set the fields you care about. The type is
59/// `#[non_exhaustive]`: it will keep gaining fields, so construct it through a
60/// constructor rather than a struct literal.
61///
62/// ```no_run
63/// use par2_rs::Par2RepairSessionOptions;
64/// use std::path::PathBuf;
65///
66/// let mut options = Par2RepairSessionOptions::new(
67///     PathBuf::from("/downloads/release"),
68///     vec![PathBuf::from("/downloads/release/release.par2")],
69/// );
70/// options.retained_state_limit = 8 * 1024 * 1024;
71/// ```
72#[derive(Clone)]
73#[non_exhaustive]
74pub struct Par2RepairSessionOptions {
75    /// Where repair *output* lands, and — for filesystem sessions only — where
76    /// source scanning looks. An access-backed session never reads from here.
77    pub base_dir: PathBuf,
78    /// An already-parsed set, used instead of reading [`par2_paths`]. Set this
79    /// when the `.par2` volumes are not files — a caller that assembled them
80    /// in memory has the packets already, and making it write them out just so
81    /// they can be read back is the cost this avoids.
82    ///
83    /// [`par2_paths`]: Self::par2_paths
84    pub file_set: Option<Par2FileSet>,
85    /// The primary PAR2 files. Ignored when [`file_set`] is set. Adjacent
86    /// volumes are deliberately not loaded here; add them later with
87    /// [`Par2RepairSession::merge_recovery_paths`].
88    ///
89    /// [`file_set`]: Self::file_set
90    pub par2_paths: Vec<PathBuf>,
91    /// Explicit recovery volumes to merge immediately after opening.
92    pub recovery_paths: Vec<PathBuf>,
93    pub extra_paths: Vec<PathBuf>,
94    /// Paths under [`base_dir`] the extra scan must never enrol as candidates.
95    ///
96    /// Forwarded verbatim to [`Par2RepairerOptions::exclude_paths`], which
97    /// documents what it is for: a directory can hold the volumes of *other*
98    /// recovery sets, whose bytes cannot contain this set's slices at any
99    /// offset, and rolling-scanning them is a whole-payload read that can only
100    /// ever find nothing. Every scanning pass of the session honours it, so an
101    /// exclusion set once at open applies to the analysis and to every
102    /// re-analysis after an invalidation.
103    ///
104    /// [`base_dir`]: Self::base_dir
105    /// [`Par2RepairerOptions::exclude_paths`]: crate::Par2RepairerOptions::exclude_paths
106    pub exclude_paths: Vec<PathBuf>,
107    /// Whether the extra scan walks [`base_dir`] for candidates at all.
108    /// `true` is the default and the pre-existing behaviour; `false` limits
109    /// extra candidates to [`extra_paths`].
110    ///
111    /// [`base_dir`]: Self::base_dir
112    /// [`extra_paths`]: Self::extra_paths
113    pub discover_extras: bool,
114    pub memory_limit: Option<usize>,
115    pub retained_state_limit: usize,
116    pub rename_only: bool,
117    pub scan_skip_data: bool,
118    pub scan_skip_leeway: u64,
119    /// Let the analysis scan seek past byte ranges that seeded slice evidence
120    /// has already located, instead of reading and re-hashing them.
121    ///
122    /// `false` — the default — reads every candidate file in full, exactly as
123    /// every release before this option did. Turning it on is a statement about
124    /// the host's evidence, not about this crate: a damaged file's scan then
125    /// reads only the ranges no verdict accounts for, and the verdicts that
126    /// covered the rest are taken as given.
127    ///
128    /// What still holds when it is on:
129    ///
130    /// - Only evidence admitted by [`SliceEvidence::may_seed_repair_input`] can
131    ///   settle anything, and only where the block it names is already located
132    ///   at that path and offset. A skip can never drop a block.
133    /// - Each verdict carries the stat fingerprint its path had when it was
134    ///   admitted — length, mtime, and on Unix device and inode. The file is
135    ///   re-stat'd immediately before its scan, and any verdict whose
136    ///   fingerprint no longer matches is refused: that file is read in full,
137    ///   with no error and no partial trust.
138    /// - Repair is untouched. This governs analysis reads only; repair inputs
139    ///   still go through their own validated read paths.
140    /// - [`ScanDiagnostics::bytes_skipped_by_evidence`] and
141    ///   [`ScanDiagnostics::slices_settled_by_evidence`] are non-zero whenever
142    ///   an outcome was reached without reading its sources in full.
143    ///
144    /// What does not hold: a verdict that is simply *wrong* about bytes that
145    /// never moved is believed. `stat` cannot see that, and with this on
146    /// nothing re-reads to catch it. That is the trade being made.
147    ///
148    /// Seed verdicts against a settled file. A host that feeds them while the
149    /// file is still being written will find most of them refused at scan time,
150    /// because the fingerprint each was admitted against no longer matches.
151    ///
152    /// [`SliceEvidence::may_seed_repair_input`]: crate::SliceEvidence::may_seed_repair_input
153    /// [`ScanDiagnostics::bytes_skipped_by_evidence`]: crate::ScanDiagnostics::bytes_skipped_by_evidence
154    /// [`ScanDiagnostics::slices_settled_by_evidence`]: crate::ScanDiagnostics::slices_settled_by_evidence
155    pub trust_seeded_evidence_for_scan: bool,
156    pub cancel: Option<CancellationToken>,
157    pub progress: Option<ProgressCallback>,
158    /// Handle serving this set's sources. `None` — the default — reads sources
159    /// from the filesystem under `base_dir`. When present, every source read
160    /// goes through the handle and no source is ever opened by path.
161    pub source_access: Option<Arc<dyn FileAccess + Send + Sync>>,
162}
163
164impl Par2RepairSessionOptions {
165    /// Options for a session whose sources are files under `base_dir`.
166    pub fn new(base_dir: PathBuf, par2_paths: Vec<PathBuf>) -> Self {
167        Self {
168            base_dir,
169            par2_paths,
170            ..Self::default()
171        }
172    }
173
174    /// Options for a session whose sources are served by `source_access`
175    /// rather than read from disk.
176    ///
177    /// `base_dir` still names where repair output is staged and installed, and
178    /// `par2_paths` are still read as files — only the protected *sources*
179    /// move behind the handle.
180    ///
181    /// ```no_run
182    /// use par2_rs::{MemoryFileAccess, Par2RepairSessionOptions};
183    /// use std::path::PathBuf;
184    /// use std::sync::Arc;
185    ///
186    /// let access = Arc::new(MemoryFileAccess::new());
187    /// let options = Par2RepairSessionOptions::with_source_access(
188    ///     PathBuf::from("/var/tmp/repair-scratch"),
189    ///     vec![PathBuf::from("/downloads/release/release.par2")],
190    ///     access,
191    /// );
192    /// assert!(options.source_access.is_some());
193    /// ```
194    pub fn with_source_access(
195        base_dir: PathBuf,
196        par2_paths: Vec<PathBuf>,
197        source_access: Arc<dyn FileAccess + Send + Sync>,
198    ) -> Self {
199        Self {
200            base_dir,
201            par2_paths,
202            source_access: Some(source_access),
203            ..Self::default()
204        }
205    }
206
207    /// Options for a session with no PAR2 files and no source files: the set
208    /// is already parsed, and sources are served by `source_access`.
209    ///
210    /// `base_dir` still names where repair output is staged and installed —
211    /// repair writes real files whatever the sources were.
212    ///
213    /// ```no_run
214    /// use par2_rs::{MemoryFileAccess, Par2FileSet, Par2RepairSessionOptions};
215    /// use std::path::PathBuf;
216    /// use std::sync::Arc;
217    ///
218    /// # fn main() -> par2_rs::Result<()> {
219    /// # let packets = Vec::new();
220    /// let set = Par2FileSet::from_packets(packets)?;
221    /// let access = Arc::new(MemoryFileAccess::new());
222    /// let options = Par2RepairSessionOptions::from_set(
223    ///     PathBuf::from("/var/tmp/repair-scratch"),
224    ///     set,
225    ///     access,
226    /// );
227    /// assert!(options.par2_paths.is_empty());
228    /// # Ok(())
229    /// # }
230    /// ```
231    pub fn from_set(
232        base_dir: PathBuf,
233        file_set: Par2FileSet,
234        source_access: Arc<dyn FileAccess + Send + Sync>,
235    ) -> Self {
236        Self {
237            base_dir,
238            file_set: Some(file_set),
239            source_access: Some(source_access),
240            ..Self::default()
241        }
242    }
243}
244
245impl Default for Par2RepairSessionOptions {
246    fn default() -> Self {
247        Self {
248            base_dir: PathBuf::new(),
249            file_set: None,
250            par2_paths: Vec::new(),
251            recovery_paths: Vec::new(),
252            extra_paths: Vec::new(),
253            exclude_paths: Vec::new(),
254            discover_extras: true,
255            memory_limit: Some(DEFAULT_REPAIR_MEMORY_LIMIT),
256            retained_state_limit: DEFAULT_RETAINED_STATE_LIMIT,
257            rename_only: false,
258            scan_skip_data: false,
259            scan_skip_leeway: 64,
260            trust_seeded_evidence_for_scan: false,
261            cancel: None,
262            progress: None,
263            source_access: None,
264        }
265    }
266}
267
268/// Diagnostics accumulated by the retained session.
269///
270/// `#[non_exhaustive]`: counters get added as the session learns to report
271/// more. Read the fields you need; do not match the struct exhaustively.
272#[derive(Debug, Clone, Default)]
273#[non_exhaustive]
274pub struct Par2RepairSessionDiagnostics {
275    pub packets: PacketDiagnostics,
276    pub scan: ScanDiagnostics,
277    pub source_scan_passes: u32,
278    pub retained_bytes: usize,
279    pub committed_sources: u32,
280    pub slice_evidence: u32,
281    pub quick_proof_hits: u32,
282    pub quick_proof_fallbacks: u32,
283    pub live_slices: u32,
284    pub recovery_paths_merged: u32,
285    pub recovery_packets_rejected: u32,
286    pub repair_validation_bytes: u64,
287    pub analyzed: bool,
288    /// Slice evidence retained by [`FileId`] rather than by path. Always zero
289    /// on a filesystem session.
290    pub access_slice_evidence: u32,
291    /// Current source-coverage generation; see
292    /// [`Par2RepairSession::source_generation`].
293    pub source_generation: u64,
294}
295
296/// Errors specific to retaining PAR2 source analysis between calls.
297#[derive(Debug, Error)]
298#[non_exhaustive]
299pub enum Par2SessionError {
300    #[error("invalid retained-session state: {reason}")]
301    InvalidState { reason: &'static str },
302
303    #[error("source changed before or during repair: {path}")]
304    SourceChanged { path: PathBuf },
305
306    #[error(
307        "retained PAR2 session state requires {required_bytes} bytes, exceeding the {limit_bytes} byte limit"
308    )]
309    RetainedStateLimitExceeded {
310        limit_bytes: usize,
311        required_bytes: usize,
312    },
313
314    #[error("committed evidence does not match a recoverable PAR2 file: {logical_name}")]
315    EvidenceDoesNotMatch { logical_name: String },
316
317    #[error(transparent)]
318    Par2(#[from] Par2Error),
319}
320
321/// Stateful PAR2 repair engine retaining only owned packet metadata and
322/// source-location evidence between calls.
323#[derive(Debug, Clone, PartialEq, Eq)]
324struct RetainedSliceEvidence {
325    source: SourceLocation,
326    valid: bool,
327    /// What `stat` said about the path when this verdict was admitted, or
328    /// `None` when there was nothing to fingerprint: an access-backed source,
329    /// a path that could not be stat'd, or — the ordinary case — a session
330    /// that never asked for the scan-skip policy and so has no use for one.
331    ///
332    /// Nothing reads this unless
333    /// [`Par2RepairSessionOptions::trust_seeded_evidence_for_scan`] is set, and
334    /// nothing captures it otherwise, so a default session pays neither the
335    /// syscall nor a change of behaviour.
336    fingerprint: Option<FileStatFingerprint>,
337}
338
339pub struct Par2RepairSession {
340    options: Par2RepairSessionOptions,
341    state: RepairState,
342    packet_diagnostics: PacketDiagnostics,
343    committed: Vec<CommittedFileEvidence>,
344    slice_evidence: HashMap<(FileId, u32), RetainedSliceEvidence>,
345    merged_recovery_paths: HashSet<PathBuf>,
346    sources_scanned: bool,
347    source_generation: u64,
348    diagnostics: Par2RepairSessionDiagnostics,
349    assessment: Option<Par2RepairOutcome>,
350    /// Held from the first `analyze()` until the session drops: pages the
351    /// scan verifies must survive to a later `repair()` on the same session,
352    /// or the accumulate pass re-reads them from physical storage.
353    cache_retention: Option<crate::file_cache::CacheEvictionDeferral>,
354}
355
356impl Par2RepairSession {
357    /// Open the primary packet set without eagerly selecting adjacent recovery
358    /// volumes. Any explicit recovery paths are merged afterward.
359    pub fn open(mut options: Par2RepairSessionOptions) -> Result<Self, Par2SessionError> {
360        let explicit_recovery = std::mem::take(&mut options.recovery_paths);
361        let repairer = Par2Repairer::new(repairer_options(&options, false));
362        let inventory = repairer.load_inventory_without_adjacent_recovery()?;
363        let required_bytes =
364            RepairState::estimated_retained_bytes_from_set(&options.base_dir, &inventory.set);
365        if required_bytes > options.retained_state_limit {
366            return Err(Par2SessionError::RetainedStateLimitExceeded {
367                limit_bytes: options.retained_state_limit,
368                required_bytes,
369            });
370        }
371        let state = RepairState::from_set_with_access(
372            &options.base_dir,
373            inventory.set,
374            options.source_access.clone(),
375        )?;
376        let mut session = Self {
377            options,
378            state,
379            packet_diagnostics: inventory.diagnostics,
380            committed: Vec::new(),
381            slice_evidence: HashMap::new(),
382            merged_recovery_paths: HashSet::new(),
383            sources_scanned: false,
384            source_generation: 0,
385            diagnostics: Par2RepairSessionDiagnostics::default(),
386            assessment: None,
387            cache_retention: None,
388        };
389        session.refresh_diagnostics();
390        session.enforce_retained_limit()?;
391        if !explicit_recovery.is_empty() {
392            session.merge_recovery_paths(explicit_recovery)?;
393        }
394        Ok(session)
395    }
396
397    /// Whether this session reads its sources through a [`FileAccess`] handle
398    /// instead of the filesystem.
399    pub fn is_access_backed(&self) -> bool {
400        self.options.source_access.is_some()
401    }
402
403    /// Add independently captured committed-file evidence. Full MD5 evidence
404    /// seeds a whole-file location. Contiguous CRC32 + 16 KiB evidence is
405    /// quick-proved against its captured path and also seeds a complete file.
406    ///
407    /// # This evidence class is physical by definition
408    ///
409    /// Committed-file evidence is admitted on a stat fingerprint — device,
410    /// inode, mtime and length, captured when the evidence was taken and
411    /// re-checked before every analysis, merge and repair. That gate is not an
412    /// implementation limit that a virtual source happens to fail; it *is* the
413    /// definition of the class. The claim being made is "the file I hashed is
414    /// still the same file, unmoved and unrewritten", and only a filesystem
415    /// object can carry that claim.
416    ///
417    /// A source served through a [`FileAccess`] handle has no device, no inode
418    /// and no mtime. Its staleness is governed by the handle's own coverage
419    /// (see [`Par2RepairSession::source_generation`]), not by `stat`. So an
420    /// access-backed session refuses this evidence outright, with a named
421    /// reason, rather than letting the stat gate refuse it incidentally.
422    /// Feed wire evidence for such sources with
423    /// [`Par2RepairSession::add_slice_evidence_for_file`] instead.
424    pub fn add_committed_file(
425        &mut self,
426        evidence: CommittedFileEvidence,
427    ) -> Result<(), Par2SessionError> {
428        if self.is_access_backed() {
429            self.diagnostics.quick_proof_fallbacks =
430                self.diagnostics.quick_proof_fallbacks.saturating_add(1);
431            return Err(Par2SessionError::InvalidState {
432                reason: "committed-file evidence is physical-only: it is admitted by a stat \
433                         fingerprint, which an access-backed source cannot carry",
434            });
435        }
436        match evidence_stat_matches(&evidence) {
437            Ok(true) => {}
438            Ok(false) => {
439                self.diagnostics.quick_proof_fallbacks =
440                    self.diagnostics.quick_proof_fallbacks.saturating_add(1);
441                return Err(Par2SessionError::EvidenceDoesNotMatch {
442                    logical_name: evidence.logical_name().to_owned(),
443                });
444            }
445            Err(error) => {
446                self.diagnostics.quick_proof_fallbacks =
447                    self.diagnostics.quick_proof_fallbacks.saturating_add(1);
448                return Err(error);
449            }
450        }
451        let targets = self.evidence_targets(&evidence);
452        if targets.len() != 1 {
453            self.diagnostics.quick_proof_fallbacks =
454                self.diagnostics.quick_proof_fallbacks.saturating_add(1);
455            return Err(Par2SessionError::EvidenceDoesNotMatch {
456                logical_name: evidence.logical_name().to_owned(),
457            });
458        }
459        let source = SourceLocation::Path(evidence.path().to_path_buf());
460        let path_budget = self
461            .state
462            .complete_location_budget(targets[0], &source)
463            .ok_or_else(|| Par2SessionError::EvidenceDoesNotMatch {
464                logical_name: evidence.logical_name().to_owned(),
465            })?;
466        let projected = self
467            .estimated_retained_bytes()
468            .saturating_add(committed_evidence_bytes(&evidence))
469            .saturating_add(path_budget);
470        self.ensure_limit(projected)?;
471        if !self.state.seed_complete_location(targets[0], source) {
472            return Err(Par2SessionError::EvidenceDoesNotMatch {
473                logical_name: evidence.logical_name().to_owned(),
474            });
475        }
476        self.diagnostics.quick_proof_hits = self.diagnostics.quick_proof_hits.saturating_add(1);
477        self.committed.push(evidence);
478        self.sources_scanned = false;
479        self.assessment = None;
480        self.refresh_diagnostics();
481        Ok(())
482    }
483
484    /// Add a settled IFSC verdict from [`crate::session::VerificationSession`]
485    /// for the file at `path`. Only valid slices seed a source block; an
486    /// invalid verdict invalidates prior locations for the supplied path.
487    ///
488    /// The verdict must be one a session may act on — see
489    /// [`SliceEvidence::may_seed_repair_input`], which admits a slice this
490    /// crate hashed with CRC32 and MD5, and an in-stream CRC32 verdict the
491    /// caller attested with [`SliceEvidence::from_in_stream_crc32`].
492    ///
493    /// Use [`Self::add_slice_evidence_for_file`] for sources served by a
494    /// [`FileAccess`] handle, which have no path to name.
495    pub fn add_slice_evidence(
496        &mut self,
497        path: impl Into<PathBuf>,
498        evidence: SliceEvidence,
499    ) -> Result<(), Par2SessionError> {
500        self.retain_slice_evidence(SourceLocation::Path(path.into()), evidence)
501    }
502
503    /// Add a settled IFSC verdict for a source named only by its PAR2
504    /// [`FileId`] — the form used when sources are served by a [`FileAccess`]
505    /// handle rather than found on disk.
506    ///
507    /// The identifier comes from the evidence itself, so there is nothing to
508    /// pass but the verdict. As with the path-keyed form, only evidence that
509    /// passes [`SliceEvidence::may_seed_repair_input`] may seed repair input —
510    /// a slice this crate hashed with
511    /// [`crate::SliceEvidenceStrength::Crc32AndMd5`], or an in-stream CRC32
512    /// verdict attested by the caller with
513    /// [`SliceEvidence::from_in_stream_crc32`] — and an invalid verdict clears
514    /// any location previously held for that slice.
515    ///
516    /// This is the seat an in-stream verdict takes: a downloader that hashes
517    /// payload bytes once, cut on the recovery set's block grid, feeds its
518    /// block conclusions here and never hands the bytes over.
519    ///
520    /// This requires an access-backed session: without a handle there is no
521    /// way to read the bytes a [`FileId`] names, and quietly falling back to
522    /// `base_dir` would reintroduce exactly the filesystem coupling the handle
523    /// exists to remove.
524    ///
525    /// ```no_run
526    /// # use par2_rs::{MemoryFileAccess, Par2RepairSession, Par2RepairSessionOptions};
527    /// # use std::path::PathBuf;
528    /// # use std::sync::Arc;
529    /// # fn demo(evidence: par2_rs::SliceEvidence) -> Result<(), par2_rs::Par2SessionError> {
530    /// let access = Arc::new(MemoryFileAccess::new());
531    /// let mut session = Par2RepairSession::open(Par2RepairSessionOptions::with_source_access(
532    ///     PathBuf::from("/var/tmp/repair-scratch"),
533    ///     vec![PathBuf::from("/downloads/release/release.par2")],
534    ///     access,
535    /// ))?;
536    /// session.add_slice_evidence_for_file(evidence)?;
537    /// # Ok(())
538    /// # }
539    /// ```
540    pub fn add_slice_evidence_for_file(
541        &mut self,
542        evidence: SliceEvidence,
543    ) -> Result<(), Par2SessionError> {
544        if !self.is_access_backed() {
545            return Err(Par2SessionError::InvalidState {
546                reason: "FileId-keyed slice evidence requires a session opened with a \
547                         source-access handle",
548            });
549        }
550        self.retain_slice_evidence(SourceLocation::Access(evidence.file_id()), evidence)
551    }
552
553    fn retain_slice_evidence(
554        &mut self,
555        source: SourceLocation,
556        evidence: SliceEvidence,
557    ) -> Result<(), Par2SessionError> {
558        if evidence.recovery_set_id() != self.state.set.recovery_set_id {
559            return Err(Par2Error::ConflictingRecoverySet.into());
560        }
561        if !evidence.may_seed_repair_input() {
562            return Err(Par2SessionError::InvalidState {
563                reason: "unattested CRC32-only slice evidence cannot seed repair input",
564            });
565        }
566        let key = (evidence.file_id(), evidence.slice_index());
567        let valid = evidence.is_valid();
568        // Dedup on the verdict itself, never on the fingerprint. Re-admitting
569        // an identical verdict has always been a no-op, and it stays one: a
570        // fingerprint in the comparison would turn a repeat call after an
571        // mtime change into a re-seed that invalidates the whole path.
572        if self
573            .slice_evidence
574            .get(&key)
575            .is_some_and(|existing| existing.source == source && existing.valid == valid)
576        {
577            return Ok(());
578        }
579        // Captured here and only here: the state of the path at the moment
580        // this verdict was admitted, which is the only thing the scan-skip gate
581        // can honestly compare a later stat against.
582        let fingerprint = if self.options.trust_seeded_evidence_for_scan {
583            source.path().and_then(stat_fingerprint)
584        } else {
585            None
586        };
587        let retained = RetainedSliceEvidence {
588            source,
589            valid,
590            fingerprint,
591        };
592        let Some(location_budget) = self.state.block_location_budget(
593            evidence.file_id(),
594            evidence.slice_index(),
595            &retained.source,
596        ) else {
597            return Err(Par2SessionError::EvidenceDoesNotMatch {
598                logical_name: format!(
599                    "PAR2 file {} slice {}",
600                    evidence.file_id(),
601                    evidence.slice_index()
602                ),
603            });
604        };
605        let key_bytes = std::mem::size_of_val(&key);
606        let projected = self
607            .estimated_retained_bytes()
608            .saturating_add(key_bytes)
609            .saturating_add(retained_slice_evidence_bytes(&retained))
610            .saturating_add(if evidence.is_valid() {
611                location_budget
612            } else {
613                0
614            });
615        self.ensure_limit(projected)?;
616        if let Some(old) = self.slice_evidence.get(&key) {
617            invalidate_source_in_state(&mut self.state, &old.source);
618        }
619        self.slice_evidence.insert(key, retained.clone());
620        if evidence.is_valid() {
621            self.state.seed_block_location(
622                evidence.file_id(),
623                evidence.slice_index(),
624                retained.source,
625            );
626        } else {
627            invalidate_source_in_state(&mut self.state, &retained.source);
628        }
629        self.diagnostics.live_slices = self.diagnostics.live_slices.saturating_add(1);
630        self.sources_scanned = false;
631        self.assessment = None;
632        self.refresh_diagnostics();
633        Ok(())
634    }
635
636    /// Analyze sources once. Repeated calls return the cached assessment.
637    ///
638    /// A filesystem session scans `base_dir` for anything evidence has not
639    /// already resolved. An **access-backed session never scans**: there is no
640    /// directory that holds its sources, so a walk would at best waste I/O and
641    /// at worst bind the set to unrelated files that happen to share a name.
642    /// Blocks that no evidence has named simply stay unresolved, and the
643    /// caller decides what to do about them.
644    pub fn analyze(&mut self) -> Result<Par2RepairOutcome, Par2SessionError> {
645        self.ensure_committed_sources_unchanged()?;
646        self.cache_retention
647            .get_or_insert_with(crate::file_cache::CacheEvictionDeferral::acquire);
648        if let Some(assessment) = &self.assessment {
649            return Ok(assessment.clone());
650        }
651        let scan = if self.is_access_backed() {
652            // No scan pass is counted, and no scan byte is read: the whole
653            // point of an access-backed session is that `base_dir` holds no
654            // sources to find.
655            self.state.refresh_access_file_states();
656            self.sources_scanned = true;
657            ScanDiagnostics::default()
658        } else if self.sources_scanned {
659            self.diagnostics.scan.clone()
660        } else {
661            let trust = self.evidence_scan_trust();
662            let scan = self
663                .state
664                .scan_unresolved(&repairer_options(&self.options, false), &trust)?;
665            self.sources_scanned = true;
666            self.diagnostics.source_scan_passes =
667                self.diagnostics.source_scan_passes.saturating_add(1);
668            scan
669        };
670        self.diagnostics.scan = scan.clone();
671        let assessment = self.build_assessment(scan, 0, 0)?;
672        let required_bytes = self
673            .estimated_retained_bytes()
674            .saturating_add(assessment_bytes(&assessment));
675        if let Err(error) = self.ensure_limit(required_bytes) {
676            self.invalidate_all_sources();
677            return Err(error);
678        }
679        self.assessment = Some(assessment.clone());
680        self.refresh_diagnostics();
681        Ok(assessment)
682    }
683
684    /// The slice verdicts this session is willing to let the scan take on
685    /// trust, with the fingerprint each was admitted against.
686    ///
687    /// Empty unless
688    /// [`Par2RepairSessionOptions::trust_seeded_evidence_for_scan`] is set — no
689    /// fingerprint is captured without it, so there is nothing to offer. Only
690    /// valid, path-keyed verdicts appear: an invalid verdict locates nothing,
691    /// and an access-keyed one belongs to a session that never scans.
692    fn evidence_scan_trust(&self) -> EvidenceScanTrust {
693        let mut trust = EvidenceScanTrust::default();
694        if !self.options.trust_seeded_evidence_for_scan {
695            return trust;
696        }
697        for (&(file_id, slice_index), evidence) in &self.slice_evidence {
698            if !evidence.valid {
699                continue;
700            }
701            let (Some(path), Some(fingerprint)) =
702                (evidence.source.path(), evidence.fingerprint.as_ref())
703            else {
704                continue;
705            };
706            trust.record(file_id, path, slice_index, fingerprint.clone());
707        }
708        trust
709    }
710
711    /// Return the cached assessment. Call [`Self::analyze`] first.
712    pub fn assessment(&self) -> Result<&Par2RepairOutcome, Par2SessionError> {
713        self.assessment
714            .as_ref()
715            .ok_or(Par2SessionError::InvalidState {
716                reason: "analyze must complete before requesting an assessment",
717            })
718    }
719
720    /// Parse only paths not previously merged, then merge their recovery
721    /// packets in place. Existing source locations and scan accounting remain
722    /// valid because recovery-only additions cannot alter source bytes.
723    pub fn merge_recovery_paths<I, P>(&mut self, paths: I) -> Result<MergeResult, Par2SessionError>
724    where
725        I: IntoIterator<Item = P>,
726        P: AsRef<Path>,
727    {
728        self.ensure_committed_sources_unchanged()?;
729        let new_paths = paths
730            .into_iter()
731            .map(|path| path.as_ref().to_path_buf())
732            .filter(|path| !self.merged_recovery_paths.contains(path))
733            .collect::<Vec<_>>();
734        if new_paths.is_empty() {
735            return Ok(MergeResult {
736                new_recovery_slices: 0,
737                duplicates_ignored: 0,
738            });
739        }
740        let mut packets = Vec::new();
741        let mut rejected = 0u32;
742        let mut metadata_changed = false;
743        for path in &new_paths {
744            for scanned in scan_packets_from_path_with_set_ids(path)? {
745                if scanned.recovery_set_id != self.state.set.recovery_set_id {
746                    return Err(Par2Error::ConflictingRecoverySet.into());
747                }
748                let packet = scanned.packet;
749                metadata_changed |= match &packet {
750                    Packet::FileDescription(description) => {
751                        !self.state.set.files.contains_key(&description.file_id)
752                    }
753                    Packet::InputFileSliceChecksum(checksums) => !self
754                        .state
755                        .set
756                        .slice_checksums
757                        .contains_key(&checksums.file_id),
758                    _ => false,
759                };
760                match packet {
761                    Packet::RecoverySlice(recovery)
762                        if recovery.data.len() as u64 == self.state.set.slice_size =>
763                    {
764                        packets.push(Packet::RecoverySlice(recovery));
765                    }
766                    Packet::RecoverySlice(_) | Packet::Unknown { .. } => {
767                        rejected = rejected.saturating_add(1);
768                    }
769                    metadata => packets.push(metadata),
770                }
771            }
772        }
773        let new_path_bytes = retained_path_bytes(&new_paths);
774        let retained_without_state_or_assessment = self
775            .estimated_retained_bytes()
776            .saturating_sub(self.state.estimated_retained_bytes())
777            .saturating_sub(self.assessment.as_ref().map_or(0, assessment_bytes));
778        let packets_loaded = packets.len() as u32;
779        let merged_paths = new_paths.len() as u32;
780        let mut candidate_set = self.state.set.clone();
781        let result = candidate_set.merge_packets(packets)?;
782        if metadata_changed {
783            let mut candidate_state = RepairState::from_set(&self.options.base_dir, candidate_set)?;
784            for evidence in &self.committed {
785                apply_committed_evidence_to_state(&mut candidate_state, evidence);
786            }
787            apply_slice_evidence_to_state(&mut candidate_state, &self.slice_evidence);
788            let required_bytes = retained_without_state_or_assessment
789                .saturating_add(candidate_state.estimated_retained_bytes())
790                .saturating_add(new_path_bytes);
791            self.ensure_limit(required_bytes)?;
792            self.state = candidate_state;
793            self.sources_scanned = false;
794        } else {
795            let required_bytes = retained_without_state_or_assessment
796                .saturating_add(self.state.estimated_retained_bytes_with_set(&candidate_set))
797                .saturating_add(new_path_bytes);
798            self.ensure_limit(required_bytes)?;
799            self.state.set = candidate_set;
800        }
801        self.merged_recovery_paths.extend(new_paths);
802        self.packet_diagnostics.packets_loaded = self
803            .packet_diagnostics
804            .packets_loaded
805            .saturating_add(packets_loaded);
806        self.packet_diagnostics.duplicate_packets = self
807            .packet_diagnostics
808            .duplicate_packets
809            .saturating_add(result.duplicates_ignored);
810        self.diagnostics.recovery_paths_merged = self
811            .diagnostics
812            .recovery_paths_merged
813            .saturating_add(merged_paths);
814        self.diagnostics.recovery_packets_rejected = self
815            .diagnostics
816            .recovery_packets_rejected
817            .saturating_add(rejected);
818        // Recovery-only packets preserve the source scan. Newly discovered
819        // FileDesc/IFSC metadata rebuilds the source map and requires one new
820        // analysis pass because the protected file set itself changed.
821        self.assessment = None;
822        self.refresh_diagnostics();
823        Ok(result)
824    }
825
826    /// Repair using the retained assessment. Source bytes are checked inline
827    /// as blocks are staged and as streamed reconstruction reads consume them.
828    pub fn repair(&mut self) -> Result<Par2RepairOutcome, Par2SessionError> {
829        self.ensure_committed_sources_unchanged()?;
830        self.cache_retention
831            .get_or_insert_with(crate::file_cache::CacheEvictionDeferral::acquire);
832        let assessment = self.assessment()?.clone();
833        if !matches!(assessment.status, Par2RepairStatus::RepairPossible) {
834            return Ok(assessment);
835        }
836        let repair_options = repairer_options(&self.options, true);
837        let repair = match self
838            .state
839            .repair_validated(&repair_options, &assessment.verification)
840        {
841            Ok(repair) => repair,
842            Err(error @ Par2Error::InsufficientRecoveryData { .. }) => {
843                let rejected = self.discard_unusable_recovery_packets();
844                if rejected == 0 {
845                    return Err(map_par2_error(error));
846                }
847                self.diagnostics.recovery_packets_rejected = self
848                    .diagnostics
849                    .recovery_packets_rejected
850                    .saturating_add(rejected);
851                let outcome = self.build_assessment(self.diagnostics.scan.clone(), 0, 0)?;
852                self.assessment = Some(outcome.clone());
853                self.refresh_diagnostics();
854                return Ok(outcome);
855            }
856            Err(error) => return Err(map_par2_error(error)),
857        };
858        let validation_bytes = repair.validation_bytes;
859        let result = self.finish_repair(repair, assessment.verification, repair_options);
860        match result {
861            Ok(outcome) => {
862                self.diagnostics.repair_validation_bytes = self
863                    .diagnostics
864                    .repair_validation_bytes
865                    .saturating_add(validation_bytes);
866                self.assessment = Some(outcome.clone());
867                self.refresh_diagnostics();
868                Ok(outcome)
869            }
870            Err(error) => Err(map_par2_error(error)),
871        }
872    }
873
874    /// Forget retained locations for one path. Packet metadata and other
875    /// sources stay available for a later unresolved-only analysis.
876    ///
877    /// This is the disk-side invalidation and it names a disk object; it
878    /// cannot reach an access-backed source, which has no path. Use
879    /// [`Self::invalidate_file`] for those.
880    pub fn invalidate_path(&mut self, path: impl AsRef<Path>) {
881        let path = path.as_ref();
882        self.state.invalidate_path(path);
883        self.committed.retain(|evidence| evidence.path() != path);
884        self.slice_evidence
885            .retain(|_, evidence| !evidence.source.is_path(path));
886        self.sources_scanned = false;
887        self.assessment = None;
888        self.refresh_diagnostics();
889    }
890
891    /// Forget every retained location and every piece of evidence belonging to
892    /// one PAR2 file, whichever kind of source backs it.
893    ///
894    /// This is the identity-keyed invalidation: it names the *file* in the
895    /// recovery set rather than a place on disk, so it is the right call when
896    /// a virtual source stops being trustworthy. Packet metadata, recovery
897    /// packets and every other file's evidence survive, so the next
898    /// [`Self::analyze`] re-resolves only what this dropped.
899    ///
900    /// Returns `true` when something was actually forgotten.
901    pub fn invalidate_file(&mut self, file_id: FileId) -> bool {
902        let mut changed = self.state.invalidate_file(file_id);
903        let before_committed = self.committed.len();
904        let state = &self.state;
905        self.committed
906            .retain(|evidence| evidence_targets_in_state(state, evidence) != vec![file_id]);
907        changed |= self.committed.len() != before_committed;
908        let before_slices = self.slice_evidence.len();
909        self.slice_evidence
910            .retain(|(key_id, _), _| *key_id != file_id);
911        changed |= self.slice_evidence.len() != before_slices;
912        self.sources_scanned = false;
913        self.assessment = None;
914        self.refresh_diagnostics();
915        changed
916    }
917
918    /// Replace the paths this session's extra scan must leave alone.
919    ///
920    /// A retained session outlives the facts that decide the exclusion set —
921    /// which files the host has bound to which recovery set — so the set has to
922    /// be replaceable without throwing the session away and paying for its
923    /// packet parse and its source scan again.
924    ///
925    /// A path that becomes excluded also loses whatever this session had
926    /// retained from it. An exclusion states that the file's bytes belong to
927    /// something else, so a location or a piece of evidence still pointing
928    /// into it is a claim this session is no longer willing to make — leaving
929    /// it standing would let a repair read from a file the caller has just
930    /// said not to read.
931    ///
932    /// A path that stops being excluded is a candidate this session has never
933    /// looked at, so the cached assessment is discarded and the next
934    /// [`Self::analyze`] scans what is unresolved with the file admitted.
935    ///
936    /// An unchanged list does nothing at all, so a caller may recompute and
937    /// re-set it on every pass without costing a scan.
938    pub fn set_exclude_paths(&mut self, exclude_paths: Vec<PathBuf>) {
939        let mut next: Vec<PathBuf> = exclude_paths;
940        next.sort();
941        next.dedup();
942        // Both sides are held in the same normal form, so "did it change" is
943        // answered by ordering rather than by how the caller spelled the list.
944        self.options.exclude_paths.sort();
945        self.options.exclude_paths.dedup();
946        if next == self.options.exclude_paths {
947            return;
948        }
949        let added: Vec<PathBuf> = next
950            .iter()
951            .filter(|path| self.options.exclude_paths.binary_search(path).is_err())
952            .cloned()
953            .collect();
954        let widened = self
955            .options
956            .exclude_paths
957            .iter()
958            .any(|path| next.binary_search(path).is_err());
959        self.options.exclude_paths = next;
960        for path in added {
961            self.invalidate_path(path);
962        }
963        if widened {
964            self.sources_scanned = false;
965            self.assessment = None;
966        }
967    }
968
969    /// Forget every retained source location and evidence while retaining the
970    /// parsed packet set and lazily selected recovery packets.
971    ///
972    /// Also advances [`Self::source_generation`], because forgetting every
973    /// source is by definition a coverage change.
974    pub fn invalidate_all_sources(&mut self) {
975        self.state.invalidate_all_sources();
976        self.committed.clear();
977        self.slice_evidence.clear();
978        self.sources_scanned = false;
979        self.assessment = None;
980        self.source_generation = self.source_generation.saturating_add(1);
981        self.refresh_diagnostics();
982    }
983
984    /// The current source-coverage generation, starting at 0.
985    ///
986    /// This is a monotonic counter, not a walk over anything. A caller that
987    /// serves virtual sources holds the generation it last fed evidence under
988    /// and compares: unchanged means every access-backed location the session
989    /// holds is still the one it seeded, so nothing needs re-feeding. It never
990    /// goes backwards, and it is the session's whole answer to "is my view
991    /// still current?".
992    pub fn source_generation(&self) -> u64 {
993        self.source_generation
994    }
995
996    /// Retire every access-backed source at once and return the new
997    /// generation.
998    ///
999    /// This is the counterpart to [`Self::invalidate_path`] for sources that
1000    /// have no path: when a serving handle's coverage moves — a router
1001    /// re-plans, a cache is dropped — one call retires everything read through
1002    /// it, without the caller enumerating identifiers. Physical locations and
1003    /// committed-file evidence are untouched, because nothing about them
1004    /// changed.
1005    pub fn invalidate_access_sources(&mut self) -> u64 {
1006        self.state.invalidate_access_sources();
1007        self.slice_evidence
1008            .retain(|_, evidence| !evidence.source.is_access());
1009        self.sources_scanned = false;
1010        self.assessment = None;
1011        self.source_generation = self.source_generation.saturating_add(1);
1012        self.refresh_diagnostics();
1013        self.source_generation
1014    }
1015
1016    /// Point the session at a new serving handle, retiring everything read
1017    /// through the old one, and return the new generation.
1018    ///
1019    /// A handle whose coverage is a snapshot gets *replaced* rather than
1020    /// mutated — a router that re-plans builds a new one — so re-pointing is
1021    /// the same event as [`Self::invalidate_access_sources`] and retires the
1022    /// same set. Physical locations and committed-file evidence are untouched.
1023    /// This is what lets one session outlive many handles instead of being
1024    /// rebuilt, and with it every physical proof the session has accumulated.
1025    ///
1026    /// Returns `None` on a filesystem session, where adopting a handle would
1027    /// silently redefine where sources come from. Open the session
1028    /// access-backed if that is what you want.
1029    pub fn set_source_access(
1030        &mut self,
1031        source_access: Arc<dyn FileAccess + Send + Sync>,
1032    ) -> Option<u64> {
1033        if !self.is_access_backed() {
1034            return None;
1035        }
1036        self.options.source_access = Some(Arc::clone(&source_access));
1037        self.state.source_access = Some(source_access);
1038        Some(self.invalidate_access_sources())
1039    }
1040
1041    /// Conservative upper bound on the heap this session owns.
1042    ///
1043    /// Every mutation projects this figure forward before it applies, so a
1044    /// caller budgeting several sessions against one ceiling can trust it.
1045    /// Access-backed retention is counted the same way as path-backed
1046    /// retention, and it is genuinely smaller: a [`FileId`] is 16 inline bytes
1047    /// where a path owns its own allocation.
1048    pub fn estimated_retained_bytes(&self) -> usize {
1049        self.state
1050            .estimated_retained_bytes()
1051            .saturating_add(
1052                self.committed
1053                    .iter()
1054                    .map(committed_evidence_bytes)
1055                    .sum::<usize>(),
1056            )
1057            .saturating_add(
1058                self.slice_evidence
1059                    .iter()
1060                    .map(|(key, evidence)| {
1061                        std::mem::size_of_val(key)
1062                            .saturating_add(retained_slice_evidence_bytes(evidence))
1063                    })
1064                    .sum::<usize>(),
1065            )
1066            .saturating_add(
1067                self.merged_recovery_paths
1068                    .iter()
1069                    .map(|path| retained_path_buf_bytes(path))
1070                    .sum::<usize>(),
1071            )
1072            .saturating_add(self.assessment.as_ref().map_or(0, assessment_bytes))
1073    }
1074
1075    pub fn diagnostics(&self) -> &Par2RepairSessionDiagnostics {
1076        &self.diagnostics
1077    }
1078
1079    fn finish_repair(
1080        &self,
1081        repair: RepairInstall,
1082        verification: VerificationResult,
1083        options: Par2RepairerOptions,
1084    ) -> Result<Par2RepairOutcome, Par2Error> {
1085        let access = RepairVerificationAccess::new(
1086            &self.state.files,
1087            &repair.install_dir,
1088            &repair.staged_file_ids,
1089            self.options.source_access.clone(),
1090        );
1091        let staged_ids = self
1092            .state
1093            .set
1094            .recovery_file_ids
1095            .iter()
1096            .filter(|file_id| repair.staged_file_ids.contains(file_id))
1097            .copied()
1098            .collect::<Vec<_>>();
1099        let post_staged =
1100            verify::verify_repaired_file_ids_parallel(&self.state.set, &access, &staged_ids);
1101        let post = verify::merge_verification_results(&self.state.set, &verification, post_staged);
1102        if post.total_missing_blocks > 0
1103            || !post
1104                .files
1105                .iter()
1106                .all(|file| matches!(file.status, FileStatus::Complete))
1107        {
1108            let _ = fs::remove_dir_all(&repair.install_dir);
1109            return Err(Par2Error::ReedSolomonError {
1110                reason: format!(
1111                    "post-repair verification failed: {} blocks remain damaged",
1112                    post.total_missing_blocks
1113                ),
1114            });
1115        }
1116        if let Err(error) = self.state.install_repaired_files(&repair, &options) {
1117            let _ = fs::remove_dir_all(&repair.install_dir);
1118            return Err(error);
1119        }
1120        let _ = fs::remove_dir_all(&repair.install_dir);
1121        Ok(self.state.outcome(
1122            Par2RepairStatus::Repaired,
1123            repair.bytes_copied,
1124            repair.bytes_reconstructed,
1125            self.packet_diagnostics.clone(),
1126            self.diagnostics.scan.clone(),
1127            post,
1128        ))
1129    }
1130
1131    fn build_assessment(
1132        &self,
1133        scan: ScanDiagnostics,
1134        bytes_copied: u64,
1135        bytes_reconstructed: u64,
1136    ) -> Result<Par2RepairOutcome, Par2SessionError> {
1137        let mut verification = self.state.verification_result();
1138        if let Some(reason) = repair_matrix_resource_limit_reason(
1139            &self.state.set,
1140            &verification,
1141            self.options.memory_limit,
1142        )? {
1143            verification.repairable = Repairability::ResourceLimited { reason };
1144        }
1145        let status = if verification.total_missing_blocks == 0
1146            && self.state.files_are_canonical_complete()
1147        {
1148            Par2RepairStatus::Verified
1149        } else {
1150            match &verification.repairable {
1151                Repairability::NotNeeded => Par2RepairStatus::Verified,
1152                Repairability::Repairable { .. } => Par2RepairStatus::RepairPossible,
1153                Repairability::Insufficient { .. } => Par2RepairStatus::Insufficient,
1154                Repairability::ResourceLimited { .. } => Par2RepairStatus::ResourceLimited,
1155            }
1156        };
1157        Ok(self.state.outcome(
1158            status,
1159            bytes_copied,
1160            bytes_reconstructed,
1161            self.packet_diagnostics.clone(),
1162            scan,
1163            verification,
1164        ))
1165    }
1166
1167    fn evidence_targets(&self, evidence: &CommittedFileEvidence) -> Vec<FileId> {
1168        evidence_targets_in_state(&self.state, evidence)
1169    }
1170
1171    fn discard_unusable_recovery_packets(&mut self) -> u32 {
1172        let recovery_set_id = self.state.set.recovery_set_id;
1173        let before = self.state.set.recovery_slices.len();
1174        self.state.set.recovery_slices.retain(|exponent, recovery| {
1175            recovery
1176                .data
1177                .validate_packet_hash(recovery_set_id.as_bytes(), *exponent)
1178                .unwrap_or(false)
1179        });
1180        u32::try_from(before.saturating_sub(self.state.set.recovery_slices.len()))
1181            .unwrap_or(u32::MAX)
1182    }
1183
1184    fn ensure_committed_sources_unchanged(&self) -> Result<(), Par2SessionError> {
1185        for evidence in &self.committed {
1186            if !evidence_stat_matches(evidence)? {
1187                return Err(Par2SessionError::SourceChanged {
1188                    path: evidence.path().to_path_buf(),
1189                });
1190            }
1191        }
1192        Ok(())
1193    }
1194
1195    fn ensure_limit(&self, required_bytes: usize) -> Result<(), Par2SessionError> {
1196        if required_bytes > self.options.retained_state_limit {
1197            return Err(Par2SessionError::RetainedStateLimitExceeded {
1198                limit_bytes: self.options.retained_state_limit,
1199                required_bytes,
1200            });
1201        }
1202        Ok(())
1203    }
1204
1205    fn enforce_retained_limit(&self) -> Result<(), Par2SessionError> {
1206        self.ensure_limit(self.estimated_retained_bytes())
1207    }
1208
1209    fn refresh_diagnostics(&mut self) {
1210        self.diagnostics.packets = self.packet_diagnostics.clone();
1211        self.diagnostics.retained_bytes = self.estimated_retained_bytes();
1212        self.diagnostics.committed_sources = self.committed.len() as u32;
1213        self.diagnostics.slice_evidence = self.slice_evidence.len() as u32;
1214        self.diagnostics.access_slice_evidence = self
1215            .slice_evidence
1216            .values()
1217            .filter(|evidence| evidence.source.is_access())
1218            .count() as u32;
1219        self.diagnostics.source_generation = self.source_generation;
1220        self.diagnostics.analyzed = self.assessment.is_some();
1221    }
1222}
1223
1224fn evidence_targets_in_state(state: &RepairState, evidence: &CommittedFileEvidence) -> Vec<FileId> {
1225    state
1226        .files
1227        .iter()
1228        .filter(|file| {
1229            file.recoverable
1230                && file.length == evidence.expected_length()
1231                && evidence
1232                    .bound_file_id()
1233                    .is_none_or(|file_id| file.file_id == file_id)
1234                && evidence.full_md5().map_or_else(
1235                    || {
1236                        evidence.hash_16k() == Some(file.hash_16k)
1237                            && evidence.assembly_crc32().is_some_and(|crc32| {
1238                                state.set.expected_file_crc32(file.file_id) == Some(crc32)
1239                            })
1240                    },
1241                    |full_md5| full_md5 == file.hash_full,
1242                )
1243        })
1244        .map(|file| file.file_id)
1245        .collect()
1246}
1247
1248fn apply_committed_evidence_to_state(
1249    state: &mut RepairState,
1250    evidence: &CommittedFileEvidence,
1251) -> bool {
1252    let targets = evidence_targets_in_state(state, evidence);
1253    if targets.len() != 1 {
1254        return false;
1255    }
1256    state.seed_complete_location(
1257        targets[0],
1258        SourceLocation::Path(evidence.path().to_path_buf()),
1259    )
1260}
1261
1262fn apply_slice_evidence_to_state(
1263    state: &mut RepairState,
1264    slice_evidence: &HashMap<(FileId, u32), RetainedSliceEvidence>,
1265) {
1266    for (&(file_id, slice_index), evidence) in slice_evidence {
1267        if evidence.valid {
1268            state.seed_block_location(file_id, slice_index, evidence.source.clone());
1269        }
1270    }
1271}
1272
1273/// Drop retained locations backed by `source`. Paths invalidate by path;
1274/// access-backed sources invalidate by the file identity they name, which is
1275/// the only handle they have.
1276fn invalidate_source_in_state(state: &mut RepairState, source: &SourceLocation) {
1277    match source {
1278        SourceLocation::Path(path) => {
1279            state.invalidate_path(path);
1280        }
1281        SourceLocation::Access(file_id) => {
1282            state.invalidate_file(*file_id);
1283        }
1284    }
1285}
1286
1287fn repairer_options(options: &Par2RepairSessionOptions, repair: bool) -> Par2RepairerOptions {
1288    let mut out = Par2RepairerOptions::new(options.base_dir.clone(), options.par2_paths.clone());
1289    out.file_set = options.file_set.clone();
1290    out.extra_paths = options.extra_paths.clone();
1291    out.exclude_paths = options.exclude_paths.clone();
1292    out.discover_extras = options.discover_extras;
1293    out.repair = repair;
1294    out.memory_limit = options.memory_limit;
1295    out.rename_only = options.rename_only;
1296    out.scan_skip_data = options.scan_skip_data;
1297    out.scan_skip_leeway = options.scan_skip_leeway;
1298    out.cancel = options.cancel.clone();
1299    out.progress = options.progress.clone();
1300    out
1301}
1302
1303fn evidence_stat_matches(evidence: &CommittedFileEvidence) -> Result<bool, Par2SessionError> {
1304    #[cfg(unix)]
1305    use std::os::unix::fs::MetadataExt;
1306
1307    let metadata = fs::metadata(evidence.path()).map_err(|_| Par2SessionError::SourceChanged {
1308        path: evidence.path().to_path_buf(),
1309    })?;
1310    let fingerprint = evidence.stat_fingerprint();
1311    Ok(!(metadata.len() != evidence.expected_length()
1312        || metadata.len() != fingerprint.length()
1313        || metadata.modified().ok() != fingerprint.modified()
1314        || {
1315            #[cfg(unix)]
1316            {
1317                metadata.dev() != fingerprint.device() || metadata.ino() != fingerprint.inode()
1318            }
1319            #[cfg(not(unix))]
1320            {
1321                false
1322            }
1323        }))
1324}
1325
1326fn map_par2_error(error: Par2Error) -> Par2SessionError {
1327    match error {
1328        Par2Error::Io(source) => {
1329            let message = source.to_string();
1330            if let Some(path) = message.strip_prefix("PAR2 source changed: ") {
1331                return Par2SessionError::SourceChanged {
1332                    path: PathBuf::from(path),
1333                };
1334            }
1335            Par2SessionError::Par2(Par2Error::Io(source))
1336        }
1337        error => Par2SessionError::Par2(error),
1338    }
1339}
1340
1341fn committed_evidence_bytes(evidence: &CommittedFileEvidence) -> usize {
1342    std::mem::size_of::<CommittedFileEvidence>()
1343        .saturating_add(evidence.path().as_os_str().len())
1344        .saturating_add(evidence.logical_name().len())
1345}
1346
1347/// Bytes retained per slice verdict: the record itself plus whatever its
1348/// source owns on the heap. A path owns its bytes; a [`FileId`] lives inline
1349/// in the record and owns nothing further.
1350fn retained_slice_evidence_bytes(evidence: &RetainedSliceEvidence) -> usize {
1351    std::mem::size_of::<RetainedSliceEvidence>().saturating_add(match &evidence.source {
1352        SourceLocation::Path(path) => path.as_os_str().len(),
1353        SourceLocation::Access(_) => 0,
1354    })
1355}
1356
1357fn retained_path_buf_bytes(path: &Path) -> usize {
1358    std::mem::size_of::<PathBuf>().saturating_add(path.as_os_str().len())
1359}
1360
1361fn retained_path_bytes(paths: &[PathBuf]) -> usize {
1362    paths.iter().map(|path| retained_path_buf_bytes(path)).sum()
1363}
1364
1365fn assessment_bytes(outcome: &Par2RepairOutcome) -> usize {
1366    std::mem::size_of::<Par2RepairOutcome>().saturating_add(
1367        outcome
1368            .verification
1369            .files
1370            .iter()
1371            .map(|file| {
1372                std::mem::size_of_val(file)
1373                    .saturating_add(file.filename.capacity())
1374                    .saturating_add(file.valid_slices.capacity())
1375            })
1376            .sum::<usize>(),
1377    )
1378}
1379
1380const _: fn() = || {
1381    fn assert_send<T: Send>() {}
1382    assert_send::<Par2RepairSession>();
1383};
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use crate::checksum;
1389    use crate::evidence::ContiguousAssemblyProof;
1390    use crate::packet::RecoverySliceData;
1391    use crate::par2_set::{FileDescription, Par2FileSet, RecoverySlice};
1392    use crate::session::SliceEvidenceStrength;
1393    use crate::types::{RecoverySetId, SliceChecksum};
1394    use crate::verify::MemoryFileAccess;
1395    use std::collections::BTreeMap;
1396
1397    fn session_from_set(options: Par2RepairSessionOptions, set: Par2FileSet) -> Par2RepairSession {
1398        Par2RepairSession {
1399            state: RepairState::from_set_with_access(
1400                &options.base_dir,
1401                set,
1402                options.source_access.clone(),
1403            )
1404            .unwrap(),
1405            options,
1406            packet_diagnostics: PacketDiagnostics::default(),
1407            committed: Vec::new(),
1408            slice_evidence: HashMap::new(),
1409            merged_recovery_paths: HashSet::new(),
1410            sources_scanned: false,
1411            source_generation: 0,
1412            diagnostics: Par2RepairSessionDiagnostics::default(),
1413            assessment: None,
1414            cache_retention: None,
1415        }
1416    }
1417
1418    fn empty_session(recovery_set_id: RecoverySetId) -> Par2RepairSession {
1419        session_from_set(
1420            Par2RepairSessionOptions::default(),
1421            Par2FileSet {
1422                recovery_set_id,
1423                slice_size: 4,
1424                recovery_file_ids: Vec::new(),
1425                non_recovery_file_ids: Vec::new(),
1426                files: HashMap::new(),
1427                slice_checksums: HashMap::new(),
1428                recovery_slices: BTreeMap::new(),
1429                creator: None,
1430            },
1431        )
1432    }
1433
1434    fn single_file_set(
1435        recovery_set_id: RecoverySetId,
1436        file_id: FileId,
1437        filename: &str,
1438        payload: &[u8],
1439        slice_size: usize,
1440    ) -> Par2FileSet {
1441        let description = FileDescription {
1442            file_id,
1443            hash_full: checksum::md5(payload),
1444            hash_16k: checksum::md5(&payload[..payload.len().min(16 * 1024)]),
1445            length: payload.len() as u64,
1446            par2_name: filename.to_owned(),
1447            filename: filename.to_owned(),
1448        };
1449        let checksums = payload
1450            .chunks(slice_size)
1451            .map(|slice| {
1452                let mut state = checksum::SliceChecksumState::new();
1453                state.update(slice);
1454                let (crc32, md5) = state.finalize(Some(slice_size as u64));
1455                SliceChecksum { crc32, md5 }
1456            })
1457            .collect();
1458        Par2FileSet {
1459            recovery_set_id,
1460            slice_size: slice_size as u64,
1461            recovery_file_ids: vec![file_id],
1462            non_recovery_file_ids: Vec::new(),
1463            files: HashMap::from([(file_id, description)]),
1464            slice_checksums: HashMap::from([(file_id, checksums)]),
1465            recovery_slices: BTreeMap::new(),
1466            creator: None,
1467        }
1468    }
1469
1470    fn write_packet(
1471        path: &Path,
1472        recovery_set_id: RecoverySetId,
1473        packet_type: &[u8; 16],
1474        body: &[u8],
1475    ) {
1476        let mut hash_input = Vec::with_capacity(32 + body.len());
1477        hash_input.extend_from_slice(recovery_set_id.as_bytes());
1478        hash_input.extend_from_slice(packet_type);
1479        hash_input.extend_from_slice(body);
1480        let mut packet = Vec::with_capacity(crate::packet::HEADER_SIZE + body.len());
1481        packet.extend_from_slice(crate::packet::MAGIC);
1482        packet.extend_from_slice(
1483            &(crate::packet::HEADER_SIZE as u64 + body.len() as u64).to_le_bytes(),
1484        );
1485        packet.extend_from_slice(&checksum::md5(&hash_input));
1486        packet.extend_from_slice(recovery_set_id.as_bytes());
1487        packet.extend_from_slice(packet_type);
1488        packet.extend_from_slice(body);
1489        fs::write(path, packet).unwrap();
1490    }
1491
1492    fn write_creator_packet(path: &Path, recovery_set_id: RecoverySetId, creator_len: usize) {
1493        let mut body = vec![b'x'; creator_len];
1494        while !body.len().is_multiple_of(4) {
1495            body.push(0);
1496        }
1497        write_packet(
1498            path,
1499            recovery_set_id,
1500            crate::packet::header::TYPE_CREATOR,
1501            &body,
1502        );
1503    }
1504
1505    #[test]
1506    fn rejects_slice_evidence_from_another_recovery_set() {
1507        let set_id = RecoverySetId::from_bytes([1; 16]);
1508        let mut session = empty_session(set_id);
1509        let evidence = SliceEvidence::for_test(
1510            RecoverySetId::from_bytes([2; 16]),
1511            FileId::from_bytes([3; 16]),
1512            0,
1513            true,
1514            SliceEvidenceStrength::Crc32AndMd5,
1515        );
1516
1517        assert!(matches!(
1518            session.add_slice_evidence("payload.bin", evidence),
1519            Err(Par2SessionError::Par2(Par2Error::ConflictingRecoverySet))
1520        ));
1521    }
1522
1523    #[test]
1524    fn rejects_crc_only_slice_evidence_for_repair() {
1525        let set_id = RecoverySetId::from_bytes([1; 16]);
1526        let mut session = empty_session(set_id);
1527        let evidence = SliceEvidence::for_test(
1528            set_id,
1529            FileId::from_bytes([3; 16]),
1530            0,
1531            true,
1532            SliceEvidenceStrength::Crc32Only,
1533        );
1534
1535        match session.add_slice_evidence("payload.bin", evidence) {
1536            Err(Par2SessionError::InvalidState { reason }) => assert!(
1537                reason.contains("unattested"),
1538                "the refusal must name what is missing, got: {reason}"
1539            ),
1540            other => panic!("expected a named refusal, got {other:?}"),
1541        }
1542    }
1543
1544    /// The recovery-set gate runs before admissibility: an attestation cannot
1545    /// carry a verdict into a set it does not belong to.
1546    #[test]
1547    fn rejects_in_stream_evidence_from_another_recovery_set() {
1548        let set_id = RecoverySetId::from_bytes([1; 16]);
1549        let mut session = empty_session(set_id);
1550        let evidence = in_stream_evidence(
1551            RecoverySetId::from_bytes([2; 16]),
1552            FileId::from_bytes([3; 16]),
1553            0,
1554            true,
1555        );
1556
1557        assert!(matches!(
1558            session.add_slice_evidence("payload.bin", evidence),
1559            Err(Par2SessionError::Par2(Par2Error::ConflictingRecoverySet))
1560        ));
1561    }
1562
1563    /// An attestation says nothing about *which* slice exists. A verdict naming
1564    /// a slice the set does not describe is still refused.
1565    #[test]
1566    fn rejects_in_stream_evidence_for_a_slice_the_set_does_not_describe() {
1567        let dir = tempfile::tempdir().unwrap();
1568        let payload = b"sixteen-byte-pay";
1569        let set_id = RecoverySetId::from_bytes([0x61; 16]);
1570        let file_id = FileId::from_bytes([0x62; 16]);
1571        let mut memory = MemoryFileAccess::new();
1572        memory.add_file(file_id, payload.to_vec());
1573        let mut session = access_session(
1574            dir.path(),
1575            single_file_set(set_id, file_id, "payload.bin", payload, 8),
1576            Arc::new(memory),
1577        );
1578
1579        // The file has two 8-byte slices; slice 9 is not one of them.
1580        assert!(matches!(
1581            session.add_slice_evidence_for_file(in_stream_evidence(set_id, file_id, 9, true)),
1582            Err(Par2SessionError::EvidenceDoesNotMatch { .. })
1583        ));
1584        // An unknown file is refused the same way.
1585        assert!(matches!(
1586            session.add_slice_evidence_for_file(in_stream_evidence(
1587                set_id,
1588                FileId::from_bytes([0x63; 16]),
1589                0,
1590                true
1591            )),
1592            Err(Par2SessionError::EvidenceDoesNotMatch { .. })
1593        ));
1594    }
1595
1596    /// Excluding a path a live session had already resolved a block from
1597    /// retires that resolution: the caller has just said those bytes belong to
1598    /// something else, and a repair must not read them. Re-admitting the path
1599    /// lets the next analysis find it again.
1600    #[test]
1601    fn excluding_a_path_retires_what_the_session_resolved_from_it() {
1602        let dir = tempfile::tempdir().unwrap();
1603        let payload = b"sixteen-byte-pay";
1604        let set_id = RecoverySetId::from_bytes([0x51; 16]);
1605        let file_id = FileId::from_bytes([0x52; 16]);
1606        // The described file is absent; only this foreign volume holds the
1607        // bytes, so every located block comes from it.
1608        let foreign = dir.path().join("other-set.rar");
1609        fs::write(&foreign, payload).unwrap();
1610
1611        let options = Par2RepairSessionOptions::new(dir.path().to_path_buf(), Vec::new());
1612        let mut session = session_from_set(
1613            options,
1614            single_file_set(set_id, file_id, "payload.bin", payload, 8),
1615        );
1616        let found = session.analyze().unwrap();
1617        assert_eq!(session.diagnostics().source_scan_passes, 1);
1618        assert_eq!(
1619            session.diagnostics().scan.bytes_scanned,
1620            payload.len() as u64
1621        );
1622        assert_eq!(found.verification.total_missing_blocks, 0);
1623
1624        session.set_exclude_paths(vec![foreign.clone()]);
1625        let retired = session.analyze().unwrap();
1626        assert_eq!(session.diagnostics().source_scan_passes, 2);
1627        assert_eq!(
1628            session.diagnostics().scan.bytes_scanned,
1629            0,
1630            "the excluded volume must not be re-read"
1631        );
1632        assert!(retired.verification.total_missing_blocks > 0);
1633
1634        // Re-setting the same list is inert: no scan, same verdict.
1635        session.set_exclude_paths(vec![foreign.clone()]);
1636        session.analyze().unwrap();
1637        assert_eq!(session.diagnostics().source_scan_passes, 2);
1638
1639        // Re-admitting it makes the bytes findable again.
1640        session.set_exclude_paths(Vec::new());
1641        let readmitted = session.analyze().unwrap();
1642        assert_eq!(session.diagnostics().source_scan_passes, 3);
1643        assert_eq!(readmitted.verification.total_missing_blocks, 0);
1644    }
1645
1646    /// The exclusion reaches every scanning pass the session runs, not just
1647    /// the first. That matters because a re-analysis after an invalidation —
1648    /// the shape a post-repair verification takes — rebuilds the candidate
1649    /// list from scratch, and would otherwise read the foreign volume a second
1650    /// time for the same nothing.
1651    #[test]
1652    fn session_scan_honours_exclusions_on_every_pass() {
1653        let dir = tempfile::tempdir().unwrap();
1654        let payload = b"sixteen-byte-pay";
1655        let set_id = RecoverySetId::from_bytes([0x41; 16]);
1656        let file_id = FileId::from_bytes([0x42; 16]);
1657        // A volume of some other recovery set that happens to hold these
1658        // bytes: if it were scanned, the counters would say so.
1659        let foreign = dir.path().join("other-set.rar");
1660        fs::write(&foreign, payload).unwrap();
1661
1662        let mut discovering = Par2RepairSessionOptions::new(dir.path().to_path_buf(), Vec::new());
1663        discovering.discover_extras = true;
1664        let mut control = session_from_set(
1665            discovering,
1666            single_file_set(set_id, file_id, "payload.bin", payload, 8),
1667        );
1668        control.analyze().unwrap();
1669        assert_eq!(
1670            control.diagnostics().scan.bytes_scanned,
1671            payload.len() as u64
1672        );
1673
1674        let mut options = Par2RepairSessionOptions::new(dir.path().to_path_buf(), Vec::new());
1675        options.exclude_paths = vec![foreign];
1676        let mut session = session_from_set(
1677            options,
1678            single_file_set(set_id, file_id, "payload.bin", payload, 8),
1679        );
1680
1681        session.analyze().unwrap();
1682        assert_eq!(session.diagnostics().source_scan_passes, 1);
1683        assert_eq!(session.diagnostics().scan.bytes_scanned, 0);
1684
1685        session.invalidate_all_sources();
1686        session.analyze().unwrap();
1687        assert_eq!(session.diagnostics().source_scan_passes, 2);
1688        assert_eq!(session.diagnostics().scan.bytes_scanned, 0);
1689    }
1690
1691    /// The FileId-keyed form still requires a handle to read those bytes with.
1692    /// An attestation does not substitute for one.
1693    #[test]
1694    fn in_stream_evidence_for_file_still_requires_an_access_backed_session() {
1695        let set_id = RecoverySetId::from_bytes([1; 16]);
1696        let mut session = empty_session(set_id);
1697
1698        assert!(matches!(
1699            session.add_slice_evidence_for_file(in_stream_evidence(
1700                set_id,
1701                FileId::from_bytes([3; 16]),
1702                0,
1703                true
1704            )),
1705            Err(Par2SessionError::InvalidState { .. })
1706        ));
1707    }
1708
1709    /// The acceptance case: attested in-stream CRC32 verdicts seed every slice
1710    /// of an access-backed file, and the session verifies it without a scan and
1711    /// without ever having been handed the bytes.
1712    #[test]
1713    fn in_stream_crc32_evidence_seeds_repair_input_without_md5() {
1714        let dir = tempfile::tempdir().unwrap();
1715        let payload = b"sixteen-byte-pay";
1716        let set_id = RecoverySetId::from_bytes([0x71; 16]);
1717        let file_id = FileId::from_bytes([0x72; 16]);
1718        // A decoy shares the name and length; resolving through it would show.
1719        fs::write(dir.path().join("payload.bin"), vec![0xEE; payload.len()]).unwrap();
1720        let mut memory = MemoryFileAccess::new();
1721        memory.add_file(file_id, payload.to_vec());
1722        let mut session = access_session(
1723            dir.path(),
1724            single_file_set(set_id, file_id, "payload.bin", payload, 8),
1725            Arc::new(memory),
1726        );
1727
1728        for slice_index in 0..2 {
1729            session
1730                .add_slice_evidence_for_file(in_stream_evidence(set_id, file_id, slice_index, true))
1731                .expect("an attested in-stream verdict seeds repair input");
1732        }
1733
1734        assert_eq!(
1735            session.analyze().unwrap().status,
1736            Par2RepairStatus::Verified
1737        );
1738        assert_eq!(session.diagnostics().access_slice_evidence, 2);
1739        assert_eq!(session.diagnostics().source_scan_passes, 0);
1740        assert_eq!(session.diagnostics().scan.bytes_scanned, 0);
1741    }
1742
1743    /// A contradicting verdict routes into the same invalidation any other
1744    /// evidence class does, which for an access-backed source is `invalidate_file`:
1745    /// the source is named by file identity, so retiring it retires the whole
1746    /// file, not the one slice. A caller wanting the other slices to survive
1747    /// simply does not seed the damaged one.
1748    #[test]
1749    fn contradicting_in_stream_verdict_retires_the_access_source_it_named() {
1750        let dir = tempfile::tempdir().unwrap();
1751        let payload = b"sixteen-byte-pay";
1752        let set_id = RecoverySetId::from_bytes([0x81; 16]);
1753        let file_id = FileId::from_bytes([0x82; 16]);
1754        let mut memory = MemoryFileAccess::new();
1755        memory.add_file(file_id, payload.to_vec());
1756        let mut session = access_session(
1757            dir.path(),
1758            single_file_set(set_id, file_id, "payload.bin", payload, 8),
1759            Arc::new(memory),
1760        );
1761
1762        for slice_index in 0..2 {
1763            session
1764                .add_slice_evidence_for_file(in_stream_evidence(set_id, file_id, slice_index, true))
1765                .unwrap();
1766        }
1767        assert_eq!(
1768            session.analyze().unwrap().status,
1769            Par2RepairStatus::Verified
1770        );
1771
1772        // The same slice, now contradicted.
1773        session
1774            .add_slice_evidence_for_file(in_stream_evidence(set_id, file_id, 1, false))
1775            .expect("a contradicting verdict is admissible");
1776
1777        assert!(session.assessment.is_none());
1778        assert!(
1779            session
1780                .state
1781                .blocks
1782                .iter()
1783                .all(|block| block.location.is_none()),
1784            "invalidating an access-backed source retires every block of the \
1785             file it names, including slice 0, which was never contradicted"
1786        );
1787        assert_ne!(
1788            session.analyze().unwrap().status,
1789            Par2RepairStatus::Verified
1790        );
1791    }
1792
1793    /// The generation counter is how a caller asks "is my view still current?".
1794    /// In-stream verdicts live under it exactly as session-hashed ones do.
1795    #[test]
1796    fn in_stream_evidence_is_retired_by_an_access_generation_bump() {
1797        let dir = tempfile::tempdir().unwrap();
1798        let payload = b"sixteen-byte-pay";
1799        let path = dir.path().join("payload.bin");
1800        fs::write(&path, payload).unwrap();
1801        let set_id = RecoverySetId::from_bytes([0x91; 16]);
1802        let file_id = FileId::from_bytes([0x92; 16]);
1803        let mut memory = MemoryFileAccess::new();
1804        memory.add_file(file_id, payload.to_vec());
1805        let mut session = access_session(
1806            dir.path(),
1807            single_file_set(set_id, file_id, "payload.bin", payload, 8),
1808            Arc::new(memory),
1809        );
1810        assert_eq!(session.source_generation(), 0);
1811
1812        // One physical, one virtual — only the virtual one should be retired.
1813        session
1814            .add_slice_evidence(&path, in_stream_evidence(set_id, file_id, 0, true))
1815            .unwrap();
1816        session
1817            .add_slice_evidence_for_file(in_stream_evidence(set_id, file_id, 1, true))
1818            .unwrap();
1819        assert_eq!(session.diagnostics().slice_evidence, 2);
1820        assert_eq!(session.diagnostics().access_slice_evidence, 1);
1821
1822        assert_eq!(session.invalidate_access_sources(), 1);
1823
1824        assert_eq!(session.source_generation(), 1);
1825        assert_eq!(session.diagnostics().slice_evidence, 1);
1826        assert_eq!(session.diagnostics().access_slice_evidence, 0);
1827        assert!(session.state.blocks[0].location.is_some());
1828        assert!(session.state.blocks[1].location.is_none());
1829    }
1830
1831    #[test]
1832    fn assessment_and_repair_require_analysis() {
1833        let mut session = empty_session(RecoverySetId::from_bytes([1; 16]));
1834
1835        assert!(matches!(
1836            session.assessment(),
1837            Err(Par2SessionError::InvalidState { .. })
1838        ));
1839        assert!(matches!(
1840            session.repair(),
1841            Err(Par2SessionError::InvalidState { .. })
1842        ));
1843    }
1844
1845    #[test]
1846    fn analyze_limit_rejection_discards_scanned_locations() {
1847        let dir = tempfile::tempdir().unwrap();
1848        let payload = b"data";
1849        fs::write(dir.path().join("payload.bin"), payload).unwrap();
1850        let recovery_set_id = RecoverySetId::from_bytes([1; 16]);
1851        let file_id = FileId::from_bytes([2; 16]);
1852        let mut session = session_from_set(
1853            Par2RepairSessionOptions {
1854                base_dir: dir.path().to_path_buf(),
1855                ..Par2RepairSessionOptions::default()
1856            },
1857            single_file_set(recovery_set_id, file_id, "payload.bin", payload, 4),
1858        );
1859        session.options.retained_state_limit = session.estimated_retained_bytes();
1860
1861        assert!(matches!(
1862            session.analyze(),
1863            Err(Par2SessionError::RetainedStateLimitExceeded { .. })
1864        ));
1865        assert!(!session.sources_scanned);
1866        assert!(session.assessment.is_none());
1867        assert!(
1868            session
1869                .state
1870                .blocks
1871                .iter()
1872                .all(|block| block.location.is_none())
1873        );
1874        assert!(session.estimated_retained_bytes() <= session.options.retained_state_limit);
1875    }
1876
1877    #[test]
1878    fn creator_only_recovery_merge_obeys_exact_retained_limit_transactionally() {
1879        let dir = tempfile::tempdir().unwrap();
1880        let recovery_set_id = RecoverySetId::from_bytes([7; 16]);
1881        let path = dir.path().join("creator.vol.par2");
1882        write_creator_packet(&path, recovery_set_id, 64 * 1024);
1883        let mut session = empty_session(recovery_set_id);
1884        let before = session.estimated_retained_bytes();
1885        session.options.retained_state_limit =
1886            before.saturating_add(retained_path_buf_bytes(&path));
1887
1888        assert!(matches!(
1889            session.merge_recovery_paths([&path]),
1890            Err(Par2SessionError::RetainedStateLimitExceeded { .. })
1891        ));
1892        assert!(session.state.set.creator.is_none());
1893        assert!(session.merged_recovery_paths.is_empty());
1894        assert_eq!(session.diagnostics().recovery_paths_merged, 0);
1895        assert_eq!(session.estimated_retained_bytes(), before);
1896    }
1897
1898    #[test]
1899    fn strong_slice_evidence_uses_its_explicit_path() {
1900        let dir = tempfile::tempdir().unwrap();
1901        let payload = b"live";
1902        let path = dir.path().join("payload.bin");
1903        fs::write(&path, payload).unwrap();
1904        let set_id = RecoverySetId::from_bytes([1; 16]);
1905        let file_id = FileId::from_bytes([3; 16]);
1906        let mut files = HashMap::new();
1907        files.insert(
1908            file_id,
1909            FileDescription {
1910                file_id,
1911                hash_full: checksum::md5(payload),
1912                hash_16k: checksum::md5(payload),
1913                length: payload.len() as u64,
1914                par2_name: "payload.bin".to_owned(),
1915                filename: "payload.bin".to_owned(),
1916            },
1917        );
1918        let mut slice_checksums = HashMap::new();
1919        slice_checksums.insert(
1920            file_id,
1921            vec![SliceChecksum {
1922                crc32: checksum::crc32(payload),
1923                md5: checksum::md5(payload),
1924            }],
1925        );
1926        let mut session = session_from_set(
1927            Par2RepairSessionOptions {
1928                base_dir: dir.path().to_path_buf(),
1929                ..Par2RepairSessionOptions::default()
1930            },
1931            Par2FileSet {
1932                recovery_set_id: set_id,
1933                slice_size: payload.len() as u64,
1934                recovery_file_ids: vec![file_id],
1935                non_recovery_file_ids: Vec::new(),
1936                files,
1937                slice_checksums,
1938                recovery_slices: BTreeMap::new(),
1939                creator: None,
1940            },
1941        );
1942        let evidence =
1943            SliceEvidence::for_test(set_id, file_id, 0, true, SliceEvidenceStrength::Crc32AndMd5);
1944
1945        session.add_slice_evidence(&path, evidence).unwrap();
1946        assert_eq!(session.diagnostics().live_slices, 1);
1947        assert_eq!(
1948            session.analyze().unwrap().status,
1949            Par2RepairStatus::Verified
1950        );
1951        let scan_passes = session.diagnostics().source_scan_passes;
1952        session.add_slice_evidence(&path, evidence).unwrap();
1953        assert_eq!(session.diagnostics().live_slices, 1);
1954        assert_eq!(session.diagnostics().source_scan_passes, scan_passes);
1955        assert!(session.assessment().is_ok());
1956        session.invalidate_path(&path);
1957        assert_eq!(session.diagnostics().slice_evidence, 0);
1958    }
1959
1960    #[test]
1961    fn unusable_lazy_recovery_updates_assessment_without_rescanning_sources() {
1962        let dir = tempfile::tempdir().unwrap();
1963        let payload = b"data";
1964        let recovery_path = dir.path().join("recovery.bin");
1965        fs::write(&recovery_path, b"bad!").unwrap();
1966        let set_id = RecoverySetId::from_bytes([1; 16]);
1967        let file_id = FileId::from_bytes([3; 16]);
1968        let mut files = HashMap::new();
1969        files.insert(
1970            file_id,
1971            FileDescription {
1972                file_id,
1973                hash_full: checksum::md5(payload),
1974                hash_16k: checksum::md5(payload),
1975                length: payload.len() as u64,
1976                par2_name: "payload.bin".to_owned(),
1977                filename: "payload.bin".to_owned(),
1978            },
1979        );
1980        let mut slice_checksums = HashMap::new();
1981        slice_checksums.insert(
1982            file_id,
1983            vec![SliceChecksum {
1984                crc32: checksum::crc32(payload),
1985                md5: checksum::md5(payload),
1986            }],
1987        );
1988        let mut recovery_slices = BTreeMap::new();
1989        recovery_slices.insert(
1990            0,
1991            RecoverySlice {
1992                exponent: 0,
1993                data: RecoverySliceData::file_backed_with_hash(
1994                    recovery_path,
1995                    0,
1996                    payload.len(),
1997                    [0; 16],
1998                ),
1999            },
2000        );
2001        recovery_slices.insert(
2002            1,
2003            RecoverySlice {
2004                exponent: 1,
2005                data: RecoverySliceData::file_backed_with_hash(
2006                    dir.path().join("missing-recovery.bin"),
2007                    0,
2008                    payload.len(),
2009                    [0; 16],
2010                ),
2011            },
2012        );
2013        let mut session = session_from_set(
2014            Par2RepairSessionOptions {
2015                base_dir: dir.path().to_path_buf(),
2016                ..Par2RepairSessionOptions::default()
2017            },
2018            Par2FileSet {
2019                recovery_set_id: set_id,
2020                slice_size: payload.len() as u64,
2021                recovery_file_ids: vec![file_id],
2022                non_recovery_file_ids: Vec::new(),
2023                files,
2024                slice_checksums,
2025                recovery_slices,
2026                creator: None,
2027            },
2028        );
2029
2030        assert_eq!(
2031            session.analyze().unwrap().status,
2032            Par2RepairStatus::RepairPossible
2033        );
2034        let scan_passes = session.diagnostics().source_scan_passes;
2035        let outcome = session.repair().unwrap();
2036        assert_eq!(outcome.status, Par2RepairStatus::Insufficient);
2037        assert_eq!(session.diagnostics().source_scan_passes, scan_passes);
2038        assert_eq!(session.diagnostics().recovery_packets_rejected, 2);
2039        assert!(!dir.path().join("payload.bin").exists());
2040    }
2041
2042    #[test]
2043    fn renamed_full_md5_evidence_requires_unique_or_explicit_identity() {
2044        let dir = tempfile::tempdir().unwrap();
2045        let payload = b"same-content";
2046        let path = dir.path().join("download-name.bin");
2047        fs::write(&path, payload).unwrap();
2048        let set_id = RecoverySetId::from_bytes([1; 16]);
2049        let first_id = FileId::from_bytes([2; 16]);
2050        let second_id = FileId::from_bytes([3; 16]);
2051        let description = |file_id, name: &str| FileDescription {
2052            file_id,
2053            hash_full: checksum::md5(payload),
2054            hash_16k: checksum::md5(payload),
2055            length: payload.len() as u64,
2056            par2_name: name.to_owned(),
2057            filename: name.to_owned(),
2058        };
2059        let files = HashMap::from([
2060            (first_id, description(first_id, "original-a.bin")),
2061            (second_id, description(second_id, "original-b.bin")),
2062        ]);
2063        let mut session = session_from_set(
2064            Par2RepairSessionOptions {
2065                base_dir: dir.path().to_path_buf(),
2066                ..Par2RepairSessionOptions::default()
2067            },
2068            Par2FileSet {
2069                recovery_set_id: set_id,
2070                slice_size: payload.len() as u64,
2071                recovery_file_ids: vec![first_id, second_id],
2072                non_recovery_file_ids: Vec::new(),
2073                files,
2074                slice_checksums: HashMap::new(),
2075                recovery_slices: BTreeMap::new(),
2076                creator: None,
2077            },
2078        );
2079        let ambiguous = CommittedFileEvidence::from_full_md5_path(
2080            &path,
2081            "download-name.bin",
2082            payload.len() as u64,
2083            checksum::md5(payload),
2084            None,
2085        )
2086        .unwrap();
2087
2088        assert!(matches!(
2089            session.add_committed_file(ambiguous),
2090            Err(Par2SessionError::EvidenceDoesNotMatch { .. })
2091        ));
2092        assert_eq!(session.diagnostics().quick_proof_fallbacks, 1);
2093
2094        let bound = CommittedFileEvidence::from_full_md5_path(
2095            &path,
2096            "download-name.bin",
2097            payload.len() as u64,
2098            checksum::md5(payload),
2099            Some(first_id),
2100        )
2101        .unwrap();
2102        session.add_committed_file(bound).unwrap();
2103        assert_eq!(session.diagnostics().quick_proof_hits, 1);
2104    }
2105
2106    #[test]
2107    fn committed_evidence_fingerprint_is_rechecked_before_cached_analysis() {
2108        let dir = tempfile::tempdir().unwrap();
2109        let payload = b"registered-source";
2110        let path = dir.path().join("renamed.bin");
2111        fs::write(&path, payload).unwrap();
2112        let set_id = RecoverySetId::from_bytes([0x51; 16]);
2113        let file_id = FileId::from_bytes([0x52; 16]);
2114        let mut session = session_from_set(
2115            Par2RepairSessionOptions {
2116                base_dir: dir.path().to_path_buf(),
2117                ..Par2RepairSessionOptions::default()
2118            },
2119            single_file_set(set_id, file_id, "canonical.bin", payload, payload.len()),
2120        );
2121        let evidence = CommittedFileEvidence::from_full_md5_path(
2122            &path,
2123            "renamed.bin",
2124            payload.len() as u64,
2125            checksum::md5(payload),
2126            Some(file_id),
2127        )
2128        .unwrap();
2129        session.add_committed_file(evidence).unwrap();
2130        assert_eq!(
2131            session.analyze().unwrap().status,
2132            Par2RepairStatus::RepairPossible
2133        );
2134
2135        fs::write(&path, b"registered-source-changed").unwrap();
2136        assert!(matches!(
2137            session.analyze(),
2138            Err(Par2SessionError::SourceChanged { path: changed }) if changed == path
2139        ));
2140        assert!(matches!(
2141            session.repair(),
2142            Err(Par2SessionError::SourceChanged { path: changed }) if changed == path
2143        ));
2144    }
2145
2146    #[test]
2147    fn evidence_budget_rejection_is_transactional() {
2148        let dir = tempfile::tempdir().unwrap();
2149        let mut parent = dir.path().to_path_buf();
2150        for index in 0..8 {
2151            parent.push(format!("long-evidence-component-{index:02}-xxxxxxxx"));
2152        }
2153        fs::create_dir_all(&parent).unwrap();
2154        let path = parent.join("renamed.bin");
2155        let payload = vec![0xA5; 64];
2156        fs::write(&path, &payload).unwrap();
2157        let set_id = RecoverySetId::from_bytes([0x61; 16]);
2158        let file_id = FileId::from_bytes([0x62; 16]);
2159        let mut session = session_from_set(
2160            Par2RepairSessionOptions {
2161                base_dir: dir.path().to_path_buf(),
2162                ..Par2RepairSessionOptions::default()
2163            },
2164            single_file_set(set_id, file_id, "canonical.bin", &payload, 1),
2165        );
2166        let evidence = CommittedFileEvidence::from_full_md5_path(
2167            &path,
2168            "renamed.bin",
2169            payload.len() as u64,
2170            checksum::md5(&payload),
2171            Some(file_id),
2172        )
2173        .unwrap();
2174        let before = session.estimated_retained_bytes();
2175        session.options.retained_state_limit =
2176            before.saturating_add(committed_evidence_bytes(&evidence));
2177
2178        assert!(matches!(
2179            session.add_committed_file(evidence),
2180            Err(Par2SessionError::RetainedStateLimitExceeded { .. })
2181        ));
2182        assert_eq!(session.estimated_retained_bytes(), before);
2183        assert!(session.committed.is_empty());
2184        assert!(
2185            session
2186                .state
2187                .files
2188                .iter()
2189                .all(|file| file.complete_location.is_none())
2190        );
2191        assert!(
2192            session
2193                .state
2194                .blocks
2195                .iter()
2196                .all(|block| block.location.is_none())
2197        );
2198
2199        let slice =
2200            SliceEvidence::for_test(set_id, file_id, 0, true, SliceEvidenceStrength::Crc32AndMd5);
2201        session.options.retained_state_limit = before;
2202        assert!(matches!(
2203            session.add_slice_evidence(&path, slice),
2204            Err(Par2SessionError::RetainedStateLimitExceeded { .. })
2205        ));
2206        assert_eq!(session.estimated_retained_bytes(), before);
2207        assert!(session.slice_evidence.is_empty());
2208        assert!(session.state.blocks[0].location.is_none());
2209    }
2210
2211    #[test]
2212    fn retained_limit_defaults_to_64_mib() {
2213        assert_eq!(
2214            Par2RepairSessionOptions::default().retained_state_limit,
2215            DEFAULT_RETAINED_STATE_LIMIT
2216        );
2217    }
2218
2219    #[test]
2220    fn retained_preflight_accounts_for_large_block_maps() {
2221        let file_id = FileId::from_bytes([7; 16]);
2222        let mut files = HashMap::new();
2223        files.insert(
2224            file_id,
2225            FileDescription {
2226                file_id,
2227                hash_full: [0; 16],
2228                hash_16k: [0; 16],
2229                length: 8_192 * 4,
2230                par2_name: "large.bin".to_owned(),
2231                filename: "large.bin".to_owned(),
2232            },
2233        );
2234        let mut slice_checksums = HashMap::new();
2235        slice_checksums.insert(
2236            file_id,
2237            vec![
2238                SliceChecksum {
2239                    crc32: 0,
2240                    md5: [0; 16],
2241                };
2242                8_192
2243            ],
2244        );
2245        let set = Par2FileSet {
2246            recovery_set_id: RecoverySetId::from_bytes([1; 16]),
2247            slice_size: 4,
2248            recovery_file_ids: vec![file_id],
2249            non_recovery_file_ids: Vec::new(),
2250            files,
2251            slice_checksums,
2252            recovery_slices: BTreeMap::new(),
2253            creator: None,
2254        };
2255
2256        let preflight = RepairState::estimated_retained_bytes_from_set(Path::new("."), &set);
2257        let state = RepairState::from_set(Path::new("."), set).unwrap();
2258        assert!(preflight > 1024 * 1024);
2259        assert!(preflight >= state.estimated_retained_bytes());
2260    }
2261
2262    fn access_session(
2263        dir: &Path,
2264        set: Par2FileSet,
2265        access: Arc<dyn FileAccess + Send + Sync>,
2266    ) -> Par2RepairSession {
2267        let mut options = Par2RepairSessionOptions::new(dir.to_path_buf(), Vec::new());
2268        options.source_access = Some(access);
2269        session_from_set(options, set)
2270    }
2271
2272    fn strong_evidence(
2273        set_id: RecoverySetId,
2274        file_id: FileId,
2275        slice_index: u32,
2276        valid: bool,
2277    ) -> SliceEvidence {
2278        SliceEvidence::for_test(
2279            set_id,
2280            file_id,
2281            slice_index,
2282            valid,
2283            SliceEvidenceStrength::Crc32AndMd5,
2284        )
2285    }
2286
2287    /// A verdict shaped like one a downloader derives in stream: CRC32 only,
2288    /// carrying the attestation that admits it.
2289    fn in_stream_evidence(
2290        set_id: RecoverySetId,
2291        file_id: FileId,
2292        slice_index: u32,
2293        valid: bool,
2294    ) -> SliceEvidence {
2295        SliceEvidence::from_in_stream_crc32(
2296            set_id,
2297            file_id,
2298            slice_index,
2299            valid,
2300            crate::session::InStreamCrc32Proof::try_new(8, true, true, true).unwrap(),
2301        )
2302    }
2303
2304    /// A session over virtual sources resolves what evidence named and nothing
2305    /// else, and never records a scan pass — `base_dir` holds no sources.
2306    #[test]
2307    fn access_backed_analysis_resolves_evidence_without_scanning() {
2308        let dir = tempfile::tempdir().unwrap();
2309        let payload = b"eight-bytes-of-payload!!";
2310        let set_id = RecoverySetId::from_bytes([0x11; 16]);
2311        let file_id = FileId::from_bytes([0x12; 16]);
2312        // A decoy with the right name and length sits where a scan would find
2313        // it. Its bytes are wrong, so resolving through it would be visible.
2314        fs::write(dir.path().join("payload.bin"), vec![0xEE; payload.len()]).unwrap();
2315        let mut memory = MemoryFileAccess::new();
2316        memory.add_file(file_id, payload.to_vec());
2317        let mut session = access_session(
2318            dir.path(),
2319            single_file_set(set_id, file_id, "payload.bin", payload, 8),
2320            Arc::new(memory),
2321        );
2322
2323        session
2324            .add_slice_evidence_for_file(strong_evidence(set_id, file_id, 0, true))
2325            .unwrap();
2326        let assessment = session.analyze().unwrap();
2327
2328        assert_eq!(assessment.status, Par2RepairStatus::Insufficient);
2329        assert_eq!(assessment.available_blocks, 1);
2330        assert_eq!(assessment.missing_blocks, 2);
2331        assert_eq!(session.diagnostics().source_scan_passes, 0);
2332        assert_eq!(session.diagnostics().scan.files_scanned, 0);
2333        assert_eq!(session.diagnostics().scan.bytes_scanned, 0);
2334        assert_eq!(session.diagnostics().access_slice_evidence, 1);
2335    }
2336
2337    /// All slices seeded through the handle promote the file to complete, with
2338    /// no `stat` on the decoy that shares its name.
2339    #[test]
2340    fn fully_seeded_access_file_verifies_without_touching_its_path() {
2341        let dir = tempfile::tempdir().unwrap();
2342        let payload = b"sixteen-byte-pay";
2343        let set_id = RecoverySetId::from_bytes([0x21; 16]);
2344        let file_id = FileId::from_bytes([0x22; 16]);
2345        fs::write(dir.path().join("payload.bin"), vec![0xEE; payload.len()]).unwrap();
2346        let mut memory = MemoryFileAccess::new();
2347        memory.add_file(file_id, payload.to_vec());
2348        let mut session = access_session(
2349            dir.path(),
2350            single_file_set(set_id, file_id, "payload.bin", payload, 8),
2351            Arc::new(memory),
2352        );
2353
2354        for slice_index in 0..2 {
2355            session
2356                .add_slice_evidence_for_file(strong_evidence(set_id, file_id, slice_index, true))
2357                .unwrap();
2358        }
2359
2360        assert_eq!(
2361            session.analyze().unwrap().status,
2362            Par2RepairStatus::Verified
2363        );
2364    }
2365
2366    #[test]
2367    fn invalidate_file_forgets_access_locations_and_resets_analysis() {
2368        let dir = tempfile::tempdir().unwrap();
2369        let payload = b"sixteen-byte-pay";
2370        let set_id = RecoverySetId::from_bytes([0x31; 16]);
2371        let file_id = FileId::from_bytes([0x32; 16]);
2372        let mut memory = MemoryFileAccess::new();
2373        memory.add_file(file_id, payload.to_vec());
2374        let mut session = access_session(
2375            dir.path(),
2376            single_file_set(set_id, file_id, "payload.bin", payload, 8),
2377            Arc::new(memory),
2378        );
2379        let empty_bytes = session.estimated_retained_bytes();
2380        for slice_index in 0..2 {
2381            session
2382                .add_slice_evidence_for_file(strong_evidence(set_id, file_id, slice_index, true))
2383                .unwrap();
2384        }
2385        session.analyze().unwrap();
2386        let seeded_bytes = session.estimated_retained_bytes();
2387        assert!(seeded_bytes > empty_bytes);
2388
2389        assert!(session.invalidate_file(file_id));
2390
2391        assert!(session.slice_evidence.is_empty());
2392        assert_eq!(session.diagnostics().slice_evidence, 0);
2393        assert_eq!(session.diagnostics().access_slice_evidence, 0);
2394        assert!(session.assessment.is_none());
2395        assert!(matches!(
2396            session.assessment(),
2397            Err(Par2SessionError::InvalidState { .. })
2398        ));
2399        assert!(
2400            session
2401                .state
2402                .blocks
2403                .iter()
2404                .all(|block| block.location.is_none())
2405        );
2406        assert!(
2407            session
2408                .state
2409                .files
2410                .iter()
2411                .all(|file| file.complete_location.is_none())
2412        );
2413        assert!(session.estimated_retained_bytes() < seeded_bytes);
2414        assert!(!session.invalidate_file(file_id));
2415    }
2416
2417    /// The generation is the cheap thing a caller compares. Bumping it retires
2418    /// every virtual location at once and leaves physical ones alone.
2419    #[test]
2420    fn access_source_generation_bump_retires_only_virtual_locations() {
2421        let dir = tempfile::tempdir().unwrap();
2422        let payload = b"sixteen-byte-pay";
2423        let path = dir.path().join("payload.bin");
2424        fs::write(&path, payload).unwrap();
2425        let set_id = RecoverySetId::from_bytes([0x41; 16]);
2426        let file_id = FileId::from_bytes([0x42; 16]);
2427        let mut memory = MemoryFileAccess::new();
2428        memory.add_file(file_id, payload.to_vec());
2429        let mut session = access_session(
2430            dir.path(),
2431            single_file_set(set_id, file_id, "payload.bin", payload, 8),
2432            Arc::new(memory),
2433        );
2434        assert_eq!(session.source_generation(), 0);
2435
2436        session
2437            .add_slice_evidence(&path, strong_evidence(set_id, file_id, 0, true))
2438            .unwrap();
2439        session
2440            .add_slice_evidence_for_file(strong_evidence(set_id, file_id, 1, true))
2441            .unwrap();
2442        assert_eq!(session.diagnostics().slice_evidence, 2);
2443        assert_eq!(session.diagnostics().access_slice_evidence, 1);
2444        let seeded_bytes = session.estimated_retained_bytes();
2445
2446        assert_eq!(session.invalidate_access_sources(), 1);
2447
2448        assert_eq!(session.source_generation(), 1);
2449        assert_eq!(session.diagnostics().source_generation, 1);
2450        assert_eq!(session.diagnostics().slice_evidence, 1);
2451        assert_eq!(session.diagnostics().access_slice_evidence, 0);
2452        assert!(session.state.blocks[0].location.is_some());
2453        assert!(session.state.blocks[1].location.is_none());
2454        assert!(session.estimated_retained_bytes() < seeded_bytes);
2455
2456        // Monotonic: a second bump moves forward, never back.
2457        assert_eq!(session.invalidate_access_sources(), 2);
2458        assert!(session.state.blocks[0].location.is_some());
2459    }
2460
2461    /// A FileId owns no heap, but the record holding it does. The budget must
2462    /// see that record, or a session could retain evidence for free.
2463    #[test]
2464    fn access_keyed_slice_evidence_obeys_the_retained_limit() {
2465        let dir = tempfile::tempdir().unwrap();
2466        let payload = b"sixteen-byte-pay";
2467        let set_id = RecoverySetId::from_bytes([0x51; 16]);
2468        let file_id = FileId::from_bytes([0x52; 16]);
2469        let mut memory = MemoryFileAccess::new();
2470        memory.add_file(file_id, payload.to_vec());
2471        let mut session = access_session(
2472            dir.path(),
2473            single_file_set(set_id, file_id, "payload.bin", payload, 8),
2474            Arc::new(memory),
2475        );
2476        let before = session.estimated_retained_bytes();
2477        session.options.retained_state_limit = before;
2478
2479        assert!(matches!(
2480            session.add_slice_evidence_for_file(strong_evidence(set_id, file_id, 0, true)),
2481            Err(Par2SessionError::RetainedStateLimitExceeded { .. })
2482        ));
2483        assert_eq!(session.estimated_retained_bytes(), before);
2484        assert!(session.slice_evidence.is_empty());
2485        assert!(session.state.blocks[0].location.is_none());
2486
2487        // One record's worth of headroom is exactly enough for one record.
2488        let record_bytes = std::mem::size_of::<(FileId, u32)>()
2489            .saturating_add(std::mem::size_of::<RetainedSliceEvidence>());
2490        session.options.retained_state_limit = before.saturating_add(record_bytes);
2491        session
2492            .add_slice_evidence_for_file(strong_evidence(set_id, file_id, 0, true))
2493            .unwrap();
2494        assert_eq!(session.diagnostics().access_slice_evidence, 1);
2495    }
2496
2497    #[test]
2498    fn contiguous_evidence_stat_binding_rejects_size_change() {
2499        let dir = tempfile::tempdir().unwrap();
2500        let path = dir.path().join("assembled.bin");
2501        let original = b"contiguous-article-data";
2502        fs::write(&path, original).unwrap();
2503        let proof = ContiguousAssemblyProof::try_new(
2504            original.len() as u64,
2505            original.len() as u64,
2506            original.len() as u64,
2507            false,
2508            false,
2509            false,
2510            true,
2511        )
2512        .unwrap();
2513        let evidence = CommittedFileEvidence::from_contiguous_assembly_path(
2514            &path,
2515            "assembled.bin",
2516            original.len() as u64,
2517            checksum::crc32(original),
2518            checksum::md5(original),
2519            proof,
2520            None,
2521        )
2522        .unwrap();
2523        assert!(evidence_stat_matches(&evidence).unwrap());
2524
2525        fs::write(&path, b"contiguous-article-data!").unwrap();
2526        assert!(!evidence_stat_matches(&evidence).unwrap());
2527    }
2528
2529    /// `open` accepts an already-parsed set, so a caller whose `.par2` volumes
2530    /// never became files does not have to write them out to be read back.
2531    /// Every other session test builds the struct directly; this one goes
2532    /// through the public constructor, which is the whole point.
2533    #[test]
2534    fn open_takes_a_parsed_set_instead_of_reading_par2_files() {
2535        let dir = tempfile::tempdir().unwrap();
2536        let payload = b"eight-bytes-of-payload!!";
2537        let set_id = RecoverySetId::from_bytes([0x71; 16]);
2538        let file_id = FileId::from_bytes([0x72; 16]);
2539        let mut memory = MemoryFileAccess::new();
2540        memory.add_file(file_id, payload.to_vec());
2541
2542        let mut session = Par2RepairSession::open(Par2RepairSessionOptions::from_set(
2543            dir.path().to_path_buf(),
2544            single_file_set(set_id, file_id, "payload.bin", payload, 8),
2545            Arc::new(memory),
2546        ))
2547        .expect("a parsed set needs no PAR2 files on disk");
2548
2549        assert!(session.is_access_backed());
2550        session
2551            .add_slice_evidence_for_file(strong_evidence(set_id, file_id, 0, true))
2552            .unwrap();
2553        let assessment = session.analyze().unwrap();
2554
2555        assert_eq!(assessment.status, Par2RepairStatus::Insufficient);
2556        assert_eq!(assessment.available_blocks, 1);
2557        assert_eq!(assessment.missing_blocks, 2);
2558        // Nothing was read by path: no PAR2 file existed to read, and the
2559        // sources live behind the handle.
2560        assert_eq!(session.diagnostics().source_scan_passes, 0);
2561        assert_eq!(session.diagnostics().scan.bytes_scanned, 0);
2562    }
2563
2564    /// Re-pointing at a new handle serves reads from it, and retires what the
2565    /// old one had proven — the new handle's coverage is its own.
2566    #[test]
2567    fn set_source_access_adopts_the_new_handle_and_retires_the_old() {
2568        let dir = tempfile::tempdir().unwrap();
2569        let payload = b"eight-bytes-of-payload!!";
2570        let set_id = RecoverySetId::from_bytes([0x81; 16]);
2571        let file_id = FileId::from_bytes([0x82; 16]);
2572        let mut first = MemoryFileAccess::new();
2573        first.add_file(file_id, payload.to_vec());
2574        let mut session = access_session(
2575            dir.path(),
2576            single_file_set(set_id, file_id, "payload.bin", payload, 8),
2577            Arc::new(first),
2578        );
2579        session
2580            .add_slice_evidence_for_file(strong_evidence(set_id, file_id, 0, true))
2581            .unwrap();
2582        assert_eq!(session.analyze().unwrap().available_blocks, 1);
2583
2584        let mut second = MemoryFileAccess::new();
2585        second.add_file(file_id, payload.to_vec());
2586        let generation = session.set_source_access(Arc::new(second)).unwrap();
2587
2588        assert_eq!(generation, session.source_generation());
2589        // The evidence was read through the retired handle, so it goes with it
2590        // rather than being credited to a handle that never served it.
2591        assert_eq!(session.analyze().unwrap().available_blocks, 0);
2592        session
2593            .add_slice_evidence_for_file(strong_evidence(set_id, file_id, 0, true))
2594            .unwrap();
2595        assert_eq!(session.analyze().unwrap().available_blocks, 1);
2596    }
2597
2598    /// A filesystem session refuses a handle rather than quietly redefining
2599    /// where its sources come from.
2600    #[test]
2601    fn set_source_access_refuses_a_filesystem_session() {
2602        let dir = tempfile::tempdir().unwrap();
2603        let set_id = RecoverySetId::from_bytes([0x91; 16]);
2604        let file_id = FileId::from_bytes([0x92; 16]);
2605        let mut session = session_from_set(
2606            Par2RepairSessionOptions::new(dir.path().to_path_buf(), Vec::new()),
2607            single_file_set(set_id, file_id, "payload.bin", b"eight-byt", 8),
2608        );
2609
2610        assert!(
2611            session
2612                .set_source_access(Arc::new(MemoryFileAccess::new()))
2613                .is_none()
2614        );
2615        assert!(!session.is_access_backed());
2616    }
2617
2618    /// The control for the test above: without the set, the same options have
2619    /// no PAR2 input at all, and opening is an error rather than an empty
2620    /// session that silently verifies nothing.
2621    #[test]
2622    fn open_without_a_set_or_par2_paths_is_an_error() {
2623        let dir = tempfile::tempdir().unwrap();
2624        let mut options = Par2RepairSessionOptions::new(dir.path().to_path_buf(), Vec::new());
2625        options.source_access = Some(Arc::new(MemoryFileAccess::new()));
2626
2627        assert!(Par2RepairSession::open(options).is_err());
2628    }
2629}