1use 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
51pub const DEFAULT_RETAINED_STATE_LIMIT: usize = 64 * 1024 * 1024;
53
54#[derive(Clone)]
73#[non_exhaustive]
74pub struct Par2RepairSessionOptions {
75 pub base_dir: PathBuf,
78 pub file_set: Option<Par2FileSet>,
85 pub par2_paths: Vec<PathBuf>,
91 pub recovery_paths: Vec<PathBuf>,
93 pub extra_paths: Vec<PathBuf>,
94 pub exclude_paths: Vec<PathBuf>,
107 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 pub trust_seeded_evidence_for_scan: bool,
156 pub cancel: Option<CancellationToken>,
157 pub progress: Option<ProgressCallback>,
158 pub source_access: Option<Arc<dyn FileAccess + Send + Sync>>,
162}
163
164impl Par2RepairSessionOptions {
165 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 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 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#[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 pub access_slice_evidence: u32,
291 pub source_generation: u64,
294}
295
296#[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#[derive(Debug, Clone, PartialEq, Eq)]
324struct RetainedSliceEvidence {
325 source: SourceLocation,
326 valid: bool,
327 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 cache_retention: Option<crate::file_cache::CacheEvictionDeferral>,
354}
355
356impl Par2RepairSession {
357 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 pub fn is_access_backed(&self) -> bool {
400 self.options.source_access.is_some()
401 }
402
403 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 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 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 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 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 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 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 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 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 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 self.assessment = None;
822 self.refresh_diagnostics();
823 Ok(result)
824 }
825
826 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 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 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 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 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 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 pub fn source_generation(&self) -> u64 {
993 self.source_generation
994 }
995
996 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 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 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
1273fn 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
1347fn 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 #[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 #[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 assert!(matches!(
1581 session.add_slice_evidence_for_file(in_stream_evidence(set_id, file_id, 9, true)),
1582 Err(Par2SessionError::EvidenceDoesNotMatch { .. })
1583 ));
1584 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 #[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 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 session.set_exclude_paths(vec![foreign.clone()]);
1636 session.analyze().unwrap();
1637 assert_eq!(session.diagnostics().source_scan_passes, 2);
1638
1639 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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 assert_eq!(session.invalidate_access_sources(), 2);
2458 assert!(session.state.blocks[0].location.is_some());
2459 }
2460
2461 #[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 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 #[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 assert_eq!(session.diagnostics().source_scan_passes, 0);
2561 assert_eq!(session.diagnostics().scan.bytes_scanned, 0);
2562 }
2563
2564 #[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 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 #[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 #[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}