From c5e7147d9cadd80cde8e69932fc5f67cba78491d Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 22:18:49 -0600 Subject: [PATCH 01/30] Match turbo verify repair extra-file behavior --- src/bin/par2.rs | 14 ++ src/par1/verify.rs | 2 + src/repair/mod.rs | 141 +++++++++++--------- src/verify/config.rs | 29 ++++ src/verify/global_engine.rs | 66 ++++++++- src/verify/mod.rs | 2 +- src/verify/types.rs | 9 ++ tests/test_console_verification_reporter.rs | 10 ++ tests/test_silent_verification_reporter.rs | 10 ++ tests/test_turbo_verify_repair_parity.rs | 125 +++++++++++++++++ tests/test_verify.rs | 7 + tests/test_verify_types_comprehensive.rs | 4 + 12 files changed, 351 insertions(+), 68 deletions(-) create mode 100644 tests/test_turbo_verify_repair_parity.rs diff --git a/src/bin/par2.rs b/src/bin/par2.rs index 9fef314d..4738bc95 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -691,6 +691,20 @@ fn handle_verify(matches: &clap::ArgMatches) -> Result<()> { .map(Path::new) .unwrap_or(&file_path); let par2_files = par2rs::par2_files::collect_par2_files(file_name); + let mut par2_files = par2_files; + par2_files.extend( + verify_config + .extra_files + .iter() + .filter(|path| { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("par2")) + }) + .cloned(), + ); + par2_files.sort(); + par2_files.dedup(); // Parse packets excluding recovery slices but validate and count them // Recovery slice data is NOT loaded into memory (saves gigabytes for large PAR2 sets) diff --git a/src/par1/verify.rs b/src/par1/verify.rs index 97cd31cc..538016eb 100644 --- a/src/par1/verify.rs +++ b/src/par1/verify.rs @@ -150,6 +150,7 @@ pub(crate) fn verify_entry(base_dir: &Path, entry: &Par1FileEntry) -> FileVerifi damaged_blocks, block_positions: HashMap::default(), matched_path: None, + block_sources: HashMap::default(), } } @@ -263,6 +264,7 @@ fn file_result_from_match( damaged_blocks, block_positions: HashMap::default(), matched_path: file_match.matched_path.clone(), + block_sources: HashMap::default(), } } diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 31a84dac..4fe071d5 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -43,6 +43,7 @@ pub use types::{ pub use validate::validate_blocks_md5_crc32; use crate::domain::{FileId, LocalSliceIndex, Md5Hash}; +use crate::verify::BlockSource; use crate::RecoverySlicePacket; use error_helpers::*; use log::debug; @@ -216,7 +217,8 @@ impl RepairContext { // Convert FileVerificationResult to ValidationCache let mut validation_cache = ValidationCache::new(); let mut file_status = HashMap::default(); - let mut block_positions_map: HashMap> = HashMap::default(); + let mut block_sources_map: HashMap> = + HashMap::default(); for file_result in &verification_results.files { // Build set of valid block indices @@ -233,8 +235,25 @@ impl RepairContext { validation_cache.insert(file_result.file_id, valid_slices); - // Store block positions for this file (maps block_number -> file_offset) - block_positions_map.insert(file_result.file_id, file_result.block_positions.clone()); + let target_path = self.base_path.join(&file_result.file_name); + let block_sources = if file_result.block_sources.is_empty() { + file_result + .block_positions + .iter() + .map(|(block_number, offset)| { + ( + *block_number, + BlockSource { + file_path: target_path.clone(), + offset: *offset, + }, + ) + }) + .collect() + } else { + file_result.block_sources.clone() + }; + block_sources_map.insert(file_result.file_id, block_sources); // Convert verify::FileStatus to repair::FileStatus let status = match file_result.status { @@ -299,7 +318,7 @@ impl RepairContext { } // Perform the actual repair with validation cache from comprehensive verification - self.perform_reed_solomon_repair(&file_status, &validation_cache, &block_positions_map) + self.perform_reed_solomon_repair(&file_status, &validation_cache, &block_sources_map) } /// Perform Reed-Solomon repair @@ -325,7 +344,7 @@ impl RepairContext { &self, file_status: &HashMap, validation_cache: &ValidationCache, - block_positions_map: &HashMap>, + block_sources_map: &HashMap>, ) -> Result { debug!( "perform_reed_solomon_repair: processing {} files", @@ -405,7 +424,7 @@ impl RepairContext { let reconstructed_data: HashMap> = self.reconstruct_all_missing_slices( &files_to_repair, validation_cache, - block_positions_map, + block_sources_map, )?; // STEP 3: Write reconstructed data to each file @@ -428,8 +447,7 @@ impl RepairContext { .get(&file_info.file_id) .ok_or_else(|| RepairError::NoValidationCache(file_info.file_name.clone()))?; - // Get block positions for this file (maps block_number -> file_offset) - let block_positions = block_positions_map + let block_sources = block_sources_map .get(&file_info.file_id) .cloned() .unwrap_or_default(); @@ -440,7 +458,7 @@ impl RepairContext { file_info, valid_slice_indices, &file_reconstructed, - &block_positions, + &block_sources, ) { Ok(()) => { self.reporter() @@ -539,7 +557,7 @@ impl RepairContext { &self, files_to_repair: &[(&FileInfo, Vec)], validation_cache: &ValidationCache, - block_positions_map: &HashMap>, + block_sources_map: &HashMap>, ) -> Result>> { use self::slice_provider::{ChunkedSliceProvider, RecoverySliceProvider, SliceLocation}; use std::io::Cursor; @@ -593,18 +611,7 @@ impl RepairContext { valid_slices.len() ); - // CRITICAL FIX: Get actual file size to handle truncated files - // If the file is shorter than expected (e.g., truncated), we need to - // use the ACTUAL file size, not the expected size from PAR2 metadata. - // Otherwise we'll try to read past EOF when adding slices to the provider. - let actual_file_size = if file_path.exists() { - fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0) - } else { - 0 - }; - - // Get block positions for this file (maps block_number -> actual file_offset) - let file_block_positions = block_positions_map + let file_block_sources = block_sources_map .get(&file_info.file_id) .cloned() .unwrap_or_default(); @@ -616,11 +623,21 @@ impl RepairContext { let global_index = file_info.local_to_global(LocalSliceIndex::new(slice_index)); - // CRITICAL: Use actual block position from verification if available (handles displaced blocks) - // Otherwise fall back to expected position - let offset = file_block_positions - .get(&(slice_index as u32)) - .map(|&pos| pos as u64) + let block_source = file_block_sources.get(&(slice_index as u32)); + let source_path = block_source + .map(|source| source.file_path.clone()) + .unwrap_or_else(|| file_path.clone()); + + let source_file_size = if source_path.exists() { + fs::metadata(&source_path).map(|m| m.len()).unwrap_or(0) + } else { + 0 + }; + + // CRITICAL: Use actual source location from verification if available. + // Otherwise fall back to expected target-file position. + let offset = block_source + .map(|source| source.offset as u64) .unwrap_or_else(|| { (slice_index * self.recovery_set.slice_size.as_usize()) as u64 }); @@ -639,15 +656,15 @@ impl RepairContext { // CRITICAL FIX: Calculate ACTUAL available bytes in the file for this slice // Handles truncated files where actual_file_size < expected file_length - let actual_size = if offset >= actual_file_size { + let actual_size = if offset >= source_file_size { // Slice is entirely beyond EOF (file severely truncated) debug!( " Slice {} is beyond EOF (offset {} >= file size {}), skipping", - slice_index, offset, actual_file_size + slice_index, offset, source_file_size ); continue; // Skip this slice entirely } else { - let bytes_available = (actual_file_size - offset) as usize; + let bytes_available = (source_file_size - offset) as usize; bytes_available.min(expected_slice_size) }; @@ -661,7 +678,7 @@ impl RepairContext { input_provider.add_slice( global_index.as_usize(), SliceLocation { - file_path: file_path.clone(), + file_path: source_path, offset, actual_size: ActualDataSize::new(actual_size), logical_size: LogicalSliceSize::new( @@ -812,7 +829,7 @@ impl RepairContext { file_info: &FileInfo, valid_slice_indices: &HashSet, reconstructed_slices: &ReconstructedSlices, - block_positions: &HashMap, + block_sources: &HashMap, ) -> Result<()> { debug!("Writing repaired file with streaming I/O: {:?}", file_path); @@ -839,13 +856,8 @@ impl RepairContext { keep: false, }; - // Open source file for reading valid slices - let source_path = self.base_path.join(&file_info.file_name); - let mut source_file = if source_path.exists() { - Some(open_for_reading(&source_path)?) - } else { - None - }; + let target_source_path = self.base_path.join(&file_info.file_name); + let mut source_files: HashMap)> = HashMap::default(); // Create temp output file let file = create_file(&temp_path)?; @@ -855,7 +867,6 @@ impl RepairContext { let slice_size = self.recovery_set.slice_size.as_usize(); let mut slice_buffer = vec![0u8; slice_size]; let mut bytes_written = 0u64; - let mut next_expected_offset: Option = Some(0); for slice_index in 0..file_info.slice_count.as_usize() { let actual_size = if slice_index == file_info.slice_count - 1 { @@ -885,40 +896,48 @@ impl RepairContext { slice_index, )?; bytes_written += actual_size as u64; - // Mark that we've broken the sequential read pattern - next_expected_offset = None; } else if valid_slice_indices.contains(&slice_index) { - // Read from source file - if let Some(ref mut file) = source_file { - // CRITICAL: Use actual block position from verification if available. - // Comprehensive verification finds blocks via byte-by-byte scanning, - // so blocks may be at DISPLACED positions (not at expected aligned offsets). - // block_positions maps block_number -> actual_file_offset where the block was found. - let offset = block_positions - .get(&(slice_index as u32)) - .map(|&pos| pos as u64) - .unwrap_or_else(|| (slice_index * slice_size) as u64); + let source = block_sources + .get(&(slice_index as u32)) + .cloned() + .unwrap_or_else(|| BlockSource { + file_path: target_source_path.clone(), + offset: slice_index * slice_size, + }); + + if source.file_path.exists() { + if !source_files.contains_key(&source.file_path) { + source_files.insert( + source.file_path.clone(), + (open_for_reading(&source.file_path)?, Some(0)), + ); + } + let (file, next_expected_offset) = source_files + .get_mut(&source.file_path) + .ok_or(RepairError::ValidSliceMissingSource(slice_index))?; + let offset = source.offset as u64; debug!( - "Reading slice {} from offset {} ({})", + "Reading slice {} from {:?} offset {} ({})", slice_index, + source.file_path, offset, - if block_positions.contains_key(&(slice_index as u32)) { - "displaced position from verification" + if block_sources.contains_key(&(slice_index as u32)) { + "source location from verification" } else { - "expected aligned position" + "expected aligned target position" } ); // Only seek if we're not already at the right position (optimize sequential reads) - if next_expected_offset != Some(offset) { - seek_file(file, SeekFrom::Start(offset), file_path)?; + if *next_expected_offset != Some(offset) { + seek_file(file, SeekFrom::Start(offset), &source.file_path)?; } read_slice_exact( file, &mut slice_buffer[..actual_size], - file_path, + &source.file_path, slice_index, )?; write_slice_all( @@ -928,7 +947,7 @@ impl RepairContext { slice_index, )?; bytes_written += actual_size as u64; - next_expected_offset = Some(offset + actual_size as u64); + *next_expected_offset = Some(offset + actual_size as u64); } else { return Err(RepairError::ValidSliceMissingSource(slice_index)); } @@ -943,7 +962,7 @@ impl RepairContext { let (mut buffered_writer, computed_md5) = writer.finalize(); flush_writer(&mut buffered_writer, &temp_path)?; drop(buffered_writer); // Close the file before rename - drop(source_file); // Close source file before rename + drop(source_files); // Close source files before rename if bytes_written != file_info.file_length.as_u64() { return Err(RepairError::ByteCountMismatch { diff --git a/src/verify/config.rs b/src/verify/config.rs index c2016fff..e78f802a 100644 --- a/src/verify/config.rs +++ b/src/verify/config.rs @@ -1,6 +1,7 @@ //! Configuration for verification operations use crate::cli::compat::{parse_memory_mb, parse_positive_usize, parse_skip_options}; +use std::path::PathBuf; /// Configuration for file verification operations #[derive(Debug, Clone)] @@ -21,6 +22,8 @@ pub struct VerificationConfig { pub skip_leeway: usize, /// Turbo-compatible rename-only mode for verify/repair. pub rename_only: bool, + /// Additional data files to scan for misplaced or renamed source data. + pub extra_files: Vec, } impl Default for VerificationConfig { @@ -34,6 +37,7 @@ impl Default for VerificationConfig { data_skipping: false, skip_leeway: 0, rename_only: false, + extra_files: Vec::new(), } } } @@ -49,6 +53,7 @@ impl VerificationConfig { data_skipping: false, skip_leeway: 0, rename_only: false, + extra_files: Vec::new(), } } @@ -63,9 +68,15 @@ impl VerificationConfig { data_skipping: false, skip_leeway: 0, rename_only: false, + extra_files: Vec::new(), } } + pub fn with_extra_files(mut self, extra_files: Vec) -> Self { + self.extra_files = extra_files; + self + } + pub fn from_args(matches: &clap::ArgMatches) -> Self { Self::try_from_args(matches).unwrap_or_else(|_| { let threads = matches @@ -128,6 +139,12 @@ impl VerificationConfig { .map_err(|e| e.to_string())? .map(String::as_str), )?; + let extra_files = matches + .try_get_many::("files") + .ok() + .flatten() + .map(|files| files.map(|file| Self::normalize_extra_file(file)).collect()) + .unwrap_or_default(); Ok(Self { threads, @@ -143,9 +160,21 @@ impl VerificationConfig { .flatten() .copied() .unwrap_or(false), + extra_files, }) } + fn normalize_extra_file(file: &str) -> PathBuf { + let path = PathBuf::from(file); + if path.is_absolute() { + path + } else { + std::env::current_dir() + .map(|cwd| cwd.join(&path)) + .unwrap_or(path) + } + } + /// Get effective thread count (auto-detect if 0) pub fn effective_threads(&self) -> usize { match (self.parallel, self.threads) { diff --git a/src/verify/global_engine.rs b/src/verify/global_engine.rs index c28b8f4f..00eecda3 100644 --- a/src/verify/global_engine.rs +++ b/src/verify/global_engine.rs @@ -7,8 +7,8 @@ use super::global_table::{GlobalBlockTable, GlobalBlockTableBuilder}; use super::scanner_state::ScannerState; use super::types::{ - BlockCount, BlockNumber, BlockVerificationResult, FileScanMetadata, FileSize, FileStatus, - FileVerificationResult, VerificationResults, + BlockCount, BlockNumber, BlockSource, BlockVerificationResult, FileScanMetadata, FileSize, + FileStatus, FileVerificationResult, VerificationResults, }; use super::utils::extract_file_name; @@ -28,6 +28,8 @@ type AvailableBlocksMap = HashMap<(Md5Hash, Crc32Value), Vec<(FileId, u32)>>; type FileStatusMap = HashMap; /// Map of file IDs to wrong-name paths that exactly matched them. type RenamedFileMatches = HashMap; +/// Concrete source locations for blocks found during scanning. +type BlockLocationMap = HashMap<(FileId, u32), BlockSource>; /// Result of attempting to match a block against the global table #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -98,6 +100,8 @@ pub struct GlobalVerificationEngine { skip_leeway: usize, /// Only scan extra files that can be exact renamed matches. rename_only: bool, + /// Additional non-PAR2 files to scan for renamed or misplaced data. + extra_files: Vec, } /// Result of verifying a single file using global block table @@ -175,6 +179,7 @@ impl GlobalVerificationEngine { data_skipping: config.data_skipping, skip_leeway: config.skip_leeway, rename_only: config.rename_only, + extra_files: config.extra_files.clone(), }) } @@ -194,7 +199,7 @@ impl GlobalVerificationEngine { reporter: &R, parallel: bool, ) -> VerificationResults { - self.verify_recovery_set_with_extra_files(reporter, parallel, &[]) + self.verify_recovery_set_with_extra_files(reporter, parallel, &self.extra_files) } /// Verify the recovery set while also scanning user-supplied extra files. @@ -209,10 +214,24 @@ impl GlobalVerificationEngine { extra_files: &[PathBuf], ) -> VerificationResults { // Note: report_verification_start and report_files_found should be called by the caller + let combined_extra_files; + let scan_extra_files = if self.extra_files.is_empty() { + extra_files + } else if extra_files.is_empty() { + &self.extra_files + } else { + combined_extra_files = self + .extra_files + .iter() + .chain(extra_files.iter()) + .cloned() + .collect::>(); + &combined_extra_files + }; // Step 1: Scan all available files to build availability map - let (available_blocks, file_statuses, scan_metadatas, renamed_matches) = - self.scan_available_blocks_with_extra_files(reporter, parallel, extra_files); + let (available_blocks, file_statuses, scan_metadatas, renamed_matches, block_locations) = + self.scan_available_blocks_with_extra_files(reporter, parallel, scan_extra_files); // Step 2: Create aggregate results (individual file reporting already done in scan_available_blocks) let file_results = self.create_file_results( @@ -220,6 +239,7 @@ impl GlobalVerificationEngine { &file_statuses, &scan_metadatas, &renamed_matches, + &block_locations, ); let block_results = self.create_block_verification_results(&available_blocks); @@ -247,6 +267,7 @@ impl GlobalVerificationEngine { FileStatusMap, HashMap, RenamedFileMatches, + BlockLocationMap, ) { // Wrap reporter in Mutex for thread-safe output (like par2cmdline-turbo's output_lock) let reporter_lock = Mutex::new(reporter); @@ -279,11 +300,14 @@ impl GlobalVerificationEngine { let mut global_block_map = HashMap::default(); let mut file_statuses = HashMap::default(); let mut scan_metadatas = HashMap::default(); + let mut block_locations = HashMap::default(); - for (local_map, _file_size, file_id, status, metadata) in file_results { + for (local_map, _file_size, file_id, status, metadata, source_path) in file_results { // Store the computed status file_statuses.insert(file_id, status); + Self::merge_block_locations(&mut block_locations, &metadata, &source_path); + // Store the scan metadata scan_metadatas.insert(file_id, metadata); @@ -339,6 +363,8 @@ impl GlobalVerificationEngine { .record_block_found(*offset, *file_id, *block_number); } + Self::merge_block_locations(&mut block_locations, &metadata, &extra_path); + for (key, entries) in local_map { global_block_map .entry(key) @@ -352,6 +378,7 @@ impl GlobalVerificationEngine { file_statuses, scan_metadatas, renamed_matches, + block_locations, ) } @@ -404,6 +431,7 @@ impl GlobalVerificationEngine { FileId, FileStatus, FileScanMetadata, + PathBuf, ) { use crate::verify::types::FileSize; @@ -453,9 +481,25 @@ impl GlobalVerificationEngine { file_description.file_id, status, scan_metadata, + file_path, ) } + fn merge_block_locations( + block_locations: &mut BlockLocationMap, + metadata: &FileScanMetadata, + source_path: &Path, + ) { + for (offset, file_id, block_number) in &metadata.found_blocks { + block_locations + .entry((*file_id, *block_number)) + .or_insert_with(|| BlockSource { + file_path: source_path.to_path_buf(), + offset: *offset, + }); + } + } + /// Scan a single file and return its local block map with progress reporting fn scan_single_file_with_progress( &self, @@ -1202,6 +1246,7 @@ impl GlobalVerificationEngine { file_statuses: &FileStatusMap, scan_metadatas: &HashMap, renamed_matches: &RenamedFileMatches, + block_locations: &BlockLocationMap, ) -> Vec { let mut file_results = Vec::new(); @@ -1270,6 +1315,14 @@ impl GlobalVerificationEngine { .collect() }) .unwrap_or_default(); + let mut block_sources = HashMap::default(); + for block_num in 0..total_blocks.as_usize() { + let block_number = block_num as u32; + if let Some(source) = block_locations.get(&(file_description.file_id, block_number)) + { + block_sources.insert(block_number, source.clone()); + } + } // Just create the result record (reporting already done inline) @@ -1284,6 +1337,7 @@ impl GlobalVerificationEngine { matched_path: (status == FileStatus::Renamed) .then(|| renamed_matches.get(&file_description.file_id).cloned()) .flatten(), + block_sources, }); } diff --git a/src/verify/mod.rs b/src/verify/mod.rs index 8c06d41a..2b30f22a 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -27,7 +27,7 @@ pub use global_table::{ GlobalBlockEntry, GlobalBlockPosition, GlobalBlockTable, GlobalBlockTableBuilder, }; pub use types::{ - BlockVerificationResult, FileScanMetadata, FileStatus, FileVerificationResult, + BlockSource, BlockVerificationResult, FileScanMetadata, FileStatus, FileVerificationResult, VerificationResults, }; pub use utils::extract_file_name; diff --git a/src/verify/types.rs b/src/verify/types.rs index 4feb058d..63489cb7 100644 --- a/src/verify/types.rs +++ b/src/verify/types.rs @@ -410,6 +410,15 @@ pub struct FileVerificationResult { /// This is populated only for exact extra-file rename matches where /// `status == FileStatus::Renamed`. pub matched_path: Option, + /// Source files and offsets where blocks were found during scanning. + /// Maps block_number -> concrete source location for repair reads. + pub block_sources: rustc_hash::FxHashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockSource { + pub file_path: PathBuf, + pub offset: usize, } /// Buffer for scanning file data diff --git a/tests/test_console_verification_reporter.rs b/tests/test_console_verification_reporter.rs index 39bb7cd6..8f48de34 100644 --- a/tests/test_console_verification_reporter.rs +++ b/tests/test_console_verification_reporter.rs @@ -149,6 +149,7 @@ fn test_report_verification_results_single_file() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results = create_test_results(vec![file], 1, 0, 0, 0); reporter.report_verification_results(&results); @@ -168,6 +169,7 @@ fn test_report_verification_results_with_small_damaged_blocks() { damaged_blocks: vec![1, 3, 5, 7, 9], // 5 blocks ≤ 20 block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results = create_test_results(vec![corrupted_file], 0, 0, 1, 0); reporter.report_verification_results(&results); @@ -188,6 +190,7 @@ fn test_report_verification_results_with_large_damaged_blocks() { damaged_blocks: large_damaged_blocks, block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results = create_test_results(vec![corrupted_file], 0, 0, 1, 0); reporter.report_verification_results(&results); @@ -208,6 +211,7 @@ fn test_report_verification_results_boundary_cases() { damaged_blocks: exactly_20_blocks, block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results_20 = create_test_results(vec![file_20], 0, 0, 1, 0); reporter.report_verification_results(&results_20); @@ -223,6 +227,7 @@ fn test_report_verification_results_boundary_cases() { damaged_blocks: over_20_blocks, block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results_21 = create_test_results(vec![file_21], 0, 0, 1, 0); reporter.report_verification_results(&results_21); @@ -242,6 +247,7 @@ fn test_report_verification_results_mixed_files() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "missing.txt".to_string(), @@ -252,6 +258,7 @@ fn test_report_verification_results_mixed_files() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "corrupted.txt".to_string(), @@ -262,6 +269,7 @@ fn test_report_verification_results_mixed_files() { damaged_blocks: vec![2, 4, 6], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "renamed.txt".to_string(), @@ -272,6 +280,7 @@ fn test_report_verification_results_mixed_files() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, ]; let results = create_test_results(mixed_files, 1, 1, 1, 1); @@ -293,6 +302,7 @@ fn test_print_block_list_head_tail_logic() { damaged_blocks: very_large_blocks, block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results = create_test_results(vec![massive_file], 0, 0, 1, 0); reporter.report_verification_results(&results); diff --git a/tests/test_silent_verification_reporter.rs b/tests/test_silent_verification_reporter.rs index 5c0f6281..e5e79671 100644 --- a/tests/test_silent_verification_reporter.rs +++ b/tests/test_silent_verification_reporter.rs @@ -159,6 +159,7 @@ fn test_report_verification_results_single_file_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results = create_test_results(vec![file], 1, 0, 0, 0); reporter.report_verification_results(&results); @@ -178,6 +179,7 @@ fn test_report_verification_results_with_damaged_blocks_silent() { damaged_blocks: vec![1, 3, 5, 7, 9], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results = create_test_results(vec![corrupted_file], 0, 0, 1, 0); reporter.report_verification_results(&results); @@ -198,6 +200,7 @@ fn test_report_verification_results_large_damaged_blocks_silent() { damaged_blocks: large_damaged_blocks, block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let results = create_test_results(vec![corrupted_file], 0, 0, 1, 0); reporter.report_verification_results(&results); @@ -217,6 +220,7 @@ fn test_report_verification_results_mixed_files_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "missing.txt".to_string(), @@ -227,6 +231,7 @@ fn test_report_verification_results_mixed_files_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "corrupted.txt".to_string(), @@ -237,6 +242,7 @@ fn test_report_verification_results_mixed_files_silent() { damaged_blocks: vec![2, 4, 6], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "renamed.txt".to_string(), @@ -247,6 +253,7 @@ fn test_report_verification_results_mixed_files_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, ]; let results = create_test_results(mixed_files, 1, 1, 1, 1); @@ -286,6 +293,7 @@ fn test_comprehensive_verification_workflow_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "file2.txt".to_string(), @@ -296,6 +304,7 @@ fn test_comprehensive_verification_workflow_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "file3.txt".to_string(), @@ -306,6 +315,7 @@ fn test_comprehensive_verification_workflow_silent() { damaged_blocks: vec![1, 5, 9], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, ]; let final_results = create_test_results(final_files, 1, 0, 1, 1); diff --git a/tests/test_turbo_verify_repair_parity.rs b/tests/test_turbo_verify_repair_parity.rs new file mode 100644 index 00000000..c99a890a --- /dev/null +++ b/tests/test_turbo_verify_repair_parity.rs @@ -0,0 +1,125 @@ +use par2rs::create::{CreateContextBuilder, SilentCreateReporter}; +use par2rs::par2_files; +use par2rs::repair::{repair_files, SilentReporter}; +use par2rs::reporters::SilentVerificationReporter; +use par2rs::verify::{comprehensive_verify_files, FileStatus, VerificationConfig}; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn create_small_recovery_set(temp_dir: &TempDir, file_name: &str, data: &[u8]) -> PathBuf { + let source = temp_dir.path().join(file_name); + fs::write(&source, data).unwrap(); + + let par2_file = temp_dir.path().join(format!("{file_name}.par2")); + let mut context = CreateContextBuilder::new() + .output_name(par2_file.to_string_lossy()) + .source_files(vec![source]) + .base_path(temp_dir.path()) + .block_size(4) + .recovery_block_count(1) + .reporter(Box::new(SilentCreateReporter)) + .build() + .unwrap(); + + context.create().unwrap(); + par2_file +} + +fn verify_with_config( + par2_file: &Path, + config: &VerificationConfig, +) -> par2rs::verify::VerificationResults { + let par2_files = par2_files::collect_par2_files(par2_file); + let packet_set = par2_files::load_par2_packets(&par2_files, false, false); + comprehensive_verify_files( + packet_set, + config, + &SilentVerificationReporter, + par2_file.parent().unwrap(), + ) +} + +#[test] +fn verify_marks_complete_extra_file_as_renamed() { + let temp_dir = TempDir::new().unwrap(); + let par2_file = create_small_recovery_set(&temp_dir, "data.bin", b"abcdefghijkl"); + + let target = temp_dir.path().join("data.bin"); + let misplaced = temp_dir.path().join("misplaced.bin"); + fs::rename(&target, &misplaced).unwrap(); + + let config = VerificationConfig::default().with_extra_files(vec![misplaced]); + let results = verify_with_config(&par2_file, &config); + + assert_eq!(results.renamed_file_count, 1); + assert_eq!(results.missing_block_count, 0); + assert_eq!(results.files[0].status, FileStatus::Renamed); +} + +#[test] +fn repair_restores_complete_extra_file_without_recovery_blocks() { + let temp_dir = TempDir::new().unwrap(); + let par2_file = create_small_recovery_set(&temp_dir, "data.bin", b"abcdefghijkl"); + + let target = temp_dir.path().join("data.bin"); + let misplaced = temp_dir.path().join("misplaced.bin"); + fs::rename(&target, &misplaced).unwrap(); + + let config = VerificationConfig::default().with_extra_files(vec![misplaced.clone()]); + let (_, result) = repair_files( + par2_file.to_str().unwrap(), + Box::new(SilentReporter), + &config, + ) + .unwrap(); + + assert!(result.is_success(), "{result:?}"); + assert_eq!(fs::read(target).unwrap(), b"abcdefghijkl"); + assert!(!misplaced.exists()); +} + +#[test] +fn repair_rewrites_misaligned_file_when_all_blocks_are_available() { + let temp_dir = TempDir::new().unwrap(); + let par2_file = create_small_recovery_set(&temp_dir, "data.bin", b"abcdefghijkl"); + + let target = temp_dir.path().join("data.bin"); + fs::write(&target, b"Xabcdefghijkl").unwrap(); + + let (_, result) = repair_files( + par2_file.to_str().unwrap(), + Box::new(SilentReporter), + &VerificationConfig::default(), + ) + .unwrap(); + + assert!(result.is_success(), "{result:?}"); + assert_eq!(fs::read(target).unwrap(), b"abcdefghijkl"); +} + +#[test] +fn repair_uses_partial_extra_file_blocks_as_repair_sources() { + let temp_dir = TempDir::new().unwrap(); + let par2_file = create_small_recovery_set(&temp_dir, "data.bin", b"abcdefghijkl"); + + let target = temp_dir.path().join("data.bin"); + fs::remove_file(&target).unwrap(); + let partial_extra = temp_dir.path().join("partial.bin"); + fs::write(&partial_extra, b"abcdefgh").unwrap(); + + let config = VerificationConfig::default().with_extra_files(vec![partial_extra.clone()]); + let results = verify_with_config(&par2_file, &config); + assert_eq!(results.missing_block_count, 1); + + let (_, result) = repair_files( + par2_file.to_str().unwrap(), + Box::new(SilentReporter), + &config, + ) + .unwrap(); + + assert!(result.is_success(), "{result:?}"); + assert_eq!(fs::read(target).unwrap(), b"abcdefghijkl"); + assert!(partial_extra.exists()); +} diff --git a/tests/test_verify.rs b/tests/test_verify.rs index f7d02015..9aeb7db8 100644 --- a/tests/test_verify.rs +++ b/tests/test_verify.rs @@ -644,6 +644,7 @@ mod file_status_tests { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let cloned = result.clone(); @@ -769,6 +770,7 @@ mod print_verification_results_tests { damaged_blocks, block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }); let results = VerificationResults { @@ -838,6 +840,7 @@ mod verification_result_calculations { damaged_blocks: if i > 0 { vec![0u32, 1u32] } else { vec![] }, block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }); } @@ -898,6 +901,7 @@ mod verification_result_calculations { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "damaged.txt".to_string(), @@ -908,6 +912,7 @@ mod verification_result_calculations { damaged_blocks: vec![5, 6, 7, 8, 9], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, FileVerificationResult { file_name: "missing.txt".to_string(), @@ -918,6 +923,7 @@ mod verification_result_calculations { damaged_blocks: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }, ]; @@ -1091,6 +1097,7 @@ mod file_name_handling { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; assert_eq!(result.file_name, "файл.txt"); diff --git a/tests/test_verify_types_comprehensive.rs b/tests/test_verify_types_comprehensive.rs index d87e6c45..7f17d473 100644 --- a/tests/test_verify_types_comprehensive.rs +++ b/tests/test_verify_types_comprehensive.rs @@ -123,6 +123,7 @@ fn test_file_verification_result_creation() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; assert_eq!(result.file_name, "test.txt"); @@ -145,6 +146,7 @@ fn test_file_verification_result_damaged() { damaged_blocks: vec![3, 7], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; assert_eq!(result.status, FileStatus::Corrupted); @@ -165,6 +167,7 @@ fn test_file_verification_result_clone() { damaged_blocks: vec![0, 1, 2, 3, 4], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let result2 = result1.clone(); @@ -369,6 +372,7 @@ fn test_verification_results_with_file_data() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, + block_sources: Default::default(), }; let block_result = BlockVerificationResult { From 4265918cd15b0e38e35d06a5a118d5e646c5d479 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 22:39:17 -0600 Subject: [PATCH 02/30] Accept turbo verify repair skipping options --- src/args.rs | 6 ++++-- src/bin/par2.rs | 6 ++++-- tests/test_binaries.rs | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/args.rs b/src/args.rs index eb92832d..56378d4c 100644 --- a/src/args.rs +++ b/src/args.rs @@ -93,7 +93,8 @@ pub fn parse_args() -> clap::ArgMatches { Arg::new("skip_leeway") .help("Skip leeway (distance +/- from expected block position)") .short('S') - .value_name("N"), + .value_name("N") + .requires("data_skipping"), ) .get_matches_from(args) } @@ -196,7 +197,8 @@ pub fn parse_repair_args() -> clap::ArgMatches { Arg::new("skip_leeway") .help("Skip leeway (distance +/- from expected block position)") .short('S') - .value_name("N"), + .value_name("N") + .requires("data_skipping"), ) .get_matches_from(args) } diff --git a/src/bin/par2.rs b/src/bin/par2.rs index 4738bc95..a268c0ee 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -253,7 +253,8 @@ fn main() -> Result<()> { Arg::new("skip_leeway") .short('S') .help("Skip leeway (distance +/- from expected block position)") - .value_name("N"), + .value_name("N") + .requires("data_skipping"), ), ) .subcommand( @@ -350,7 +351,8 @@ fn main() -> Result<()> { Arg::new("skip_leeway") .short('S') .help("Skip leeway (distance +/- from expected block position)") - .value_name("N"), + .value_name("N") + .requires("data_skipping"), ), ) .get_matches_from(args); diff --git a/tests/test_binaries.rs b/tests/test_binaries.rs index 20d0b3e7..3374c577 100644 --- a/tests/test_binaries.rs +++ b/tests/test_binaries.rs @@ -1234,6 +1234,30 @@ fn test_par2_verify_repair_accept_scan_compat_flags() { } } +#[test] +fn test_verify_repair_reject_skip_leeway_without_data_skipping() { + let par2_file = Path::new("tests/fixtures/edge_cases/test_valid.par2"); + if !par2_file.exists() { + eprintln!("Skipping test - fixture not found"); + return; + } + + let verify_output = Command::new(get_binary_path("par2")) + .arg("verify") + .arg("-S10") + .arg(par2_file) + .output() + .expect("Failed to execute par2 verify"); + assert!(!verify_output.status.success()); + + let repair_output = Command::new(get_binary_path("par2repair")) + .arg("-S10") + .arg(par2_file) + .output() + .expect("Failed to execute par2repair"); + assert!(!repair_output.status.success()); +} + #[test] fn test_par2_verify_repair_accept_all_use_resource_flags() { let par2_file = Path::new("tests/fixtures/edge_cases/test_valid.par2"); From 86315e7facaa80c2efc002ef6d1a5cd904a92a46 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 23:01:01 -0600 Subject: [PATCH 03/30] Honor turbo basepath for verify repair --- src/repair/mod.rs | 6 +++++- src/verify/config.rs | 23 ++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 4fe071d5..030f3b4b 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -1063,9 +1063,11 @@ pub fn repair_files_with_base_path_and_extra_files( return Err(RepairError::NoValidPackets); } - // Get the base directory for file resolution + // Get the base directory for file resolution. An explicit caller override + // wins over the CLI/configured base path. let base_path = base_path_override .map(Path::to_path_buf) + .or_else(|| verify_config.base_path.clone()) .unwrap_or_else(|| par2_path.parent().unwrap_or(Path::new(".")).to_path_buf()); // CRITICAL FIX: Run comprehensive verification to get accurate block availability @@ -1084,6 +1086,8 @@ pub fn repair_files_with_base_path_and_extra_files( verify_config.threads, verify_config.parallel, ); + repair_verify_config.extra_files = verify_config.extra_files.clone(); + repair_verify_config.base_path = Some(base_path.clone()); repair_verify_config.file_threads = verify_config.file_threads; repair_verify_config.data_skipping = verify_config.data_skipping; repair_verify_config.skip_leeway = verify_config.skip_leeway; diff --git a/src/verify/config.rs b/src/verify/config.rs index e78f802a..8b72178f 100644 --- a/src/verify/config.rs +++ b/src/verify/config.rs @@ -24,6 +24,8 @@ pub struct VerificationConfig { pub rename_only: bool, /// Additional data files to scan for misplaced or renamed source data. pub extra_files: Vec, + /// Base directory for resolving protected data files. + pub base_path: Option, } impl Default for VerificationConfig { @@ -38,6 +40,7 @@ impl Default for VerificationConfig { skip_leeway: 0, rename_only: false, extra_files: Vec::new(), + base_path: None, } } } @@ -54,6 +57,7 @@ impl VerificationConfig { skip_leeway: 0, rename_only: false, extra_files: Vec::new(), + base_path: None, } } @@ -69,6 +73,7 @@ impl VerificationConfig { skip_leeway: 0, rename_only: false, extra_files: Vec::new(), + base_path: None, } } @@ -77,6 +82,11 @@ impl VerificationConfig { self } + pub fn with_base_path(mut self, base_path: Option) -> Self { + self.base_path = base_path; + self + } + pub fn from_args(matches: &clap::ArgMatches) -> Self { Self::try_from_args(matches).unwrap_or_else(|_| { let threads = matches @@ -143,9 +153,15 @@ impl VerificationConfig { .try_get_many::("files") .ok() .flatten() - .map(|files| files.map(|file| Self::normalize_extra_file(file)).collect()) + .map(|files| files.map(|file| Self::normalize_arg_path(file)).collect()) .unwrap_or_default(); + let base_path = matches + .try_get_one::("basepath") + .ok() + .flatten() + .map(|path| Self::normalize_arg_path(path)); + Ok(Self { threads, parallel, @@ -161,11 +177,12 @@ impl VerificationConfig { .copied() .unwrap_or(false), extra_files, + base_path, }) } - fn normalize_extra_file(file: &str) -> PathBuf { - let path = PathBuf::from(file); + fn normalize_arg_path(value: &str) -> PathBuf { + let path = PathBuf::from(value); if path.is_absolute() { path } else { From 22abb4a6091837aed56047ff3519779622390a57 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 23:16:53 -0600 Subject: [PATCH 04/30] Match turbo purge behavior for verify repair --- src/bin/par2.rs | 21 ++++----- src/bin/par2repair.rs | 15 +++++-- src/bin/par2verify.rs | 9 +--- src/repair/context.rs | 84 +++++++++++++++++++++++++----------- tests/test_repair_context.rs | 51 +++++++++++++++++++--- 5 files changed, 126 insertions(+), 54 deletions(-) diff --git a/src/bin/par2.rs b/src/bin/par2.rs index a268c0ee..1a7c8160 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -762,14 +762,7 @@ fn handle_verify(matches: &clap::ArgMatches) -> Result<()> { if results.missing_block_count == 0 { if purge { - let packet_set = par2rs::par2_files::load_par2_packets(&par2_files, false, false); - let context = par2rs::repair::RepairContextBuilder::new() - .packets(packet_set.packets) - .base_path(base_dir) - .reporter(Box::new(par2rs::repair::ConsoleReporter::new(quiet))) - .build() - .context("Failed to initialize purge context")?; - context.purge_files(&file_name.to_string_lossy())?; + par2rs::repair::RepairContext::purge_par_files_for(&file_name.to_string_lossy())?; } Ok(()) } else if results.repair_possible { @@ -854,8 +847,16 @@ fn handle_repair(matches: &clap::ArgMatches) -> Result<()> { result.print_result(); } - if purge && result.is_success() { - context.purge_files(&resolved_par2_file)?; + if purge { + match &result { + par2rs::repair::RepairResult::Success { .. } => { + context.purge_files(&resolved_par2_file)? + } + par2rs::repair::RepairResult::NoRepairNeeded { .. } => { + context.purge_par_files(&resolved_par2_file)? + } + par2rs::repair::RepairResult::Failed { .. } => {} + } } if result.is_success() { diff --git a/src/bin/par2repair.rs b/src/bin/par2repair.rs index 059838ff..19f3f9c3 100644 --- a/src/bin/par2repair.rs +++ b/src/bin/par2repair.rs @@ -81,9 +81,18 @@ fn main() -> Result<()> { result.print_result(); } - // Purge backup and PAR2 files on successful repair if -p flag is set - if purge && result.is_success() { - context.purge_files(&resolved_par2_file)?; + // par2cmdline-turbo purges backups only after an actual repair. If no + // repair was needed, -p removes PAR2 files and leaves existing backups. + if purge { + match &result { + par2rs::repair::RepairResult::Success { .. } => { + context.purge_files(&resolved_par2_file)? + } + par2rs::repair::RepairResult::NoRepairNeeded { .. } => { + context.purge_par_files(&resolved_par2_file)? + } + par2rs::repair::RepairResult::Failed { .. } => {} + } } // Exit with success if repair was successful or not needed, error otherwise diff --git a/src/bin/par2verify.rs b/src/bin/par2verify.rs index 6ee3aab8..c08e1cda 100644 --- a/src/bin/par2verify.rs +++ b/src/bin/par2verify.rs @@ -147,14 +147,7 @@ fn main() -> Result<()> { ); if purge { - let packet_set = par2_files::load_par2_packets(&par2_files, false, false); - let context = par2rs::repair::RepairContextBuilder::new() - .packets(packet_set.packets) - .base_path(base_dir) - .reporter(Box::new(par2rs::repair::ConsoleReporter::new(quiet))) - .build() - .context("Failed to initialize purge context")?; - context.purge_files(&file_name.to_string_lossy())?; + par2rs::repair::RepairContext::purge_par_files_for(&file_name.to_string_lossy())?; } Ok(()) diff --git a/src/repair/context.rs b/src/repair/context.rs index 61bb0a28..5401b72c 100644 --- a/src/repair/context.rs +++ b/src/repair/context.rs @@ -8,7 +8,7 @@ use crate::domain::{BlockCount, BlockSize, FileId, FileSize, GlobalSliceIndex}; use crate::packets::{FileDescriptionPacket, Packet, RecoverySliceMetadata}; use log::{debug, warn}; use rustc_hash::FxHashMap as HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; /// Main repair context containing all necessary information for repair operations @@ -182,19 +182,11 @@ impl RepairContext { }) } - /// Purge backup files and PAR2 files after successful repair - /// Matches par2cmdline's -p flag behavior + /// Purge backup files and PAR2 files after an actual successful repair. + /// Matches par2cmdline's -p repair behavior. pub fn purge_files(&self, par2_file: &str) -> Result<()> { - use std::fs; - use std::path::Path; - - let par2_path = Path::new(par2_file); - let par2_dir = par2_path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - self.reporter.report_purge_backup_files(); + self.purge_backup_files()?; let backups = self .repair_created_backups @@ -213,22 +205,52 @@ impl RepairContext { } } + self.purge_par_files(par2_file) + } + + /// Purge PAR2 files without removing backups. + /// + /// par2cmdline-turbo uses this path for `verify -p` and for `repair -p` + /// when all files are already correct. + pub fn purge_par_files(&self, par2_file: &str) -> Result<()> { self.reporter.report_purge_par_files(); + Self::purge_par_files_for(par2_file) + } - // Remove all PAR2 files in the directory - if let Ok(entries) = fs::read_dir(par2_dir) { - for entry in entries.flatten() { - if let Some(ext) = entry.path().extension() { - if ext - .to_str() - .is_some_and(|ext| ext.eq_ignore_ascii_case("par2")) - { - fs::remove_file(entry.path()).map_err(|e| { - RepairError::FileDeleteError { - file: entry.path(), - source: e, - } - })?; + /// Purge PAR2 files without a repair context. + pub fn purge_par_files_for(par2_file: &str) -> Result<()> { + for path in crate::par2_files::collect_par2_files(Path::new(par2_file)) { + if path.exists() { + delete_file(&path)?; + } + } + + Ok(()) + } + + fn purge_backup_files(&self) -> Result<()> { + for file_info in &self.recovery_set.files { + let file_path = self.base_path.join(&file_info.file_name); + let Some(parent) = file_path.parent() else { + continue; + }; + let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(candidate) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if is_numeric_backup_for(candidate, file_name) { + delete_file(&path)?; self.reporter .report_purge_remove(&entry.file_name().to_string_lossy()); @@ -241,6 +263,16 @@ impl RepairContext { } } +fn is_numeric_backup_for(candidate: &str, original_name: &str) -> bool { + let Some(suffix) = candidate.strip_prefix(original_name) else { + return false; + }; + + suffix.strip_prefix('.').is_some_and(|digits| { + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/test_repair_context.rs b/tests/test_repair_context.rs index dbdde0ef..282e3a99 100644 --- a/tests/test_repair_context.rs +++ b/tests/test_repair_context.rs @@ -397,14 +397,19 @@ fn test_repair_context_purge_files_with_backups() { let dir = TempDir::new().unwrap(); let file_id = FileId::new([1; 16]); - // Create main file and backups with replaced extensions + // par2cmdline-turbo creates numeric backups by appending to the full file + // name, for example "test.txt.1". let main_file = dir.path().join("test.txt"); - let backup_1 = dir.path().join("test.1"); // with_extension replaces .txt with .1 - let backup_bak = dir.path().join("test.bak"); // with_extension replaces .txt with .bak + let backup_1 = dir.path().join("test.txt.1"); + let backup_2 = dir.path().join("test.txt.2"); + let replaced_extension_backup = dir.path().join("test.1"); + let bak_file = dir.path().join("test.txt.bak"); fs::write(&main_file, b"main").unwrap(); fs::write(&backup_1, b"backup1").unwrap(); - fs::write(&backup_bak, b"backup bak").unwrap(); + fs::write(&backup_2, b"backup2").unwrap(); + fs::write(&replaced_extension_backup, b"legacy backup").unwrap(); + fs::write(&bak_file, b"bak").unwrap(); // Create PAR2 file let par2_file = dir.path().join("test.par2"); @@ -420,9 +425,13 @@ fn test_repair_context_purge_files_with_backups() { let result = context.purge_files(par2_file.to_str().unwrap()); assert!(result.is_ok()); - // Existing user-created backup files are not deleted by generic purge. - assert!(backup_1.exists()); - assert!(backup_bak.exists()); + // Generated numeric backups should be deleted. + assert!(!backup_1.exists()); + assert!(!backup_2.exists()); + + // Replaced-extension and .bak files are not par2cmdline-turbo purge targets. + assert!(replaced_extension_backup.exists()); + assert!(bak_file.exists()); // Main file should still exist assert!(main_file.exists()); @@ -431,6 +440,34 @@ fn test_repair_context_purge_files_with_backups() { assert!(!par2_file.exists()); } +#[test] +fn test_repair_context_purge_par_files_keeps_backups() { + let dir = TempDir::new().unwrap(); + let file_id = FileId::new([1; 16]); + + let backup_file = dir.path().join("test.txt.1"); + fs::write(&backup_file, b"backup1").unwrap(); + + let par2_file = dir.path().join("test.par2"); + let par2_vol = dir.path().join("test.vol0+1.par2"); + fs::write(&par2_file, b"dummy par2").unwrap(); + fs::write(&par2_vol, b"dummy volume").unwrap(); + + let packets = vec![ + Packet::Main(create_main_packet(vec![file_id])), + Packet::FileDescription(create_file_desc(file_id, "test.txt", 1024)), + ]; + + let context = RepairContext::new(packets, dir.path().to_path_buf()).unwrap(); + + let result = context.purge_par_files(par2_file.to_str().unwrap()); + assert!(result.is_ok()); + + assert!(backup_file.exists()); + assert!(!par2_file.exists()); + assert!(!par2_vol.exists()); +} + #[test] fn test_repair_context_purge_multiple_par2_files() { let dir = TempDir::new().unwrap(); From 28dcc45b5e5b19e3a40bd8fec347f64ddd77f840 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 23:28:24 -0600 Subject: [PATCH 05/30] Ignore foreign extra PAR2 sets --- src/bin/par2.rs | 3 +- src/bin/par2verify.rs | 16 +++++- src/par2_files.rs | 109 ++++++++++++++++++++++++----------------- src/repair/mod.rs | 19 ++++++- tests/test_binaries.rs | 48 ++++++++++++++++++ 5 files changed, 145 insertions(+), 50 deletions(-) diff --git a/src/bin/par2.rs b/src/bin/par2.rs index 1a7c8160..29f0d570 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -705,8 +705,7 @@ fn handle_verify(matches: &clap::ArgMatches) -> Result<()> { }) .cloned(), ); - par2_files.sort(); - par2_files.dedup(); + par2rs::par2_files::sort_dedup_preserving_first(&mut par2_files); // Parse packets excluding recovery slices but validate and count them // Recovery slice data is NOT loaded into memory (saves gigabytes for large PAR2 sets) diff --git a/src/bin/par2verify.rs b/src/bin/par2verify.rs index c08e1cda..95a84502 100644 --- a/src/bin/par2verify.rs +++ b/src/bin/par2verify.rs @@ -88,7 +88,21 @@ fn main() -> Result<()> { .and_then(|name| name.to_str()) .map(Path::new) .unwrap_or(&file_path); - let par2_files = par2_files::collect_par2_files(file_name); + + // Collect all PAR2 files in the set, plus explicit extra PAR2 files. + let mut par2_files = par2_files::collect_par2_files(file_name); + par2_files.extend( + verify_config + .extra_files + .iter() + .filter(|path| { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("par2")) + }) + .cloned(), + ); + par2_files::sort_dedup_preserving_first(&mut par2_files); // Parse packets excluding recovery slices but validate and count them if !quiet { diff --git a/src/par2_files.rs b/src/par2_files.rs index b0950eab..a871dbd1 100644 --- a/src/par2_files.rs +++ b/src/par2_files.rs @@ -4,7 +4,7 @@ //! It includes utilities for finding PAR2 files in a directory and parsing their //! packet structures from disk with minimal memory overhead. -use crate::domain::Md5Hash; +use crate::domain::{Md5Hash, RecoverySetId}; use crate::Packet; use rayon::prelude::*; use rustc_hash::FxHashSet as HashSet; @@ -253,14 +253,15 @@ pub fn collect_par2_files(file_path: &Path) -> Vec { let base_stem = par2_base_stem(file_path); - let mut par2_files = vec![file_path.to_path_buf()]; - par2_files.extend( - find_par2_files_in_directory(folder_path, file_path) - .into_iter() - .filter(|p| par2_base_stem(p) == base_stem), - ); + let mut related_files: Vec = find_par2_files_in_directory(folder_path, file_path) + .into_iter() + .filter(|p| par2_base_stem(p) == base_stem) + .collect(); + related_files.sort(); - par2_files.sort(); + let mut par2_files = vec![file_path.to_path_buf()]; + par2_files.extend(related_files); + sort_dedup_preserving_first(&mut par2_files); par2_files } @@ -293,6 +294,21 @@ pub fn collect_par1_files(file_path: &Path) -> Vec { par1_files } +/// Sort and deduplicate paths while keeping the first path in front. +pub fn sort_dedup_preserving_first(paths: &mut Vec) { + let Some(first) = paths.first().cloned() else { + return; + }; + + let mut rest = paths[1..].to_vec(); + rest.sort(); + rest.dedup(); + + paths.clear(); + paths.push(first.clone()); + paths.extend(rest.into_iter().filter(|path| path != &first)); +} + /// Get a unique hash for a packet to detect duplicates #[must_use] pub fn get_packet_hash(packet: &Packet) -> Md5Hash { @@ -444,11 +460,8 @@ pub fn load_par2_packets( include_recovery_slices: bool, show_progress: bool, ) -> PacketSet { - use std::sync::atomic::{AtomicUsize, Ordering}; - // Parse files in parallel and collect results // Use mutex for thread-safe output (like par2cmdline-turbo's output_lock) - let total_recovery_blocks = AtomicUsize::new(0); let output_lock = Mutex::new(()); let all_packets: Vec> = par2_files @@ -460,11 +473,7 @@ pub fn load_par2_packets( show_progress, &output_lock, ) - .map(|result| { - // Accumulate recovery block count atomically - total_recovery_blocks.fetch_add(result.recovery_block_count, Ordering::Relaxed); - result.packets - }) + .map(|result| result.packets) .map_err(|e| { let _guard = output_lock.lock().unwrap(); eprintln!( @@ -478,9 +487,16 @@ pub fn load_par2_packets( }) .collect(); - // Deduplicate packets in a single pass and check for mixed recovery sets + let primary_set_id = all_packets + .iter() + .flatten() + .next() + .map(packet_recovery_set_id); + + // Deduplicate packets in a single pass, keeping only the primary recovery + // set. par2cmdline-turbo treats packets from explicit foreign PAR2 files as + // "no new packets" rather than merging recovery sets. let mut seen_hashes = HashSet::default(); - let mut recovery_set_ids: HashSet = HashSet::default(); let packets: Vec = all_packets .into_iter() @@ -491,16 +507,9 @@ pub fn load_par2_packets( return false; } - // Track recovery set IDs to detect mixed PAR2 files - let set_id = match packet { - Packet::Main(p) => p.set_id, - Packet::PackedMain(p) => p.set_id, - Packet::FileDescription(p) => p.set_id, - Packet::InputFileSliceChecksum(p) => p.set_id, - Packet::RecoverySlice(p) => p.set_id, - Packet::Creator(p) => p.set_id, - }; - recovery_set_ids.insert(set_id); + if primary_set_id.is_some_and(|set_id| packet_recovery_set_id(packet) != set_id) { + return false; + } // Deduplicate based on packet hash let packet_hash = get_packet_hash(packet); @@ -508,22 +517,21 @@ pub fn load_par2_packets( }) .collect(); - // Check for mixed recovery sets (common user error) - if show_progress && recovery_set_ids.len() > 1 { - eprintln!("\nWarning: Multiple recovery sets detected."); - eprintln!( - "Found {} different recovery set IDs in the PAR2 files.", - recovery_set_ids.len() - ); - eprintln!( - "This usually means you're trying to verify/repair files from different PAR2 sets." - ); - eprintln!("Please specify only PAR2 files that belong to the same recovery set.\n"); - eprintln!("Hint: Each PAR2 set has a unique base filename (e.g., 'myfile.par2', 'myfile.vol*.par2')"); - eprintln!( - " Don't mix files like 'file1.par2' and 'file2.par2' in the same operation.\n" - ); - } + let recovery_block_count = if let Some(set_id) = primary_set_id { + if include_recovery_slices { + packets + .iter() + .filter(|packet| matches!(packet, Packet::RecoverySlice(_))) + .count() + } else { + parse_recovery_slice_metadata(par2_files, false) + .into_iter() + .filter(|metadata| metadata.set_id == set_id) + .count() + } + } else { + 0 + }; // Determine base directory from the first PAR2 file let base_dir = par2_files @@ -532,7 +540,18 @@ pub fn load_par2_packets( .map(ToOwned::to_owned) .unwrap_or_else(|| PathBuf::from(".")); - PacketSet::new(packets, total_recovery_blocks.into_inner(), base_dir) + PacketSet::new(packets, recovery_block_count, base_dir) +} + +fn packet_recovery_set_id(packet: &Packet) -> RecoverySetId { + match packet { + Packet::Main(p) => p.set_id, + Packet::PackedMain(p) => p.set_id, + Packet::FileDescription(p) => p.set_id, + Packet::InputFileSliceChecksum(p) => p.set_id, + Packet::RecoverySlice(p) => p.set_id, + Packet::Creator(p) => p.set_id, + } } /// Load all PAR2 packets INCLUDING recovery slices (in parallel) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 030f3b4b..d15d8d80 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -1049,8 +1049,17 @@ pub fn repair_files_with_base_path_and_extra_files( return Err(RepairError::FileNotFound(par2_file.to_string())); } - // Collect all PAR2 files in the set - let par2_files = crate::par2_files::collect_par2_files(par2_path); + // Collect all PAR2 files in the set. Explicit PAR2 inputs are allowed here, + // but packet loading filters out packets from foreign recovery sets. + let mut par2_files = crate::par2_files::collect_par2_files(par2_path); + par2_files.extend( + verify_config + .extra_files + .iter() + .filter(|path| is_par2_path(path)) + .cloned(), + ); + crate::par2_files::sort_dedup_preserving_first(&mut par2_files); // Load metadata for memory-efficient recovery slice loading let metadata = crate::par2_files::parse_recovery_slice_metadata(&par2_files, false); @@ -1189,6 +1198,12 @@ fn repair_verification_is_complete(results: &crate::verify::VerificationResults) && results.missing_file_count == 0 } +fn is_par2_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("par2")) +} + fn rename_only_repair_result( results: &crate::verify::VerificationResults, renamed_files: Vec, diff --git a/tests/test_binaries.rs b/tests/test_binaries.rs index 3374c577..ec8aa20f 100644 --- a/tests/test_binaries.rs +++ b/tests/test_binaries.rs @@ -1570,6 +1570,54 @@ fn test_par2verify_scans_extra_file_arguments() { assert!(renamed.exists(), "par2verify must remain non-mutating"); } +#[test] +fn test_par2verify_ignores_foreign_extra_par2_set() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data1 = temp_dir.path().join("data1.bin"); + let data2 = temp_dir.path().join("data2.bin"); + create_test_file(&data1, b"abcdefghijkl").expect("Failed to create first data file"); + create_test_file(&data2, b"mnopqrstuvwx").expect("Failed to create second data file"); + + let par2_file1 = temp_dir.path().join("data1.bin.par2"); + let par2_file2 = temp_dir.path().join("data2.bin.par2"); + for (par2_file, data_file) in [(&par2_file1, &data1), (&par2_file2, &data2)] { + let create_output = Command::new(get_binary_path("par2create")) + .arg("-q") + .arg("-s4") + .arg("-c1") + .arg(par2_file) + .arg(data_file) + .output() + .expect("Failed to execute par2create"); + assert!( + create_output.status.success(), + "par2create failed: {}", + String::from_utf8_lossy(&create_output.stderr) + ); + } + + let verify_output = Command::new(get_binary_path("par2verify")) + .arg(&par2_file1) + .arg(&par2_file2) + .output() + .expect("Failed to execute par2verify"); + + assert!( + verify_output.status.success(), + "par2verify failed: stdout={}, stderr={}", + String::from_utf8_lossy(&verify_output.stdout), + String::from_utf8_lossy(&verify_output.stderr) + ); + let stdout = String::from_utf8_lossy(&verify_output.stdout); + let stderr = String::from_utf8_lossy(&verify_output.stderr); + assert!( + !stdout.contains("Target: \"data2.bin\"") && !stderr.contains("Multiple recovery sets"), + "foreign PAR2 set was not ignored: stdout={}, stderr={}", + stdout, + stderr + ); +} + #[test] fn test_par2verify_purge_removes_par_files_when_valid() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); From 6c46ea4e2b0eaa4fafb191a463d0dc67708ad25d Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 23:29:59 -0600 Subject: [PATCH 06/30] Match turbo par2verify repair exit codes --- src/bin/par2verify.rs | 30 ++++++++++++++++-------------- tests/test_binaries.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/bin/par2verify.rs b/src/bin/par2verify.rs index 95a84502..842a5210 100644 --- a/src/bin/par2verify.rs +++ b/src/bin/par2verify.rs @@ -148,20 +148,22 @@ fn main() -> Result<()> { reporter.report_verification_results(&verification_results); } - // Return success if no repair is needed, error if repair is required - anyhow::ensure!( - verification_results.renamed_file_count == 0, - "Repair required: {} files are renamed", - verification_results.renamed_file_count - ); - anyhow::ensure!( - verification_results.missing_block_count == 0, - "Repair required: {} blocks are missing or damaged", - verification_results.missing_block_count - ); - - if purge { - par2rs::repair::RepairContext::purge_par_files_for(&file_name.to_string_lossy())?; + let repair_required = + verification_results.renamed_file_count > 0 || verification_results.missing_block_count > 0; + if !repair_required { + if purge { + par2rs::repair::RepairContext::purge_par_files_for(&file_name.to_string_lossy())?; + } + } else if verification_results.repair_possible { + if !quiet { + eprintln!("\nRepair is required."); + } + std::process::exit(1); + } else { + if !quiet { + eprintln!("\nRepair is not possible."); + } + std::process::exit(2); } Ok(()) diff --git a/tests/test_binaries.rs b/tests/test_binaries.rs index ec8aa20f..d1a40b76 100644 --- a/tests/test_binaries.rs +++ b/tests/test_binaries.rs @@ -1618,6 +1618,46 @@ fn test_par2verify_ignores_foreign_extra_par2_set() { ); } +#[test] +fn test_par2verify_exit_codes_match_repair_possibility() { + for (recovery_blocks, expected_code) in [(1, 2), (3, 1)] { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_file = temp_dir.path().join("data.bin"); + create_test_file(&data_file, b"abcdefghijkl").expect("Failed to create test file"); + + let par2_file = temp_dir.path().join("data.bin.par2"); + let create_output = Command::new(get_binary_path("par2create")) + .arg("-q") + .arg("-s4") + .arg("-c") + .arg(recovery_blocks.to_string()) + .arg(&par2_file) + .arg(&data_file) + .output() + .expect("Failed to execute par2create"); + assert!( + create_output.status.success(), + "par2create failed: {}", + String::from_utf8_lossy(&create_output.stderr) + ); + + fs::remove_file(&data_file).expect("Failed to remove data file"); + + let verify_output = Command::new(get_binary_path("par2verify")) + .arg(&par2_file) + .output() + .expect("Failed to execute par2verify"); + assert_eq!( + verify_output.status.code(), + Some(expected_code), + "par2verify exit mismatch for -c {}: stdout={}, stderr={}", + recovery_blocks, + String::from_utf8_lossy(&verify_output.stdout), + String::from_utf8_lossy(&verify_output.stderr) + ); + } +} + #[test] fn test_par2verify_purge_removes_par_files_when_valid() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); From 48e512d63960797710ca1e2802595833786d0ce7 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 23:37:28 -0600 Subject: [PATCH 07/30] Match turbo repair impossible exit code --- src/bin/par2.rs | 5 +++-- src/bin/par2repair.rs | 5 +++-- src/repair/types.rs | 13 +++++++++++ tests/test_binaries.rs | 49 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/bin/par2.rs b/src/bin/par2.rs index 29f0d570..1eb3396c 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -858,9 +858,10 @@ fn handle_repair(matches: &clap::ArgMatches) -> Result<()> { } } - if result.is_success() { + let exit_code = result.exit_code(); + if exit_code == 0 { Ok(()) } else { - std::process::exit(2); + std::process::exit(exit_code); } } diff --git a/src/bin/par2repair.rs b/src/bin/par2repair.rs index 19f3f9c3..e6f55ae0 100644 --- a/src/bin/par2repair.rs +++ b/src/bin/par2repair.rs @@ -96,9 +96,10 @@ fn main() -> Result<()> { } // Exit with success if repair was successful or not needed, error otherwise - if result.is_success() { + let exit_code = result.exit_code(); + if exit_code == 0 { Ok(()) } else { - std::process::exit(2); + std::process::exit(exit_code); } } diff --git a/src/repair/types.rs b/src/repair/types.rs index 1d093e41..3a7f650f 100644 --- a/src/repair/types.rs +++ b/src/repair/types.rs @@ -141,6 +141,19 @@ impl RepairResult { ) } + /// Process exit code matching par2cmdline-turbo's common repair outcomes. + pub fn exit_code(&self) -> i32 { + match self { + RepairResult::Success { .. } | RepairResult::NoRepairNeeded { .. } => 0, + RepairResult::Failed { message, .. } + if message.starts_with("Insufficient recovery data") => + { + 2 + } + RepairResult::Failed { .. } => 1, + } + } + /// Get the files that were successfully repaired pub fn repaired_files(&self) -> &[String] { match self { diff --git a/tests/test_binaries.rs b/tests/test_binaries.rs index d1a40b76..b5a36771 100644 --- a/tests/test_binaries.rs +++ b/tests/test_binaries.rs @@ -1878,6 +1878,55 @@ fn test_par1_repair_rejects_zero_memory_flag() { } } +#[test] +fn test_par2repair_insufficient_recovery_exits_two() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_file = temp_dir.path().join("data.bin"); + create_test_file(&data_file, b"abcdefghijkl").expect("Failed to create test file"); + + let par2_file = temp_dir.path().join("data.bin.par2"); + let create_output = Command::new(get_binary_path("par2create")) + .arg("-q") + .arg("-s4") + .arg("-c1") + .arg(&par2_file) + .arg(&data_file) + .output() + .expect("Failed to execute par2create"); + assert!( + create_output.status.success(), + "par2create failed: {}", + String::from_utf8_lossy(&create_output.stderr) + ); + + fs::remove_file(&data_file).expect("Failed to remove data file"); + + let combined_repair = Command::new(get_binary_path("par2")) + .arg("repair") + .arg(&par2_file) + .output() + .expect("Failed to execute par2 repair"); + assert_eq!( + combined_repair.status.code(), + Some(2), + "par2 repair exit mismatch: stdout={}, stderr={}", + String::from_utf8_lossy(&combined_repair.stdout), + String::from_utf8_lossy(&combined_repair.stderr) + ); + + let standalone_repair = Command::new(get_binary_path("par2repair")) + .arg(&par2_file) + .output() + .expect("Failed to execute par2repair"); + assert_eq!( + standalone_repair.status.code(), + Some(2), + "par2repair exit mismatch: stdout={}, stderr={}", + String::from_utf8_lossy(&standalone_repair.stdout), + String::from_utf8_lossy(&standalone_repair.stderr) + ); +} + #[test] fn test_par2repair_with_fixtures() { let par2_file = Path::new("tests/fixtures/repair_scenarios/testfile.par2"); From 729d503bd7442d97485e92724eb08d8070e4b0ca Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 23:40:33 -0600 Subject: [PATCH 08/30] Match turbo single quiet verify output --- src/reporters/console.rs | 91 ++++++++++++++++++++++++++++++++++++++++ src/reporters/mod.rs | 4 +- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/reporters/console.rs b/src/reporters/console.rs index 54b8f1ba..dca6a96c 100644 --- a/src/reporters/console.rs +++ b/src/reporters/console.rs @@ -15,6 +15,26 @@ pub struct ConsoleVerificationReporter { output_lock: Mutex<()>, } +/// Concise verification output used for a single `-q`, matching +/// par2cmdline-turbo's quiet-but-not-silent mode. +pub struct ConciseVerificationReporter { + output_lock: Mutex<()>, +} + +impl Default for ConciseVerificationReporter { + fn default() -> Self { + Self::new() + } +} + +impl ConciseVerificationReporter { + pub fn new() -> Self { + Self { + output_lock: Mutex::new(()), + } + } +} + impl Default for ConsoleVerificationReporter { fn default() -> Self { Self::new() @@ -127,6 +147,77 @@ impl VerificationReporter for ConsoleVerificationReporter { } } +impl Reporter for ConciseVerificationReporter { + fn report_progress(&self, _message: &str, _progress: f64) {} + + fn report_error(&self, error: &str) { + let _lock = self.output_lock.lock().unwrap(); + eprintln!("Error: {}", error); + } + + fn report_complete(&self, message: &str) { + let _lock = self.output_lock.lock().unwrap(); + println!("{}", message); + } +} + +impl VerificationReporter for ConciseVerificationReporter { + fn report_verification_start(&self, _parallel: bool) {} + + fn report_files_found(&self, _count: usize) {} + + fn report_verifying_file(&self, _file_name: &str) {} + + fn report_file_status(&self, file_name: &str, status: FileStatus) { + let _lock = self.output_lock.lock().unwrap(); + match status { + FileStatus::Present => println!("Target: \"{}\" - found.", file_name), + FileStatus::Missing => println!("Target: \"{}\" - missing.", file_name), + FileStatus::Corrupted => {} + FileStatus::Renamed => println!("Target: \"{}\" - renamed.", file_name), + } + } + + fn report_damaged_blocks( + &self, + file_name: &str, + damaged_blocks: &[u32], + available_blocks: usize, + total_blocks: usize, + ) { + let _lock = self.output_lock.lock().unwrap(); + if !damaged_blocks.is_empty() { + println!( + "Target: \"{}\" - damaged. Found {} of {} data blocks.", + file_name, available_blocks, total_blocks + ); + } + } + + fn report_verification_results(&self, results: &VerificationResults) { + let _lock = self.output_lock.lock().unwrap(); + println!(); + + match (results.missing_block_count, results.repair_possible) { + (0, _) => println!("All files are correct, repair is not required."), + (_, true) => { + println!("Repair is required."); + println!("Repair is possible."); + } + (missing, false) => { + println!("Repair is required."); + println!("Repair is not possible."); + println!( + "You need {} more recovery blocks to be able to repair.", + missing - results.recovery_blocks_available + ); + } + } + } + + fn report_scanning_progress(&self, _fraction: f64) {} +} + // Base Reporter implementation for ConsoleRepairReporter impl Reporter for ConsoleRepairReporter { fn report_progress(&self, message: &str, progress: f64) { diff --git a/src/reporters/mod.rs b/src/reporters/mod.rs index 426faaea..e73d2515 100644 --- a/src/reporters/mod.rs +++ b/src/reporters/mod.rs @@ -7,7 +7,9 @@ mod console; mod silent; -pub use console::{ConsoleRepairReporter, ConsoleVerificationReporter}; +pub use console::{ + ConciseVerificationReporter, ConsoleRepairReporter, ConsoleVerificationReporter, +}; pub use silent::{SilentRepairReporter, SilentVerificationReporter}; use crate::verify::{FileStatus, VerificationResults}; From a7ce06eaf403a8e762072425ce1bb705df6ada15 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 23:43:31 -0600 Subject: [PATCH 09/30] Suppress repair progress in quiet mode --- src/bin/par2.rs | 2 ++ src/bin/par2repair.rs | 2 ++ src/reed_solomon/codec.rs | 35 ++++++++++++++++++++++++++--------- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/bin/par2.rs b/src/bin/par2.rs index 1eb3396c..71768932 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -827,6 +827,8 @@ fn handle_repair(matches: &clap::ArgMatches) -> Result<()> { let verify_config = par2rs::verify::VerificationConfig::try_from_args(matches).map_err(anyhow::Error::msg)?; + par2rs::reed_solomon::codec::set_repair_progress_output(!quiet); + let resolved_par2_file = par2rs::par2_files::resolve_par2_file_argument(Path::new(par2_file)) .with_context(|| format!("Failed to locate PAR2 file for {}", par2_file))?; diff --git a/src/bin/par2repair.rs b/src/bin/par2repair.rs index e6f55ae0..926bf126 100644 --- a/src/bin/par2repair.rs +++ b/src/bin/par2repair.rs @@ -61,6 +61,8 @@ fn main() -> Result<()> { // Create verification config from command line arguments let verify_config = VerificationConfig::try_from_args(&matches).map_err(anyhow::Error::msg)?; + par2rs::reed_solomon::codec::set_repair_progress_output(!quiet); + let resolved_par2_file = par2rs::par2_files::resolve_par2_file_argument(Path::new(par2_file)) .with_context(|| format!("Failed to locate PAR2 file for {}", par2_file))?; diff --git a/src/reed_solomon/codec.rs b/src/reed_solomon/codec.rs index 4b35bbbb..0d997e67 100644 --- a/src/reed_solomon/codec.rs +++ b/src/reed_solomon/codec.rs @@ -26,10 +26,12 @@ use crate::reed_solomon::simd::{detect_simd_support, process_slice_multiply_add_ use crate::RecoverySlicePacket; use log::debug; use rustc_hash::FxHashMap as HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::OnceLock; // Global SIMD level detection (done once at first use) static SIMD_LEVEL: OnceLock = OnceLock::new(); +static REPAIR_PROGRESS_OUTPUT: AtomicBool = AtomicBool::new(true); /// Initialize SIMD level once, using an explicit override from caller. /// @@ -45,6 +47,15 @@ pub fn init_simd_level(force_scalar: bool) { }); } +/// Enable or suppress low-level repair progress printed by the Reed-Solomon path. +pub fn set_repair_progress_output(enabled: bool) { + REPAIR_PROGRESS_OUTPUT.store(enabled, Ordering::Relaxed); +} + +fn repair_progress_output_enabled() -> bool { + REPAIR_PROGRESS_OUTPUT.load(Ordering::Relaxed) +} + /// Process entire slice at once: output = coefficient * input (direct write, no XOR) /// /// Uses the centralized scalar implementation from simd::common with Direct write mode. @@ -1450,9 +1461,11 @@ impl ReconstructionEngine { num_chunks, chunk_size ); - // Print initial progress for sabnzbd - print!("\rRepairing: 0.0%"); - std::io::Write::flush(&mut std::io::stdout()).ok(); + if repair_progress_output_enabled() { + // Print initial progress for sabnzbd + print!("\rRepairing: 0.0%"); + std::io::Write::flush(&mut std::io::stdout()).ok(); + } for chunk_idx in 0..num_chunks { let chunk_offset = chunk_idx * chunk_size; @@ -1465,7 +1478,9 @@ impl ReconstructionEngine { } else { num_chunks / 100 }; - if chunk_idx % report_interval == 0 || chunk_idx == num_chunks - 1 { + if repair_progress_output_enabled() + && (chunk_idx % report_interval == 0 || chunk_idx == num_chunks - 1) + { let percentage = (chunk_idx as f64 / num_chunks as f64) * 100.0; print!("\rRepairing: {:.1}%", percentage); std::io::Write::flush(&mut std::io::stdout()).ok(); @@ -1544,7 +1559,7 @@ impl ReconstructionEngine { // Report progress periodically based on input slices processed // This provides progress updates even with large chunk sizes - if num_chunks == 1 && idx % 100 == 0 { + if repair_progress_output_enabled() && num_chunks == 1 && idx % 100 == 0 { let percentage = (idx as f64 / available_slices.len() as f64) * 100.0; print!("\rRepairing: {:.1}%", percentage); std::io::Write::flush(&mut std::io::stdout()).ok(); @@ -1640,10 +1655,12 @@ impl ReconstructionEngine { } } - // Print final 100% progress - print!("\rRepairing: 100.0%"); - println!(); // Newline after completion - std::io::Write::flush(&mut std::io::stdout()).ok(); + if repair_progress_output_enabled() { + // Print final 100% progress + print!("\rRepairing: 100.0%"); + println!(); // Newline after completion + std::io::Write::flush(&mut std::io::stdout()).ok(); + } debug!("Chunked reconstruction completed successfully"); From 6d9a11f2a9847f544648b926b162246c7dfd0087 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Mon, 20 Apr 2026 23:48:07 -0600 Subject: [PATCH 10/30] Match turbo single quiet repair output --- src/repair/mod.rs | 48 +++++++++++++++++++++++++++++++------ src/reporters/console.rs | 29 ++++++++++++++++++++++ src/verify/global_engine.rs | 16 ++++++------- src/verify/mod.rs | 4 ++-- 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index d15d8d80..691b0e66 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -1025,12 +1025,14 @@ pub fn repair_files_with_base_path( verify_config: &crate::verify::VerificationConfig, base_path_override: Option<&Path>, ) -> Result<(RepairContext, RepairResult)> { - repair_files_with_base_path_and_extra_files( + let silent_reporter = crate::reporters::SilentVerificationReporter; + repair_files_with_base_path_and_extra_files_and_verification_reporter( par2_file, reporter, verify_config, base_path_override, &[], + &silent_reporter, ) } @@ -1041,6 +1043,26 @@ pub fn repair_files_with_base_path_and_extra_files( verify_config: &crate::verify::VerificationConfig, base_path_override: Option<&Path>, extra_files: &[PathBuf], +) -> Result<(RepairContext, RepairResult)> { + let silent_reporter = crate::reporters::SilentVerificationReporter; + repair_files_with_base_path_and_extra_files_and_verification_reporter( + par2_file, + reporter, + verify_config, + base_path_override, + extra_files, + &silent_reporter, + ) +} + +/// Repair files while reporting the pre-repair verification pass. +pub fn repair_files_with_base_path_and_extra_files_and_verification_reporter( + par2_file: &str, + reporter: Box, + verify_config: &crate::verify::VerificationConfig, + base_path_override: Option<&Path>, + extra_files: &[PathBuf], + verification_reporter: &dyn crate::reporters::VerificationReporter, ) -> Result<(RepairContext, RepairResult)> { let par2_path = Path::new(par2_file); @@ -1104,8 +1126,14 @@ pub fn repair_files_with_base_path_and_extra_files( if !extra_files.is_empty() || verify_config.rename_only { repair_verify_config.skip_full_file_md5 = false; } - let mut verification_results = - run_repair_verification(&par2_files, &repair_verify_config, &base_path, extra_files); + let mut verification_results = run_repair_verification( + &par2_files, + &repair_verify_config, + &base_path, + extra_files, + verification_reporter, + ); + verification_reporter.report_verification_results(&verification_results); // Re-load packets for repair context (verification consumed them) // This is acceptable since packet parsing is fast (no recovery slice data) @@ -1130,7 +1158,13 @@ pub fn repair_files_with_base_path_and_extra_files( let renamed_files = repair_context.restore_renamed_files(&verification_results)?; if !renamed_files.is_empty() { verification_results = - run_repair_verification(&par2_files, &repair_verify_config, &base_path, extra_files); + run_repair_verification( + &par2_files, + &repair_verify_config, + &base_path, + extra_files, + verification_reporter, + ); if repair_verification_is_complete(&verification_results) { return Ok(( @@ -1170,22 +1204,22 @@ fn run_repair_verification( repair_verify_config: &crate::verify::VerificationConfig, base_path: &Path, extra_files: &[PathBuf], + verification_reporter: &dyn crate::reporters::VerificationReporter, ) -> crate::verify::VerificationResults { let packet_set = crate::par2_files::load_par2_packets(par2_files, false, false); - let silent_reporter = crate::reporters::SilentVerificationReporter; if extra_files.is_empty() { crate::verify::comprehensive_verify_files( packet_set, repair_verify_config, - &silent_reporter, + verification_reporter, base_path, ) } else { crate::verify::comprehensive_verify_files_with_extra_files( packet_set, repair_verify_config, - &silent_reporter, + verification_reporter, base_path, extra_files, ) diff --git a/src/reporters/console.rs b/src/reporters/console.rs index dca6a96c..6310fe9f 100644 --- a/src/reporters/console.rs +++ b/src/reporters/console.rs @@ -5,6 +5,7 @@ use super::{RepairReporter, Reporter, VerificationReporter}; use crate::verify::{FileStatus, VerificationResults}; +use std::collections::HashSet; use std::sync::Mutex; /// Console implementation for verification operations @@ -19,6 +20,7 @@ pub struct ConsoleVerificationReporter { /// par2cmdline-turbo's quiet-but-not-silent mode. pub struct ConciseVerificationReporter { output_lock: Mutex<()>, + reported_files: Mutex>, } impl Default for ConciseVerificationReporter { @@ -31,6 +33,7 @@ impl ConciseVerificationReporter { pub fn new() -> Self { Self { output_lock: Mutex::new(()), + reported_files: Mutex::new(HashSet::new()), } } } @@ -170,6 +173,10 @@ impl VerificationReporter for ConciseVerificationReporter { fn report_file_status(&self, file_name: &str, status: FileStatus) { let _lock = self.output_lock.lock().unwrap(); + self.reported_files + .lock() + .unwrap() + .insert(file_name.to_string()); match status { FileStatus::Present => println!("Target: \"{}\" - found.", file_name), FileStatus::Missing => println!("Target: \"{}\" - missing.", file_name), @@ -187,6 +194,10 @@ impl VerificationReporter for ConciseVerificationReporter { ) { let _lock = self.output_lock.lock().unwrap(); if !damaged_blocks.is_empty() { + self.reported_files + .lock() + .unwrap() + .insert(file_name.to_string()); println!( "Target: \"{}\" - damaged. Found {} of {} data blocks.", file_name, available_blocks, total_blocks @@ -196,6 +207,24 @@ impl VerificationReporter for ConciseVerificationReporter { fn report_verification_results(&self, results: &VerificationResults) { let _lock = self.output_lock.lock().unwrap(); + let mut reported_files = self.reported_files.lock().unwrap(); + for file in &results.files { + if !reported_files.insert(file.file_name.clone()) { + continue; + } + + match file.status { + FileStatus::Present => println!("Target: \"{}\" - found.", file.file_name), + FileStatus::Missing => println!("Target: \"{}\" - missing.", file.file_name), + FileStatus::Corrupted => println!( + "Target: \"{}\" - damaged. Found {} of {} data blocks.", + file.file_name, file.blocks_available, file.total_blocks + ), + FileStatus::Renamed => println!("Target: \"{}\" - renamed.", file.file_name), + } + } + drop(reported_files); + println!(); match (results.missing_block_count, results.repair_possible) { diff --git a/src/verify/global_engine.rs b/src/verify/global_engine.rs index 00eecda3..1ae25b8b 100644 --- a/src/verify/global_engine.rs +++ b/src/verify/global_engine.rs @@ -194,7 +194,7 @@ impl GlobalVerificationEngine { /// 1. Scanning all available files and building a map of available blocks /// 2. Comparing against the global block table to determine what's missing /// 3. Computing file-level status based on block availability - pub fn verify_recovery_set( + pub fn verify_recovery_set( &self, reporter: &R, parallel: bool, @@ -207,7 +207,7 @@ impl GlobalVerificationEngine { /// Extra files are not target filters. They are scanned for blocks that match /// protected files, matching par2cmdline's `[files]` behavior for renamed or /// misplaced data files. - pub fn verify_recovery_set_with_extra_files( + pub fn verify_recovery_set_with_extra_files( &self, reporter: &R, parallel: bool, @@ -257,7 +257,7 @@ impl GlobalVerificationEngine { /// Scan all available files and build a global map of which blocks exist where /// This is the core of the global block table approach - we scan every file /// and index every block we find by its checksum, regardless of filename - fn scan_available_blocks_with_extra_files( + fn scan_available_blocks_with_extra_files( &self, reporter: &R, parallel: bool, @@ -382,7 +382,7 @@ impl GlobalVerificationEngine { ) } - fn process_extra_file( + fn process_extra_file( &self, file_path: &Path, reporter_lock: &Mutex<&R>, @@ -421,7 +421,7 @@ impl GlobalVerificationEngine { } /// Process a single file: scan blocks and report status - fn process_single_file( + fn process_single_file( &self, file_description: &FileDescriptionPacket, reporter_lock: &Mutex<&R>, @@ -501,7 +501,7 @@ impl GlobalVerificationEngine { } /// Scan a single file and return its local block map with progress reporting - fn scan_single_file_with_progress( + fn scan_single_file_with_progress( &self, file_path: &Path, file_size: FileSize, @@ -959,7 +959,7 @@ impl GlobalVerificationEngine { } /// Report scanning progress to the reporter - fn report_progress( + fn report_progress( reporter_lock: &Mutex<&R>, state: &crate::verify::scanner_state::ScannerState, file_size: crate::verify::types::FileSize, @@ -1210,7 +1210,7 @@ impl GlobalVerificationEngine { } /// Report file verification status to the reporter - fn report_file_status( + fn report_file_status( reporter_lock: &Mutex<&R>, file_name: &str, status: FileStatus, diff --git a/src/verify/mod.rs b/src/verify/mod.rs index 2b30f22a..07a91805 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -52,7 +52,7 @@ use std::path::{Path, PathBuf}; /// * `config` - Verification configuration (threading, parallel/sequential) /// * `reporter` - Progress reporter for verification events /// * `base_dir` - Base directory for resolving file paths -pub fn comprehensive_verify_files( +pub fn comprehensive_verify_files( packet_set: crate::par2_files::PacketSet, config: &VerificationConfig, reporter: &R, @@ -65,7 +65,7 @@ pub fn comprehensive_verify_files( /// /// Extra files are scanned for matching data blocks but do not limit the target /// recovery set. This mirrors par2cmdline's optional `[files]` arguments. -pub fn comprehensive_verify_files_with_extra_files( +pub fn comprehensive_verify_files_with_extra_files( packet_set: crate::par2_files::PacketSet, config: &VerificationConfig, reporter: &R, From 0d593d1de7a6b7ec9a77455fe352c7c941fd2900 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:05:45 -0600 Subject: [PATCH 11/30] Report opening files during verify --- src/reporters/console.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/reporters/console.rs b/src/reporters/console.rs index 6310fe9f..ee486d66 100644 --- a/src/reporters/console.rs +++ b/src/reporters/console.rs @@ -99,8 +99,9 @@ impl VerificationReporter for ConsoleVerificationReporter { // par2cmdline doesn't print this } - fn report_verifying_file(&self, _file_name: &str) { - // par2cmdline doesn't print individual file verification start + fn report_verifying_file(&self, file_name: &str) { + let _lock = self.output_lock.lock().unwrap(); + println!("Opening: \"{}\"", file_name); } fn report_file_status(&self, file_name: &str, status: FileStatus) { From e9727f3bef4d40a9d894f859f71eb5763bf5f424 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:10:46 -0600 Subject: [PATCH 12/30] Match turbo healthy verify summary --- src/verify/types.rs | 17 ++++++++++++----- tests/test_verify_types_comprehensive.rs | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/verify/types.rs b/src/verify/types.rs index 63489cb7..64385c1b 100644 --- a/src/verify/types.rs +++ b/src/verify/types.rs @@ -329,15 +329,23 @@ impl VerificationResults { impl fmt::Display for VerificationResults { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f)?; + + if self.missing_block_count == 0 + && self.renamed_file_count == 0 + && self.corrupted_file_count == 0 + && self.missing_file_count == 0 + { + writeln!(f, "All files are correct, repair is not required.")?; + return Ok(()); + } + // par2cmdline prints "Scanning extra files:" after verification writeln!(f, "Scanning extra files:")?; writeln!(f)?; writeln!(f)?; - // Print repair status first if repair is needed - if self.missing_block_count > 0 { - writeln!(f, "Repair is required.")?; - } + writeln!(f, "Repair is required.")?; // Functional file status reporting [ @@ -367,7 +375,6 @@ impl fmt::Display for VerificationResults { // Repair status using functional pattern matching match (self.missing_block_count, self.repair_possible) { - (0, _) => writeln!(f, "All files are correct, repair is not required.")?, (missing, true) => { writeln!(f, "Repair is possible.")?; if self.recovery_blocks_available > missing { diff --git a/tests/test_verify_types_comprehensive.rs b/tests/test_verify_types_comprehensive.rs index 7f17d473..adcb7d91 100644 --- a/tests/test_verify_types_comprehensive.rs +++ b/tests/test_verify_types_comprehensive.rs @@ -195,7 +195,7 @@ fn test_verification_results_all_ok() { }; let display = results.to_string(); - assert!(display.contains("5 file(s) are ok.")); + assert!(!display.contains("5 file(s) are ok.")); assert!(display.contains("All files are correct")); } From add0d0ee3dac22a9db69b538241c762a284af2c9 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:17:31 -0600 Subject: [PATCH 13/30] Reduce normal repair summary duplication --- src/repair/mod.rs | 36 ++++++++++++++++-------------------- src/repair/progress.rs | 26 ++++++++++++++++---------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 691b0e66..7d2fcfa4 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -1101,6 +1101,22 @@ pub fn repair_files_with_base_path_and_extra_files_and_verification_reporter( .or_else(|| verify_config.base_path.clone()) .unwrap_or_else(|| par2_path.parent().unwrap_or(Path::new(".")).to_path_buf()); + // Create repair context before verification so normal repair output prints + // the set summary once before source verification. + let mut repair_builder = RepairContextBuilder::new() + .packets(initial_packet_set.packets) + .metadata(metadata) + .base_path(base_path.clone()) + .reporter(reporter); + if let Some(memory_limit) = verify_config.memory_limit { + repair_builder = repair_builder.memory_limit(memory_limit); + } + let repair_context = repair_builder.build()?; + + repair_context + .reporter() + .report_statistics(&repair_context.recovery_set); + // CRITICAL FIX: Run comprehensive verification to get accurate block availability // Reference: par2cmdline-turbo uses byte-by-byte sliding window scanning (FileCheckSummer) // to find blocks at ANY position (displaced blocks), not just aligned positions. @@ -1135,26 +1151,6 @@ pub fn repair_files_with_base_path_and_extra_files_and_verification_reporter( ); verification_reporter.report_verification_results(&verification_results); - // Re-load packets for repair context (verification consumed them) - // This is acceptable since packet parsing is fast (no recovery slice data) - let packet_set = crate::par2_files::load_par2_packets(&par2_files, false, false); - - // Create repair context using builder - let mut repair_builder = RepairContextBuilder::new() - .packets(packet_set.packets) - .metadata(metadata) - .base_path(base_path.clone()) - .reporter(reporter); - if let Some(memory_limit) = verify_config.memory_limit { - repair_builder = repair_builder.memory_limit(memory_limit); - } - let repair_context = repair_builder.build()?; - - // Report statistics before starting - repair_context - .reporter() - .report_statistics(&repair_context.recovery_set); - let renamed_files = repair_context.restore_renamed_files(&verification_results)?; if !renamed_files.is_empty() { verification_results = diff --git a/src/repair/progress.rs b/src/repair/progress.rs index bbe7094a..eaa43ccf 100644 --- a/src/repair/progress.rs +++ b/src/repair/progress.rs @@ -79,12 +79,24 @@ pub trait ProgressReporter: Send + Sync { /// Console reporter - standard par2cmdline-style output pub struct ConsoleReporter { quiet: bool, + show_recovery_info: bool, } impl ConsoleReporter { /// Create a new console reporter pub fn new(quiet: bool) -> Self { - Self { quiet } + Self { + quiet, + show_recovery_info: true, + } + } + + /// Create a new console reporter with control over recovery summary output. + pub fn with_recovery_info(quiet: bool, show_recovery_info: bool) -> Self { + Self { + quiet, + show_recovery_info, + } } } @@ -94,17 +106,11 @@ impl ProgressReporter for ConsoleReporter { return; } - println!( - "There are {} recoverable files and {} recovery blocks.", - recovery_set.files.len(), - recovery_set.recovery_slices_metadata.len() - ); - println!("The block size used was {} bytes.", recovery_set.slice_size); - println!(); + recovery_set.print_statistics(); } fn report_file_opening(&self, file_name: &str) { - if self.quiet { + if self.quiet || !self.show_recovery_info { return; } println!("Opening: \"{}\"", file_name); @@ -158,7 +164,7 @@ impl ProgressReporter for ConsoleReporter { } fn report_recovery_info(&self, available: usize, needed: usize) { - if self.quiet { + if self.quiet || !self.show_recovery_info { return; } From 78b03d7cc26f42d80d0bbe08a48d203419c01967 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:19:04 -0600 Subject: [PATCH 14/30] Backfill normal verify file statuses --- src/reporters/console.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/reporters/console.rs b/src/reporters/console.rs index ee486d66..56201032 100644 --- a/src/reporters/console.rs +++ b/src/reporters/console.rs @@ -14,6 +14,7 @@ pub struct ConsoleVerificationReporter { /// Mutex to ensure atomic printing from multiple threads /// Reference: par2cmdline-turbo uses output_lock for thread-safe console output output_lock: Mutex<()>, + reported_files: Mutex>, } /// Concise verification output used for a single `-q`, matching @@ -48,6 +49,7 @@ impl ConsoleVerificationReporter { pub fn new() -> Self { Self { output_lock: Mutex::new(()), + reported_files: Mutex::new(HashSet::new()), } } } @@ -106,6 +108,10 @@ impl VerificationReporter for ConsoleVerificationReporter { fn report_file_status(&self, file_name: &str, status: FileStatus) { let _lock = self.output_lock.lock().unwrap(); + self.reported_files + .lock() + .unwrap() + .insert(file_name.to_string()); match status { FileStatus::Present => println!("Target: \"{}\" - found.", file_name), FileStatus::Missing => println!("Target: \"{}\" - missing.", file_name), @@ -126,6 +132,10 @@ impl VerificationReporter for ConsoleVerificationReporter { ) { let _lock = self.output_lock.lock().unwrap(); if !damaged_blocks.is_empty() { + self.reported_files + .lock() + .unwrap() + .insert(file_name.to_string()); println!( "Target: \"{}\" - damaged. Found {} of {} data blocks.", file_name, available_blocks, total_blocks @@ -135,6 +145,24 @@ impl VerificationReporter for ConsoleVerificationReporter { fn report_verification_results(&self, results: &VerificationResults) { let _lock = self.output_lock.lock().unwrap(); + let mut reported_files = self.reported_files.lock().unwrap(); + for file in &results.files { + if !reported_files.insert(file.file_name.clone()) { + continue; + } + + match file.status { + FileStatus::Present => println!("Target: \"{}\" - found.", file.file_name), + FileStatus::Missing => println!("Target: \"{}\" - missing.", file.file_name), + FileStatus::Corrupted => println!( + "Target: \"{}\" - damaged. Found {} of {} data blocks.", + file.file_name, file.blocks_available, file.total_blocks + ), + FileStatus::Renamed => println!("Target: \"{}\" - renamed.", file.file_name), + } + } + drop(reported_files); + // Use the Display implementation for main summary // par2cmdline doesn't print detailed block lists in normal mode print!("{}", results); From c971016f230603838d51300984e4143091433558 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:22:22 -0600 Subject: [PATCH 15/30] Report opening repaired files --- src/repair/mod.rs | 1 + src/repair/progress.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 7d2fcfa4..11b26f99 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -521,6 +521,7 @@ impl RepairContext { )) })?; + self.reporter().report_file_opening(&file_info.file_name); self.reporter() .report_verification(&file_info.file_name, VerificationResult::Verified); } diff --git a/src/repair/progress.rs b/src/repair/progress.rs index eaa43ccf..325fc205 100644 --- a/src/repair/progress.rs +++ b/src/repair/progress.rs @@ -110,7 +110,7 @@ impl ProgressReporter for ConsoleReporter { } fn report_file_opening(&self, file_name: &str) { - if self.quiet || !self.show_recovery_info { + if self.quiet { return; } println!("Opening: \"{}\"", file_name); From 1dac8c5569ba87dcc5fa6d86282504dd96ff820b Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:29:41 -0600 Subject: [PATCH 16/30] Deduplicate loading progress output --- src/par2_files.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++++ src/repair/mod.rs | 29 +++++++++++-- 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/src/par2_files.rs b/src/par2_files.rs index a871dbd1..bbcc1690 100644 --- a/src/par2_files.rs +++ b/src/par2_files.rs @@ -460,6 +460,10 @@ pub fn load_par2_packets( include_recovery_slices: bool, show_progress: bool, ) -> PacketSet { + if show_progress { + return load_par2_packets_with_progress(par2_files, include_recovery_slices); + } + // Parse files in parallel and collect results // Use mutex for thread-safe output (like par2cmdline-turbo's output_lock) let output_lock = Mutex::new(()); @@ -543,6 +547,109 @@ pub fn load_par2_packets( PacketSet::new(packets, recovery_block_count, base_dir) } +fn load_par2_packets_with_progress( + par2_files: &[PathBuf], + include_recovery_slices: bool, +) -> PacketSet { + let output_lock = Mutex::new(()); + let mut primary_set_id = None; + let mut seen_hashes = HashSet::default(); + let mut packets = Vec::new(); + + for par2_file in par2_files { + let filename = par2_file + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown"); + { + let _guard = output_lock.lock().unwrap(); + println!("Loading \"{}\".", filename); + } + + let result = + match parse_single_file(par2_file, include_recovery_slices, false, &output_lock) { + Ok(result) => result, + Err(e) => { + let _guard = output_lock.lock().unwrap(); + eprintln!( + "Warning: Failed to parse PAR2 file {}: {}", + par2_file.display(), + e + ); + continue; + } + }; + + let file_set_id = result.packets.first().map(packet_recovery_set_id); + if primary_set_id.is_none() { + primary_set_id = file_set_id; + } + + let mut new_packets = Vec::new(); + for packet in result.packets { + if !include_recovery_slices && matches!(packet, Packet::RecoverySlice(_)) { + continue; + } + + if primary_set_id.is_some_and(|set_id| packet_recovery_set_id(&packet) != set_id) { + continue; + } + + let packet_hash = get_packet_hash(&packet); + if seen_hashes.insert(packet_hash) { + new_packets.push(packet); + } + } + + let recovery_blocks = if include_recovery_slices { + new_packets + .iter() + .filter(|packet| matches!(packet, Packet::RecoverySlice(_))) + .count() + } else if file_set_id == primary_set_id { + result.recovery_block_count + } else { + 0 + }; + + let loaded_packet_count = new_packets.len() + + if include_recovery_slices { + 0 + } else { + recovery_blocks + }; + print_packet_load_result(loaded_packet_count, recovery_blocks, &output_lock); + + packets.extend(new_packets.into_iter().filter(|packet| { + include_recovery_slices || !matches!(packet, Packet::RecoverySlice(_)) + })); + } + + let recovery_block_count = if let Some(set_id) = primary_set_id { + if include_recovery_slices { + packets + .iter() + .filter(|packet| matches!(packet, Packet::RecoverySlice(_))) + .count() + } else { + parse_recovery_slice_metadata(par2_files, false) + .into_iter() + .filter(|metadata| metadata.set_id == set_id) + .count() + } + } else { + 0 + }; + + let base_dir = par2_files + .first() + .and_then(|p| p.parent()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| PathBuf::from(".")); + + PacketSet::new(packets, recovery_block_count, base_dir) +} + fn packet_recovery_set_id(packet: &Packet) -> RecoverySetId { match packet { Packet::Main(p) => p.set_id, diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 11b26f99..54de4d79 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -1064,6 +1064,28 @@ pub fn repair_files_with_base_path_and_extra_files_and_verification_reporter( base_path_override: Option<&Path>, extra_files: &[PathBuf], verification_reporter: &dyn crate::reporters::VerificationReporter, +) -> Result<(RepairContext, RepairResult)> { + repair_files_with_verification_reporter_and_loading_progress( + par2_file, + reporter, + verify_config, + base_path_override, + extra_files, + verification_reporter, + false, + ) +} + +/// Repair files while reporting pre-repair verification, optionally showing +/// packet loading output. +pub fn repair_files_with_verification_reporter_and_loading_progress( + par2_file: &str, + reporter: Box, + verify_config: &crate::verify::VerificationConfig, + base_path_override: Option<&Path>, + extra_files: &[PathBuf], + verification_reporter: &dyn crate::reporters::VerificationReporter, + show_loading_progress: bool, ) -> Result<(RepairContext, RepairResult)> { let par2_path = Path::new(par2_file); @@ -1090,8 +1112,9 @@ pub fn repair_files_with_base_path_and_extra_files_and_verification_reporter( // Load packets WITHOUT recovery slices (use metadata for lazy loading instead) // This saves ~1.5GB of memory for large PAR2 sets since recovery data is // loaded on-demand during reconstruction via RecoverySliceProvider - let initial_packet_set = crate::par2_files::load_par2_packets(&par2_files, false, false); - if initial_packet_set.packets.is_empty() { + let context_packet_set = + crate::par2_files::load_par2_packets(&par2_files, false, show_loading_progress); + if context_packet_set.packets.is_empty() { return Err(RepairError::NoValidPackets); } @@ -1105,7 +1128,7 @@ pub fn repair_files_with_base_path_and_extra_files_and_verification_reporter( // Create repair context before verification so normal repair output prints // the set summary once before source verification. let mut repair_builder = RepairContextBuilder::new() - .packets(initial_packet_set.packets) + .packets(context_packet_set.packets) .metadata(metadata) .base_path(base_path.clone()) .reporter(reporter); From a07af334f90e597a5aa46b918ee932602411f3f0 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:33:01 -0600 Subject: [PATCH 17/30] Report Reed Solomon construction progress --- src/repair/mod.rs | 1 + src/repair/progress.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 54de4d79..52735ee3 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -725,6 +725,7 @@ impl RepairContext { total_input_slices, dummy_recovery_slices, ); + self.reporter().report_constructing(); // Create output buffers for all missing slices let mut output_buffers: HashMap>> = HashMap::default(); diff --git a/src/repair/progress.rs b/src/repair/progress.rs index 325fc205..2d95475d 100644 --- a/src/repair/progress.rs +++ b/src/repair/progress.rs @@ -205,8 +205,8 @@ impl ProgressReporter for ConsoleReporter { fn report_repair_header(&self) { if !self.quiet { - // Don't print a separate header - sabnzbd expects only "Repairing: XX.X%" format - // The first progress update will show the repair status + println!(); + println!("Computing Reed Solomon matrix."); } } From b35e36f06a19823f6d8a317145f13743e7bc1fa8 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:37:12 -0600 Subject: [PATCH 18/30] Match turbo duplicate loading order --- src/par2_files.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/par2_files.rs b/src/par2_files.rs index bbcc1690..261dc7fd 100644 --- a/src/par2_files.rs +++ b/src/par2_files.rs @@ -556,7 +556,7 @@ fn load_par2_packets_with_progress( let mut seen_hashes = HashSet::default(); let mut packets = Vec::new(); - for par2_file in par2_files { + for par2_file in loading_progress_order(par2_files) { let filename = par2_file .file_name() .and_then(|name| name.to_str()) @@ -650,6 +650,35 @@ fn load_par2_packets_with_progress( PacketSet::new(packets, recovery_block_count, base_dir) } +fn loading_progress_order(par2_files: &[PathBuf]) -> Vec<&PathBuf> { + let Some(first) = par2_files.first() else { + return Vec::new(); + }; + + let first_filename = loading_filename(first); + let mut ordered = Vec::with_capacity(par2_files.len()); + ordered.push(first); + ordered.extend( + par2_files + .iter() + .skip(1) + .filter(|path| loading_filename(path) != first_filename), + ); + ordered.extend( + par2_files + .iter() + .skip(1) + .filter(|path| loading_filename(path) == first_filename), + ); + ordered +} + +fn loading_filename(path: &Path) -> &str { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") +} + fn packet_recovery_set_id(packet: &Packet) -> RecoverySetId { match packet { Packet::Main(p) => p.set_id, From 0c9b1026308de3595cd8e43eb4e44cfef20f5735 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:38:39 -0600 Subject: [PATCH 19/30] Label reconstruction progress as solving --- src/reed_solomon/codec.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/reed_solomon/codec.rs b/src/reed_solomon/codec.rs index 0d997e67..3b6e4aa9 100644 --- a/src/reed_solomon/codec.rs +++ b/src/reed_solomon/codec.rs @@ -1462,8 +1462,7 @@ impl ReconstructionEngine { ); if repair_progress_output_enabled() { - // Print initial progress for sabnzbd - print!("\rRepairing: 0.0%"); + print!("\rSolving: 0.0%"); std::io::Write::flush(&mut std::io::stdout()).ok(); } @@ -1471,7 +1470,6 @@ impl ReconstructionEngine { let chunk_offset = chunk_idx * chunk_size; let current_chunk_size = (self.slice_size - chunk_offset).min(chunk_size); - // Report progress for sabnzbd compatibility // Print every ~1% or at minimum every chunk for small files let report_interval = if num_chunks < 100 { 1 @@ -1482,7 +1480,7 @@ impl ReconstructionEngine { && (chunk_idx % report_interval == 0 || chunk_idx == num_chunks - 1) { let percentage = (chunk_idx as f64 / num_chunks as f64) * 100.0; - print!("\rRepairing: {:.1}%", percentage); + print!("\rSolving: {:.1}%", percentage); std::io::Write::flush(&mut std::io::stdout()).ok(); } @@ -1561,7 +1559,7 @@ impl ReconstructionEngine { // This provides progress updates even with large chunk sizes if repair_progress_output_enabled() && num_chunks == 1 && idx % 100 == 0 { let percentage = (idx as f64 / available_slices.len() as f64) * 100.0; - print!("\rRepairing: {:.1}%", percentage); + print!("\rSolving: {:.1}%", percentage); std::io::Write::flush(&mut std::io::stdout()).ok(); } @@ -1656,9 +1654,8 @@ impl ReconstructionEngine { } if repair_progress_output_enabled() { - // Print final 100% progress - print!("\rRepairing: 100.0%"); - println!(); // Newline after completion + print!("\rSolving: done."); + println!(); std::io::Write::flush(&mut std::io::stdout()).ok(); } From ee67f81fcf0af38d5ea6fd0ee0d70956fde71314 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:40:43 -0600 Subject: [PATCH 20/30] Report repaired data write phase --- src/repair/mod.rs | 2 ++ src/repair/progress.rs | 29 ++++++++++++++++---- tests/test_repair_progress_comprehensive.rs | 30 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 52735ee3..11ce35c4 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -834,6 +834,7 @@ impl RepairContext { block_sources: &HashMap, ) -> Result<()> { debug!("Writing repaired file with streaming I/O: {:?}", file_path); + self.reporter().report_writing_recovered_data(); // Write to temp file first, then rename to avoid corrupting source while reading let temp_path = file_path.with_extension("par2_tmp"); @@ -993,6 +994,7 @@ impl RepairContext { "✓ Wrote {} bytes to {:?}, MD5 verified: {:02x?}", bytes_written, file_path, computed_md5 ); + self.reporter().report_bytes_written(bytes_written); Ok(()) } diff --git a/src/repair/progress.rs b/src/repair/progress.rs index 2d95475d..cc2164c2 100644 --- a/src/repair/progress.rs +++ b/src/repair/progress.rs @@ -51,6 +51,12 @@ pub trait ProgressReporter: Send + Sync { /// Report file writing progress fn report_writing_progress(&self, file_name: &str, bytes_written: u64, total_bytes: u64); + /// Report the start of writing recovered data + fn report_writing_recovered_data(&self); + + /// Report the number of repaired bytes written to disk + fn report_bytes_written(&self, bytes_written: u64); + /// Report repair completion for a file fn report_repair_complete(&self, file_name: &str, repaired: bool); @@ -263,21 +269,32 @@ impl ProgressReporter for ConsoleReporter { } } + fn report_writing_recovered_data(&self) { + if self.quiet { + return; + } + println!("Writing recovered data"); + } + + fn report_bytes_written(&self, bytes_written: u64) { + if self.quiet { + return; + } + println!("Wrote {} bytes to disk", bytes_written); + } + fn report_repair_start(&self, file_name: &str) { if self.quiet { return; } - print!("Repairing \"{}\"... ", file_name); - std::io::Write::flush(&mut std::io::stdout()).unwrap_or(()); + let _ = file_name; } fn report_repair_complete(&self, _file_name: &str, repaired: bool) { if self.quiet { return; } - if repaired { - println!("done."); - } else { + if !repaired { println!("already valid."); } } @@ -374,6 +391,8 @@ impl ProgressReporter for SilentReporter { fn report_computing_progress(&self, _blocks_processed: usize, _total_blocks: usize) {} fn report_repair_start(&self, _file_name: &str) {} fn report_writing_progress(&self, _file_name: &str, _bytes_written: u64, _total_bytes: u64) {} + fn report_writing_recovered_data(&self) {} + fn report_bytes_written(&self, _bytes_written: u64) {} fn report_repair_complete(&self, _file_name: &str, _repaired: bool) {} fn report_repair_failed(&self, _file_name: &str, _error: &str) {} fn report_verification_header(&self) {} diff --git a/tests/test_repair_progress_comprehensive.rs b/tests/test_repair_progress_comprehensive.rs index d4e7c3c9..1dba365d 100644 --- a/tests/test_repair_progress_comprehensive.rs +++ b/tests/test_repair_progress_comprehensive.rs @@ -330,6 +330,34 @@ fn test_console_reporter_report_writing_progress_quiet() { // Should not panic } +#[test] +fn test_console_reporter_report_writing_recovered_data() { + let reporter = ConsoleReporter::new(false); + reporter.report_writing_recovered_data(); + // Should not panic +} + +#[test] +fn test_console_reporter_report_writing_recovered_data_quiet() { + let reporter = ConsoleReporter::new(true); + reporter.report_writing_recovered_data(); + // Should not panic +} + +#[test] +fn test_console_reporter_report_bytes_written() { + let reporter = ConsoleReporter::new(false); + reporter.report_bytes_written(12345); + // Should not panic +} + +#[test] +fn test_console_reporter_report_bytes_written_quiet() { + let reporter = ConsoleReporter::new(true); + reporter.report_bytes_written(12345); + // Should not panic +} + #[test] fn test_console_reporter_report_repair_complete_repaired() { let reporter = ConsoleReporter::new(false); @@ -461,6 +489,8 @@ fn test_silent_reporter_all_methods() { reporter.report_computing_progress(50, 100); reporter.report_repair_start("test.txt"); reporter.report_writing_progress("test.txt", 500, 1000); + reporter.report_writing_recovered_data(); + reporter.report_bytes_written(1000); reporter.report_repair_complete("test.txt", true); reporter.report_repair_failed("test.txt", "error"); reporter.report_verification_header(); From 53e05d6368ecf518c270da368cb34ff53e132746 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:42:58 -0600 Subject: [PATCH 21/30] Report repaired file scan progress --- src/repair/mod.rs | 8 ++++++++ src/repair/progress.rs | 19 +++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 11ce35c4..4558fe68 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -522,6 +522,14 @@ impl RepairContext { })?; self.reporter().report_file_opening(&file_info.file_name); + let file_length = file_info.file_length.as_u64(); + self.reporter() + .report_scanning_progress(&file_info.file_name, 0, file_length); + self.reporter().report_scanning_progress( + &file_info.file_name, + self.recovery_set.slice_size.as_u64().min(file_length), + file_length, + ); self.reporter() .report_verification(&file_info.file_name, VerificationResult::Verified); } diff --git a/src/repair/progress.rs b/src/repair/progress.rs index cc2164c2..d199449a 100644 --- a/src/repair/progress.rs +++ b/src/repair/progress.rs @@ -147,18 +147,13 @@ impl ProgressReporter for ConsoleReporter { return; } - // Calculate percentage with higher precision: (10000 * progress / total) for 0.01% precision - let percentage_100x = ((10000 * bytes_processed) / total_bytes) as u32; - let percentage = percentage_100x as f64 / 100.0; - - // Format as "Scanning: "filename": XX.XX%\r" with two decimal places - let truncated_name = if file_name.len() > 45 { - format!("{}...", &file_name[..42]) - } else { - file_name.to_string() - }; - - print!("Scanning: \"{}\": {:.2}%\r", truncated_name, percentage); + let _ = file_name; + let percentage_10x = ((1000 * bytes_processed) / total_bytes) as u32; + print!( + "Scanning: {}.{}%\r", + percentage_10x / 10, + percentage_10x % 10 + ); std::io::Write::flush(&mut std::io::stdout()).unwrap_or(()); } From 6301ef85e1688cac016405490209ffd6222cd885 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:43:52 -0600 Subject: [PATCH 22/30] Report construction progress tick --- src/repair/progress.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/repair/progress.rs b/src/repair/progress.rs index d199449a..49811e26 100644 --- a/src/repair/progress.rs +++ b/src/repair/progress.rs @@ -231,6 +231,7 @@ impl ProgressReporter for ConsoleReporter { if self.quiet { return; } + print!("Constructing: 0.0%\r"); println!("Constructing: done."); } From ae14a1f1f17f7ca7a0b3f8e6f46d61aa8296ed06 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 00:45:29 -0600 Subject: [PATCH 23/30] Report repair progress before writing --- src/repair/mod.rs | 5 ++++- src/repair/progress.rs | 11 +++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 4558fe68..e6f39c12 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -842,7 +842,6 @@ impl RepairContext { block_sources: &HashMap, ) -> Result<()> { debug!("Writing repaired file with streaming I/O: {:?}", file_path); - self.reporter().report_writing_recovered_data(); // Write to temp file first, then rename to avoid corrupting source while reading let temp_path = file_path.with_extension("par2_tmp"); @@ -965,8 +964,12 @@ impl RepairContext { } else { return Err(RepairError::SliceNotAvailable(slice_index)); } + + self.reporter() + .report_computing_progress(slice_index + 1, file_info.slice_count.as_usize()); } + self.reporter().report_writing_recovered_data(); flush_writer(&mut writer, &temp_path)?; // Finalize MD5 computation and get the hash diff --git a/src/repair/progress.rs b/src/repair/progress.rs index 49811e26..bacbce91 100644 --- a/src/repair/progress.rs +++ b/src/repair/progress.rs @@ -236,12 +236,15 @@ impl ProgressReporter for ConsoleReporter { } fn report_computing_progress(&self, blocks_processed: usize, total_blocks: usize) { - if self.quiet { + if self.quiet || total_blocks == 0 { return; } - let percentage = (blocks_processed as f64 / total_blocks as f64) * 100.0; - // Output format compatible with sabnzbd: "Repairing: XX.X%" - print!("\rRepairing: {:.1}%", percentage); + let percentage_10x = (blocks_processed * 1000) / total_blocks; + print!( + "\rRepairing: {}.{}%", + percentage_10x / 10, + percentage_10x % 10 + ); std::io::Write::flush(&mut std::io::stdout()).unwrap_or(()); if blocks_processed == total_blocks { println!(); From 595184ddf2c3ec6a47aa257b7070ccc8b51ed70a Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Tue, 21 Apr 2026 21:43:05 -0600 Subject: [PATCH 24/30] Fix PR review comments --- src/create/file_naming.rs | 13 +++++++- src/packets/mod.rs | 55 +++++++++++++++++++------------- tests/test_create_integration.rs | 18 +++++++++-- tests/test_repair_context.rs | 8 ++++- 4 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/create/file_naming.rs b/src/create/file_naming.rs index df54f95d..3bb728d9 100644 --- a/src/create/file_naming.rs +++ b/src/create/file_naming.rs @@ -126,7 +126,7 @@ fn allocate_recovery_blocks( }; } - if file_number == 0 && blocks > 0 { + if blocks == 0 || file_number == 0 { return allocate_recovery_blocks( recovery_file_count, recovery_block_count, @@ -423,6 +423,17 @@ mod tests { assert_eq!(plan[0].block_count, 2); } + #[test] + fn limited_scheme_falls_back_when_cap_exhausts_files() { + let allocations = allocate_recovery_blocks(1, 10, 0, RecoveryFileScheme::Limited, 4, 1); + + assert_eq!(allocations.len(), 2); + assert_eq!(allocations[0].exponent, 0); + assert_eq!(allocations[0].count, 10); + assert_eq!(allocations[1].exponent, 10); + assert_eq!(allocations[1].count, 0); + } + /// Test count_digits helper /// Reference: par2cmdline-turbo/src/par2creator.cpp lines 604-615 #[test] diff --git a/src/packets/mod.rs b/src/packets/mod.rs index 5c925131..9635abe4 100644 --- a/src/packets/mod.rs +++ b/src/packets/mod.rs @@ -229,9 +229,20 @@ fn scan_for_next_magic(reader: &mut R) -> std::io::Result(reader: &mut R) -> std::io::Result<()> { - reader.seek(SeekFrom::Current(-((MIN_PACKET_SIZE as i64) - 1)))?; - Ok(()) +fn resync_to_next_magic(reader: &mut R, rewind_bytes: i64) -> bool { + if rewind_bytes != 0 + && reader + .seek(std::io::SeekFrom::Current(-rewind_bytes)) + .is_err() + { + return false; + } + + if scan_for_next_magic(reader).ok().flatten().is_none() { + return false; + } + + reader.seek(std::io::SeekFrom::Current(-8)).is_ok() } /// Parse packets with optional recovery slice inclusion @@ -266,15 +277,9 @@ pub fn parse_packets_with_options( break; } Err(PacketParseError::InvalidMagic(_)) => { - // Bad magic - try to find next valid packet by scanning forward - if rewind_after_invalid_header(reader).is_err() { - break; - } - if scan_for_next_magic(reader).ok().flatten().is_some() { - // Found magic, but we need to rewind 8 bytes so the next parse reads the header - if reader.seek(SeekFrom::Current(-8)).is_err() { - break; - } + // PacketHeader::parse consumed 64 bytes. Rewind 63 bytes so + // resync still checks the byte immediately after the bad start. + if resync_to_next_magic(reader, 63) { continue; } else { break; @@ -293,12 +298,7 @@ pub fn parse_packets_with_options( } Err(_) => { // Validation failed - try to find next valid packet - if scan_for_next_magic(reader).ok().flatten().is_some() { - // Found magic, rewind 8 bytes - if reader.seek(SeekFrom::Current(-8)).is_err() { - break; - } - } else { + if !resync_to_next_magic(reader, 0) { break; } } @@ -311,10 +311,7 @@ pub fn parse_packets_with_options( Ok(data) => data, Err(_) => { // Failed to read packet body - try to find next valid packet - if scan_for_next_magic(reader).ok().flatten().is_some() { - if reader.seek(SeekFrom::Current(-8)).is_err() { - break; - } + if resync_to_next_magic(reader, 0) { continue; } else { break; @@ -747,6 +744,20 @@ mod tests { assert_eq!(pos, 20); // 12 bytes before magic + 8 magic bytes } + #[test] + fn invalid_magic_resync_checks_next_byte() { + let mut data = vec![0xFF; 64]; + data[1..9].copy_from_slice(MAGIC_BYTES); + let mut cursor = Cursor::new(&data); + + let result = PacketHeader::parse(&mut cursor); + assert!(matches!(result, Err(PacketParseError::InvalidMagic(_)))); + assert_eq!(cursor.position(), 64); + + assert!(resync_to_next_magic(&mut cursor, 63)); + assert_eq!(cursor.position(), 1); + } + #[test] fn corrupt_packet_recovery() { // Test that we can recover from a corrupt packet by finding the next valid magic diff --git a/tests/test_create_integration.rs b/tests/test_create_integration.rs index fb4de519..e7605dfd 100644 --- a/tests/test_create_integration.rs +++ b/tests/test_create_integration.rs @@ -42,6 +42,19 @@ fn create_varied_test_file(path: &Path, size: usize) -> std::io::Result> Ok(data) } +/// Helper to create unique 4-byte blocks for tiny-block repair tests. +/// +/// Repair tests deliberately use tiny 4-byte blocks. A repeated-byte fixture lets +/// repair tools find duplicate "good" blocks in the damaged source file instead +/// of exercising recovery slices. +fn create_indexed_block_file(path: &Path, block_count: u32) -> std::io::Result<()> { + let mut data = Vec::with_capacity(block_count as usize * 4); + for block in 0..block_count { + data.extend_from_slice(&block.to_le_bytes()); + } + fs::write(path, data) +} + /// Helper to run par2cmdline-turbo verify command fn run_par2_verify(par2_file: &Path) -> std::io::Result { let output = Command::new("par2").arg("verify").arg(par2_file).output()?; @@ -284,8 +297,7 @@ fn test_create_then_corrupt_and_repair_with_par2cmdline() { let test_file = temp.path().join("test.dat"); let par2_file = temp.path().join("test.par2"); - // Create test file - create_test_file(&test_file, 4096, 0xDD).unwrap(); + create_indexed_block_file(&test_file, 1024).unwrap(); // Create PAR2 files using our implementation let reporter = Box::new(par2rs::create::ConsoleCreateReporter::new(true)); // quiet mode @@ -570,7 +582,7 @@ fn repair_using_only_volume_files_succeeds() { let test_file = temp.path().join("test.dat"); let par2_file = temp.path().join("test.par2"); - create_test_file(&test_file, 4096, 0xEF).unwrap(); + create_indexed_block_file(&test_file, 1024).unwrap(); let reporter = Box::new(par2rs::create::ConsoleCreateReporter::new(true)); let mut context = par2rs::create::CreateContextBuilder::new() diff --git a/tests/test_repair_context.rs b/tests/test_repair_context.rs index 282e3a99..bce17692 100644 --- a/tests/test_repair_context.rs +++ b/tests/test_repair_context.rs @@ -450,8 +450,10 @@ fn test_repair_context_purge_par_files_keeps_backups() { let par2_file = dir.path().join("test.par2"); let par2_vol = dir.path().join("test.vol0+1.par2"); + let foreign_par2 = dir.path().join("foreign.par2"); fs::write(&par2_file, b"dummy par2").unwrap(); fs::write(&par2_vol, b"dummy volume").unwrap(); + fs::write(&foreign_par2, b"foreign").unwrap(); let packets = vec![ Packet::Main(create_main_packet(vec![file_id])), @@ -466,6 +468,7 @@ fn test_repair_context_purge_par_files_keeps_backups() { assert!(backup_file.exists()); assert!(!par2_file.exists()); assert!(!par2_vol.exists()); + assert!(foreign_par2.exists()); } #[test] @@ -477,10 +480,12 @@ fn test_repair_context_purge_multiple_par2_files() { let par2_main = dir.path().join("test.par2"); let par2_vol1 = dir.path().join("test.vol01+02.par2"); let par2_vol2 = dir.path().join("test.vol03+04.par2"); + let foreign_par2 = dir.path().join("other.par2"); fs::write(&par2_main, b"main").unwrap(); fs::write(&par2_vol1, b"vol1").unwrap(); fs::write(&par2_vol2, b"vol2").unwrap(); + fs::write(&foreign_par2, b"foreign").unwrap(); let packets = vec![ Packet::Main(create_main_packet(vec![file_id])), @@ -492,10 +497,11 @@ fn test_repair_context_purge_multiple_par2_files() { let result = context.purge_files(par2_main.to_str().unwrap()); assert!(result.is_ok()); - // All PAR2 files should be deleted + // All PAR2 files from the same set should be deleted. assert!(!par2_main.exists()); assert!(!par2_vol1.exists()); assert!(!par2_vol2.exists()); + assert!(foreign_par2.exists()); } #[test] From 11fab30c06d11615b334fbdda4b251e4efacf8e9 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 22 Apr 2026 14:06:29 -0600 Subject: [PATCH 25/30] Fix verify repair rebase integration --- src/args.rs | 6 +- src/bin/par2.rs | 6 +- src/packets/mod.rs | 2 +- src/repair/mod.rs | 73 +++++++++++++++------ src/verify/global_engine.rs | 19 ++++++ tests/test_console_verification_reporter.rs | 8 +-- tests/test_silent_verification_reporter.rs | 14 ++-- tests/test_turbo_verify_repair_parity.rs | 6 +- tests/test_verify.rs | 14 ++-- 9 files changed, 99 insertions(+), 49 deletions(-) diff --git a/src/args.rs b/src/args.rs index 56378d4c..eb92832d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -93,8 +93,7 @@ pub fn parse_args() -> clap::ArgMatches { Arg::new("skip_leeway") .help("Skip leeway (distance +/- from expected block position)") .short('S') - .value_name("N") - .requires("data_skipping"), + .value_name("N"), ) .get_matches_from(args) } @@ -197,8 +196,7 @@ pub fn parse_repair_args() -> clap::ArgMatches { Arg::new("skip_leeway") .help("Skip leeway (distance +/- from expected block position)") .short('S') - .value_name("N") - .requires("data_skipping"), + .value_name("N"), ) .get_matches_from(args) } diff --git a/src/bin/par2.rs b/src/bin/par2.rs index 71768932..a2b1b5a5 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -253,8 +253,7 @@ fn main() -> Result<()> { Arg::new("skip_leeway") .short('S') .help("Skip leeway (distance +/- from expected block position)") - .value_name("N") - .requires("data_skipping"), + .value_name("N"), ), ) .subcommand( @@ -351,8 +350,7 @@ fn main() -> Result<()> { Arg::new("skip_leeway") .short('S') .help("Skip leeway (distance +/- from expected block position)") - .value_name("N") - .requires("data_skipping"), + .value_name("N"), ), ) .get_matches_from(args); diff --git a/src/packets/mod.rs b/src/packets/mod.rs index 9635abe4..e328f20c 100644 --- a/src/packets/mod.rs +++ b/src/packets/mod.rs @@ -1,5 +1,5 @@ use binrw::BinReaderExt; -use std::io::{Read, Seek, SeekFrom}; +use std::io::{Read, Seek}; pub mod creator_packet; pub mod error; diff --git a/src/repair/mod.rs b/src/repair/mod.rs index e6f39c12..45210c69 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -217,8 +217,7 @@ impl RepairContext { // Convert FileVerificationResult to ValidationCache let mut validation_cache = ValidationCache::new(); let mut file_status = HashMap::default(); - let mut block_sources_map: HashMap> = - HashMap::default(); + let mut block_sources_map: HashMap> = HashMap::default(); for file_result in &verification_results.files { // Build set of valid block indices @@ -255,10 +254,12 @@ impl RepairContext { }; block_sources_map.insert(file_result.file_id, block_sources); - // Convert verify::FileStatus to repair::FileStatus + // Convert verify::FileStatus to repair::FileStatus. A renamed file + // supplied as an extra scan target is already available for reads; + // repair should not recreate or consume that user-supplied file. let status = match file_result.status { crate::verify::FileStatus::Present => FileStatus::Present, - crate::verify::FileStatus::Renamed => FileStatus::Corrupted, // Treat renamed as corrupted for repair + crate::verify::FileStatus::Renamed => FileStatus::Present, crate::verify::FileStatus::Corrupted => FileStatus::Corrupted, crate::verify::FileStatus::Missing => FileStatus::Missing, }; @@ -287,8 +288,15 @@ impl RepairContext { self.recovery_set.recovery_slices_metadata.len() ); - // Check if repair is needed - if total_damaged_blocks == 0 { + let all_files_present = verification_results + .files + .iter() + .all(|file| matches!(file.status, crate::verify::FileStatus::Present)); + + // Check if repair is needed. A corrupted file can still have every + // protected slice available at shifted offsets, in which case repair + // rewrites it without consuming recovery slices. + if total_damaged_blocks == 0 && all_files_present { let verified_files: Vec = file_status.keys().cloned().collect(); let files_verified = verified_files.len(); return Ok(RepairResult::NoRepairNeeded { @@ -393,14 +401,17 @@ impl RepairContext { .collect(); if missing_slices.is_empty() { - // All slices validated, but file status says not Present - if *status == FileStatus::Corrupted { + if *status == FileStatus::Corrupted + && self.has_noncanonical_block_sources(file_info, block_sources_map) + { debug!( - "File {} has all valid slices but MD5 doesn't match", + "File {} has all valid slices but MD5 doesn't match; rewriting", file_info.file_name ); + files_to_repair.push((file_info, missing_slices)); + } else { + verified_files.push(file_info.file_name.clone()); } - verified_files.push(file_info.file_name.clone()); continue; } @@ -1009,6 +1020,27 @@ impl RepairContext { Ok(()) } + + fn has_noncanonical_block_sources( + &self, + file_info: &FileInfo, + block_sources_map: &HashMap>, + ) -> bool { + let Some(block_sources) = block_sources_map.get(&file_info.file_id) else { + return false; + }; + + let target_path = self.base_path.join(&file_info.file_name); + let slice_size = self.recovery_set.slice_size.as_usize(); + + (0..file_info.slice_count.as_usize()).any(|slice_index| { + let Some(source) = block_sources.get(&(slice_index as u32)) else { + return false; + }; + + source.file_path != target_path || source.offset != slice_index * slice_size + }) + } } /// High-level repair function - loads PAR2 files and performs repair @@ -1189,16 +1221,19 @@ pub fn repair_files_with_verification_reporter_and_loading_progress( ); verification_reporter.report_verification_results(&verification_results); - let renamed_files = repair_context.restore_renamed_files(&verification_results)?; + let renamed_files = if verify_config.rename_only || !extra_files.is_empty() { + repair_context.restore_renamed_files(&verification_results)? + } else { + Vec::new() + }; if !renamed_files.is_empty() { - verification_results = - run_repair_verification( - &par2_files, - &repair_verify_config, - &base_path, - extra_files, - verification_reporter, - ); + verification_results = run_repair_verification( + &par2_files, + &repair_verify_config, + &base_path, + extra_files, + verification_reporter, + ); if repair_verification_is_complete(&verification_results) { return Ok(( diff --git a/src/verify/global_engine.rs b/src/verify/global_engine.rs index 1ae25b8b..f634f3b6 100644 --- a/src/verify/global_engine.rs +++ b/src/verify/global_engine.rs @@ -1481,6 +1481,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let mut local_map = HashMap::default(); @@ -1561,6 +1562,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let mut local_map = HashMap::default(); @@ -1618,6 +1620,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let mut local_map = HashMap::default(); @@ -1682,6 +1685,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; // Test 1: Direct insertion @@ -2056,6 +2060,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; // Case 1: All blocks available @@ -2205,6 +2210,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; // Create a buffer with the matching block @@ -2271,6 +2277,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let block_size = BlockSize::new(1024); @@ -2333,6 +2340,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let block_size = BlockSize::new(1024); @@ -2385,6 +2393,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let block_size = BlockSize::new(1024); @@ -2439,6 +2448,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let block_size = BlockSize::new(1024); @@ -2494,6 +2504,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; // Create a buffer with 2MB worth of data @@ -2616,6 +2627,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let block_size = BlockSize::new(1024); @@ -2680,6 +2692,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let mut state = ScannerState::new(3072); @@ -2759,6 +2772,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let mut local_map = HashMap::default(); @@ -2829,6 +2843,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let block_size = BlockSize::new(1024); @@ -2879,6 +2894,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; // Simulate finding only 2 of 3 blocks @@ -2952,6 +2968,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let mut local_map = HashMap::default(); @@ -3131,6 +3148,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let mut state = ScannerState::new(64); @@ -3186,6 +3204,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; let mut state = ScannerState::new(64); diff --git a/tests/test_console_verification_reporter.rs b/tests/test_console_verification_reporter.rs index 8f48de34..72b42f5a 100644 --- a/tests/test_console_verification_reporter.rs +++ b/tests/test_console_verification_reporter.rs @@ -247,7 +247,7 @@ fn test_report_verification_results_mixed_files() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "missing.txt".to_string(), @@ -258,7 +258,7 @@ fn test_report_verification_results_mixed_files() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "corrupted.txt".to_string(), @@ -269,7 +269,7 @@ fn test_report_verification_results_mixed_files() { damaged_blocks: vec![2, 4, 6], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "renamed.txt".to_string(), @@ -280,7 +280,7 @@ fn test_report_verification_results_mixed_files() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, ]; let results = create_test_results(mixed_files, 1, 1, 1, 1); diff --git a/tests/test_silent_verification_reporter.rs b/tests/test_silent_verification_reporter.rs index e5e79671..3d7a43ff 100644 --- a/tests/test_silent_verification_reporter.rs +++ b/tests/test_silent_verification_reporter.rs @@ -220,7 +220,7 @@ fn test_report_verification_results_mixed_files_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "missing.txt".to_string(), @@ -231,7 +231,7 @@ fn test_report_verification_results_mixed_files_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "corrupted.txt".to_string(), @@ -242,7 +242,7 @@ fn test_report_verification_results_mixed_files_silent() { damaged_blocks: vec![2, 4, 6], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "renamed.txt".to_string(), @@ -253,7 +253,7 @@ fn test_report_verification_results_mixed_files_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, ]; let results = create_test_results(mixed_files, 1, 1, 1, 1); @@ -293,7 +293,7 @@ fn test_comprehensive_verification_workflow_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "file2.txt".to_string(), @@ -304,7 +304,7 @@ fn test_comprehensive_verification_workflow_silent() { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "file3.txt".to_string(), @@ -315,7 +315,7 @@ fn test_comprehensive_verification_workflow_silent() { damaged_blocks: vec![1, 5, 9], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, ]; let final_results = create_test_results(final_files, 1, 0, 1, 1); diff --git a/tests/test_turbo_verify_repair_parity.rs b/tests/test_turbo_verify_repair_parity.rs index c99a890a..649e784c 100644 --- a/tests/test_turbo_verify_repair_parity.rs +++ b/tests/test_turbo_verify_repair_parity.rs @@ -58,7 +58,7 @@ fn verify_marks_complete_extra_file_as_renamed() { } #[test] -fn repair_restores_complete_extra_file_without_recovery_blocks() { +fn repair_accepts_complete_extra_file_without_consuming_it() { let temp_dir = TempDir::new().unwrap(); let par2_file = create_small_recovery_set(&temp_dir, "data.bin", b"abcdefghijkl"); @@ -75,8 +75,8 @@ fn repair_restores_complete_extra_file_without_recovery_blocks() { .unwrap(); assert!(result.is_success(), "{result:?}"); - assert_eq!(fs::read(target).unwrap(), b"abcdefghijkl"); - assert!(!misplaced.exists()); + assert!(!target.exists()); + assert_eq!(fs::read(misplaced).unwrap(), b"abcdefghijkl"); } #[test] diff --git a/tests/test_verify.rs b/tests/test_verify.rs index 9aeb7db8..a77ca1b2 100644 --- a/tests/test_verify.rs +++ b/tests/test_verify.rs @@ -644,7 +644,7 @@ mod file_status_tests { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }; let cloned = result.clone(); @@ -770,7 +770,7 @@ mod print_verification_results_tests { damaged_blocks, block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }); let results = VerificationResults { @@ -840,7 +840,7 @@ mod verification_result_calculations { damaged_blocks: if i > 0 { vec![0u32, 1u32] } else { vec![] }, block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }); } @@ -901,7 +901,7 @@ mod verification_result_calculations { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "damaged.txt".to_string(), @@ -912,7 +912,7 @@ mod verification_result_calculations { damaged_blocks: vec![5, 6, 7, 8, 9], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, FileVerificationResult { file_name: "missing.txt".to_string(), @@ -923,7 +923,7 @@ mod verification_result_calculations { damaged_blocks: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }, ]; @@ -1097,7 +1097,7 @@ mod file_name_handling { damaged_blocks: vec![], block_positions: Default::default(), matched_path: None, - block_sources: Default::default(), + block_sources: Default::default(), }; assert_eq!(result.file_name, "файл.txt"); From 36b65e9ba22d9663e7e3cfc67f6e8f6ace11d363 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Fri, 24 Apr 2026 11:06:09 -0600 Subject: [PATCH 26/30] Address PR review comments --- src/repair/mod.rs | 3 +++ src/repair/types.rs | 8 ++------ src/verify/global_engine.rs | 17 +++++++++++------ tests/test_repair_coverage.rs | 1 + tests/test_repair_types.rs | 3 +++ 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 45210c69..782a95c4 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -317,6 +317,7 @@ impl RepairContext { files_failed: file_status.keys().cloned().collect(), files_verified: 0, verified_files: Vec::new(), + exit_code: 2, message: format!( "Insufficient recovery data: need {} blocks but only have {}", total_damaged_blocks, @@ -503,6 +504,7 @@ impl RepairContext { files_failed, files_verified: files_verified_count, verified_files, + exit_code: 1, message, }); } @@ -1347,6 +1349,7 @@ fn rename_only_repair_result( .collect(), files_verified: verified_files.len(), verified_files, + exit_code: 1, message: "Rename-only repair could not restore all files.".to_string(), } } diff --git a/src/repair/types.rs b/src/repair/types.rs index 3a7f650f..d3a9c24a 100644 --- a/src/repair/types.rs +++ b/src/repair/types.rs @@ -128,6 +128,7 @@ pub enum RepairResult { files_failed: Vec, files_verified: usize, verified_files: Vec, + exit_code: i32, message: String, }, } @@ -145,12 +146,7 @@ impl RepairResult { pub fn exit_code(&self) -> i32 { match self { RepairResult::Success { .. } | RepairResult::NoRepairNeeded { .. } => 0, - RepairResult::Failed { message, .. } - if message.starts_with("Insufficient recovery data") => - { - 2 - } - RepairResult::Failed { .. } => 1, + RepairResult::Failed { exit_code, .. } => *exit_code, } } diff --git a/src/verify/global_engine.rs b/src/verify/global_engine.rs index f634f3b6..b1baef13 100644 --- a/src/verify/global_engine.rs +++ b/src/verify/global_engine.rs @@ -199,7 +199,7 @@ impl GlobalVerificationEngine { reporter: &R, parallel: bool, ) -> VerificationResults { - self.verify_recovery_set_with_extra_files(reporter, parallel, &self.extra_files) + self.verify_recovery_set_with_extra_files(reporter, parallel, &[]) } /// Verify the recovery set while also scanning user-supplied extra files. @@ -214,7 +214,7 @@ impl GlobalVerificationEngine { extra_files: &[PathBuf], ) -> VerificationResults { // Note: report_verification_start and report_files_found should be called by the caller - let combined_extra_files; + let mut combined_extra_files; let scan_extra_files = if self.extra_files.is_empty() { extra_files } else if extra_files.is_empty() { @@ -226,6 +226,7 @@ impl GlobalVerificationEngine { .chain(extra_files.iter()) .cloned() .collect::>(); + combined_extra_files = Self::dedupe_extra_files(&combined_extra_files); &combined_extra_files }; @@ -1316,11 +1317,15 @@ impl GlobalVerificationEngine { }) .unwrap_or_default(); let mut block_sources = HashMap::default(); - for block_num in 0..total_blocks.as_usize() { - let block_number = block_num as u32; - if let Some(source) = block_locations.get(&(file_description.file_id, block_number)) + if let Some(metadata) = scan_metadatas.get(&file_description.file_id) { + for (_, fid, block_number) in metadata + .found_blocks + .iter() + .filter(|(_, fid, _)| *fid == file_description.file_id) { - block_sources.insert(block_number, source.clone()); + if let Some(source) = block_locations.get(&(*fid, *block_number)) { + block_sources.insert(*block_number, source.clone()); + } } } diff --git a/tests/test_repair_coverage.rs b/tests/test_repair_coverage.rs index 55ce3ca2..f5ec305a 100644 --- a/tests/test_repair_coverage.rs +++ b/tests/test_repair_coverage.rs @@ -187,6 +187,7 @@ fn test_repair_result_methods() { files_failed: vec!["bad_file.txt".to_string()], files_verified: 1, verified_files: vec!["good_file.txt".to_string()], + exit_code: 1, message: "Something went wrong".to_string(), }; result.print_result(); diff --git a/tests/test_repair_types.rs b/tests/test_repair_types.rs index 8e08f3e4..361979fd 100644 --- a/tests/test_repair_types.rs +++ b/tests/test_repair_types.rs @@ -216,10 +216,12 @@ fn test_repair_result_failed() { files_failed: vec!["file1.dat".to_string(), "file2.dat".to_string()], files_verified: 1, verified_files: vec!["file3.dat".to_string()], + exit_code: 2, message: "Insufficient recovery blocks".to_string(), }; assert!(!result.is_success()); + assert_eq!(result.exit_code(), 2); assert_eq!(result.repaired_files().len(), 0); assert_eq!(result.failed_files().len(), 2); assert_eq!(result.failed_files()[0], "file1.dat"); @@ -248,6 +250,7 @@ fn test_repair_result_print() { files_failed: vec!["file1.dat".to_string()], files_verified: 0, verified_files: vec![], + exit_code: 1, message: "Not enough blocks".to_string(), }; failed.print_result(); From 4e68cb3c6b6cba61d98ef3b53814019b8e44eeff Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Fri, 24 Apr 2026 11:38:23 -0600 Subject: [PATCH 27/30] Address verify and repair review comments --- src/bin/par2.rs | 6 +- src/bin/par2verify.rs | 5 +- src/repair/mod.rs | 12 ++- tests/test_binaries.rs | 106 ++++++++++++++++++++--- tests/test_turbo_verify_repair_parity.rs | 2 +- 5 files changed, 107 insertions(+), 24 deletions(-) diff --git a/src/bin/par2.rs b/src/bin/par2.rs index a2b1b5a5..0bafdc7a 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -757,7 +757,11 @@ fn handle_verify(matches: &clap::ArgMatches) -> Result<()> { ); } - if results.missing_block_count == 0 { + let repair_required = results.renamed_file_count > 0 + || results.corrupted_file_count > 0 + || results.missing_block_count > 0; + + if !repair_required { if purge { par2rs::repair::RepairContext::purge_par_files_for(&file_name.to_string_lossy())?; } diff --git a/src/bin/par2verify.rs b/src/bin/par2verify.rs index 842a5210..e6e4a3f2 100644 --- a/src/bin/par2verify.rs +++ b/src/bin/par2verify.rs @@ -148,8 +148,9 @@ fn main() -> Result<()> { reporter.report_verification_results(&verification_results); } - let repair_required = - verification_results.renamed_file_count > 0 || verification_results.missing_block_count > 0; + let repair_required = verification_results.renamed_file_count > 0 + || verification_results.corrupted_file_count > 0 + || verification_results.missing_block_count > 0; if !repair_required { if purge { par2rs::repair::RepairContext::purge_par_files_for(&file_name.to_string_lossy())?; diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 782a95c4..20e0d60a 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -254,12 +254,10 @@ impl RepairContext { }; block_sources_map.insert(file_result.file_id, block_sources); - // Convert verify::FileStatus to repair::FileStatus. A renamed file - // supplied as an extra scan target is already available for reads; - // repair should not recreate or consume that user-supplied file. let status = match file_result.status { crate::verify::FileStatus::Present => FileStatus::Present, - crate::verify::FileStatus::Renamed => FileStatus::Present, + crate::verify::FileStatus::Renamed if target_path.exists() => FileStatus::Corrupted, + crate::verify::FileStatus::Renamed => FileStatus::Missing, crate::verify::FileStatus::Corrupted => FileStatus::Corrupted, crate::verify::FileStatus::Missing => FileStatus::Missing, }; @@ -402,11 +400,11 @@ impl RepairContext { .collect(); if missing_slices.is_empty() { - if *status == FileStatus::Corrupted + if *status != FileStatus::Present && self.has_noncanonical_block_sources(file_info, block_sources_map) { debug!( - "File {} has all valid slices but MD5 doesn't match; rewriting", + "File {} has all valid slices from noncanonical sources; rewriting", file_info.file_name ); files_to_repair.push((file_info, missing_slices)); @@ -1223,7 +1221,7 @@ pub fn repair_files_with_verification_reporter_and_loading_progress( ); verification_reporter.report_verification_results(&verification_results); - let renamed_files = if verify_config.rename_only || !extra_files.is_empty() { + let renamed_files = if verify_config.rename_only { repair_context.restore_renamed_files(&verification_results)? } else { Vec::new() diff --git a/tests/test_binaries.rs b/tests/test_binaries.rs index b5a36771..79ed188d 100644 --- a/tests/test_binaries.rs +++ b/tests/test_binaries.rs @@ -121,6 +121,31 @@ fn create_renamed_file_test_set(temp_dir: &TempDir) -> (PathBuf, PathBuf, PathBu (par2_file, source, renamed) } +fn create_misaligned_corrupted_test_set(temp_dir: &TempDir) -> (PathBuf, PathBuf) { + let source = temp_dir.path().join("sample.dat"); + create_test_file(&source, b"abcdefghijkl").expect("Failed to create source file"); + + let par2_file = temp_dir.path().join("archive.par2"); + let output = Command::new(get_binary_path("par2create")) + .arg("-q") + .arg("-s4") + .arg("-c1") + .arg(&par2_file) + .arg(&source) + .output() + .expect("Failed to execute par2create"); + + assert!( + output.status.success(), + "par2create failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + fs::write(&source, b"Xabcdefghijkl").expect("Failed to corrupt source file"); + + (par2_file, source) +} + fn create_par1_verify_test_set(temp_dir: &TempDir) -> PathBuf { const PAR1_HEADER_SIZE: usize = 96; const PAR1_ENTRY_FIXED_SIZE: usize = 56; @@ -713,6 +738,30 @@ fn test_par2_verify_rename_only_accepts_renamed_extra() { assert!(renamed.exists(), "verify -O must remain non-mutating"); } +#[test] +fn test_par2_verify_reports_repair_required_for_corrupted_file_without_missing_blocks() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (par2_file, source) = create_misaligned_corrupted_test_set(&temp_dir); + + let output = Command::new(get_binary_path("par2")) + .arg("verify") + .arg("-q") + .arg("-p") + .arg(&par2_file) + .output() + .expect("Failed to execute par2 verify"); + + assert!( + !output.status.success(), + "verify should report repair required for corrupted files even when blocks are available" + ); + assert!(source.exists(), "verify must remain non-mutating"); + assert!( + par2_file.exists(), + "verify -p must not purge PAR2 files when corruption requires repair" + ); +} + #[test] fn test_par2_repair_with_test_fixtures() { let par2_file = Path::new("tests/fixtures/repair_scenarios/testfile.par2"); @@ -930,13 +979,14 @@ fn test_par2_repair_scans_extra_file_arguments() { ); assert!( source.exists(), - "repair should restore the protected filename from the renamed extra" + "repair should recreate the protected filename from the extra scan source" ); assert!( - !renamed.exists(), - "repair should consume the renamed extra by moving it into place" + renamed.exists(), + "normal repair should not consume the user-supplied extra file" ); assert_eq!(fs::read(&source).unwrap(), b"renamed-file-scan-data"); + assert_eq!(fs::read(&renamed).unwrap(), b"renamed-file-scan-data"); } #[test] @@ -1027,15 +1077,16 @@ fn test_par2_repair_renamed_extra_backs_up_corrupted_target() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(fs::read(&source).unwrap(), b"renamed-file-scan-data"); - assert!(!renamed.exists()); + assert_eq!(fs::read(&renamed).unwrap(), b"renamed-file-scan-data"); assert_eq!( - fs::read(temp_dir.path().join("sample.dat.1")).unwrap(), - b"corrupted target" + fs::read(temp_dir.path().join("sample.dat.1")).ok(), + None, + "normal repair should not create rename backups for scan-only extras" ); } #[test] -fn test_par2_repair_renamed_extra_uses_first_free_backup_suffix() { +fn test_par2_repair_scan_extra_leaves_existing_backup_suffixes_untouched() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let source = temp_dir.path().join("sample.dat"); create_test_file(&source, b"renamed-file-scan-data").expect("Failed to create source file"); @@ -1066,13 +1117,15 @@ fn test_par2_repair_renamed_extra_uses_first_free_backup_suffix() { assert!(output.status.success()); assert_eq!(fs::read(&source).unwrap(), b"renamed-file-scan-data"); + assert_eq!(fs::read(&renamed).unwrap(), b"renamed-file-scan-data"); assert_eq!( fs::read(temp_dir.path().join("sample.dat.1")).unwrap(), b"existing backup" ); assert_eq!( - fs::read(temp_dir.path().join("sample.dat.2")).unwrap(), - b"corrupted target" + fs::read(temp_dir.path().join("sample.dat.2")).ok(), + None, + "normal repair should not allocate a rename backup suffix for scan-only extras" ); } @@ -1119,7 +1172,10 @@ fn test_par2_repair_purge_after_rename_removes_recovery_files() { String::from_utf8_lossy(&output.stderr) ); assert!(source.exists(), "protected data should remain after purge"); - assert!(!renamed.exists()); + assert!( + renamed.exists(), + "normal repair -p should not consume the user-supplied extra file" + ); assert_no_par2_files(temp_dir.path()); } @@ -1570,6 +1626,29 @@ fn test_par2verify_scans_extra_file_arguments() { assert!(renamed.exists(), "par2verify must remain non-mutating"); } +#[test] +fn test_par2verify_reports_repair_required_for_corrupted_file_without_missing_blocks() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (par2_file, source) = create_misaligned_corrupted_test_set(&temp_dir); + + let output = Command::new(get_binary_path("par2verify")) + .arg("-q") + .arg("-p") + .arg(&par2_file) + .output() + .expect("Failed to execute par2verify"); + + assert!( + !output.status.success(), + "par2verify should report repair required for corrupted files even when blocks are available" + ); + assert!(source.exists(), "par2verify must remain non-mutating"); + assert!( + par2_file.exists(), + "par2verify -p must not purge PAR2 files when corruption requires repair" + ); +} + #[test] fn test_par2verify_ignores_foreign_extra_par2_set() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); @@ -2028,13 +2107,14 @@ fn test_par2repair_scans_extra_file_arguments() { ); assert!( source.exists(), - "repair should restore the protected filename from the renamed extra" + "repair should recreate the protected filename from the extra scan source" ); assert!( - !renamed.exists(), - "repair should consume the renamed extra by moving it into place" + renamed.exists(), + "normal repair should not consume the user-supplied extra file" ); assert_eq!(fs::read(&source).unwrap(), b"renamed-file-scan-data"); + assert_eq!(fs::read(&renamed).unwrap(), b"renamed-file-scan-data"); } #[test] diff --git a/tests/test_turbo_verify_repair_parity.rs b/tests/test_turbo_verify_repair_parity.rs index 649e784c..6040566b 100644 --- a/tests/test_turbo_verify_repair_parity.rs +++ b/tests/test_turbo_verify_repair_parity.rs @@ -75,7 +75,7 @@ fn repair_accepts_complete_extra_file_without_consuming_it() { .unwrap(); assert!(result.is_success(), "{result:?}"); - assert!(!target.exists()); + assert_eq!(fs::read(target).unwrap(), b"abcdefghijkl"); assert_eq!(fs::read(misplaced).unwrap(), b"abcdefghijkl"); } From e6b5b445699058317213b044a457ebf42aded323 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Fri, 24 Apr 2026 12:03:28 -0600 Subject: [PATCH 28/30] Handle complete corrupted files during repair --- src/repair/mod.rs | 27 ++++++++++++++++++++---- tests/test_turbo_verify_repair_parity.rs | 19 +++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 20e0d60a..1c4b9b9f 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -401,11 +401,11 @@ impl RepairContext { if missing_slices.is_empty() { if *status != FileStatus::Present - && self.has_noncanonical_block_sources(file_info, block_sources_map) + && self.file_needs_rewrite_without_missing_slices(file_info, block_sources_map) { debug!( - "File {} has all valid slices from noncanonical sources; rewriting", - file_info.file_name + "File {} has all valid slices but status is {:?}; rewriting", + file_info.file_name, status ); files_to_repair.push((file_info, missing_slices)); } else { @@ -1021,6 +1021,15 @@ impl RepairContext { Ok(()) } + fn file_needs_rewrite_without_missing_slices( + &self, + file_info: &FileInfo, + block_sources_map: &HashMap>, + ) -> bool { + self.has_noncanonical_block_sources(file_info, block_sources_map) + || self.target_file_size_differs(file_info) + } + fn has_noncanonical_block_sources( &self, file_info: &FileInfo, @@ -1034,13 +1043,23 @@ impl RepairContext { let slice_size = self.recovery_set.slice_size.as_usize(); (0..file_info.slice_count.as_usize()).any(|slice_index| { - let Some(source) = block_sources.get(&(slice_index as u32)) else { + let Ok(block_number) = u32::try_from(slice_index) else { + return true; + }; + let Some(source) = block_sources.get(&block_number) else { return false; }; source.file_path != target_path || source.offset != slice_index * slice_size }) } + + fn target_file_size_differs(&self, file_info: &FileInfo) -> bool { + let target_path = self.base_path.join(&file_info.file_name); + fs::metadata(target_path) + .map(|metadata| metadata.len() != file_info.file_length.as_u64()) + .unwrap_or(true) + } } /// High-level repair function - loads PAR2 files and performs repair diff --git a/tests/test_turbo_verify_repair_parity.rs b/tests/test_turbo_verify_repair_parity.rs index 6040566b..d79ba3d7 100644 --- a/tests/test_turbo_verify_repair_parity.rs +++ b/tests/test_turbo_verify_repair_parity.rs @@ -98,6 +98,25 @@ fn repair_rewrites_misaligned_file_when_all_blocks_are_available() { assert_eq!(fs::read(target).unwrap(), b"abcdefghijkl"); } +#[test] +fn repair_rewrites_canonical_corruption_when_all_blocks_are_available() { + let temp_dir = TempDir::new().unwrap(); + let par2_file = create_small_recovery_set(&temp_dir, "data.bin", b"abcdefghijkl"); + + let target = temp_dir.path().join("data.bin"); + fs::write(&target, b"abcdefghijklX").unwrap(); + + let (_, result) = repair_files( + par2_file.to_str().unwrap(), + Box::new(SilentReporter), + &VerificationConfig::default(), + ) + .unwrap(); + + assert!(result.is_success(), "{result:?}"); + assert_eq!(fs::read(target).unwrap(), b"abcdefghijkl"); +} + #[test] fn repair_uses_partial_extra_file_blocks_as_repair_sources() { let temp_dir = TempDir::new().unwrap(); From 42142b3392a71672c6d99833c271e48ac4a59271 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Fri, 24 Apr 2026 12:08:51 -0600 Subject: [PATCH 29/30] Fix concise verify repair summary --- src/reporters/console.rs | 85 +++++++++++++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/src/reporters/console.rs b/src/reporters/console.rs index 56201032..90a7cc50 100644 --- a/src/reporters/console.rs +++ b/src/reporters/console.rs @@ -37,6 +37,16 @@ impl ConciseVerificationReporter { reported_files: Mutex::new(HashSet::new()), } } + + fn repair_required(results: &VerificationResults) -> bool { + results.missing_block_count > 0 + || results.files.iter().any(|file| { + matches!( + file.status, + FileStatus::Missing | FileStatus::Corrupted | FileStatus::Renamed + ) + }) + } } impl Default for ConsoleVerificationReporter { @@ -256,20 +266,20 @@ impl VerificationReporter for ConciseVerificationReporter { println!(); - match (results.missing_block_count, results.repair_possible) { - (0, _) => println!("All files are correct, repair is not required."), - (_, true) => { - println!("Repair is required."); - println!("Repair is possible."); - } - (missing, false) => { - println!("Repair is required."); - println!("Repair is not possible."); - println!( - "You need {} more recovery blocks to be able to repair.", - missing - results.recovery_blocks_available - ); - } + if !Self::repair_required(results) { + println!("All files are correct, repair is not required."); + } else if results.repair_possible { + println!("Repair is required."); + println!("Repair is possible."); + } else { + println!("Repair is required."); + println!("Repair is not possible."); + println!( + "You need {} more recovery blocks to be able to repair.", + results + .missing_block_count + .saturating_sub(results.recovery_blocks_available) + ); } } @@ -335,9 +345,56 @@ impl RepairReporter for ConsoleRepairReporter { #[cfg(test)] mod tests { use super::*; + use crate::domain::FileId; + use crate::verify::FileVerificationResult; use std::sync::Arc; use std::thread; + fn verification_results(status: FileStatus, missing_block_count: usize) -> VerificationResults { + let file = FileVerificationResult { + file_name: "data.bin".to_string(), + file_id: FileId::new([1; 16]), + status, + blocks_available: 1, + total_blocks: 1, + damaged_blocks: Vec::new(), + block_positions: Default::default(), + matched_path: None, + block_sources: Default::default(), + }; + + VerificationResults { + files: vec![file], + blocks: Vec::new(), + present_file_count: usize::from(status == FileStatus::Present), + renamed_file_count: usize::from(status == FileStatus::Renamed), + corrupted_file_count: usize::from(status == FileStatus::Corrupted), + missing_file_count: usize::from(status == FileStatus::Missing), + available_block_count: 1, + missing_block_count, + total_block_count: 1, + recovery_blocks_available: 1, + repair_possible: true, + blocks_needed_for_repair: missing_block_count, + } + } + + #[test] + fn concise_summary_requires_repair_for_non_present_file_with_no_missing_blocks() { + let corrupted = verification_results(FileStatus::Corrupted, 0); + let renamed = verification_results(FileStatus::Renamed, 0); + + assert!(ConciseVerificationReporter::repair_required(&corrupted)); + assert!(ConciseVerificationReporter::repair_required(&renamed)); + } + + #[test] + fn concise_summary_does_not_require_repair_for_present_file_with_no_missing_blocks() { + let present = verification_results(FileStatus::Present, 0); + + assert!(!ConciseVerificationReporter::repair_required(&present)); + } + #[test] fn test_console_reporter_thread_safe() { // Test that multiple threads can safely use the reporter From 625dd608050258affb01297f5d0e76c4f64ab2dc Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Fri, 24 Apr 2026 13:02:54 -0600 Subject: [PATCH 30/30] Fix chunked create file hashes --- src/create/backend.rs | 34 ++++++++++++++++++++++++++++++++++ src/create/context.rs | 21 ++++++--------------- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/create/backend.rs b/src/create/backend.rs index 7a266f9b..d1e89639 100644 --- a/src/create/backend.rs +++ b/src/create/backend.rs @@ -374,6 +374,40 @@ mod tests { }); } + #[test] + fn backend_output_matches_encoder_across_multiple_chunks() { + let block_size = 64; + let chunk_size = 16; + let source_count = 4; + let encoder = RecoveryBlockEncoder::new(block_size, source_count); + let inputs = (0..source_count) + .map(|src| { + (0..block_size) + .map(|byte| (src * 17 + byte * 3) as u8) + .collect::>() + }) + .collect::>(); + + let mut backend = CreateRecoveryBackend::new(encoder.base_values(), 0, 2, chunk_size); + let mut recovery_blocks = backend.recovery_blocks(block_size); + + for offset in (0..block_size).step_by(chunk_size) { + backend.begin_chunk(chunk_size); + inputs.iter().enumerate().for_each(|(idx, input)| { + backend.add_input(idx, &input[offset..offset + chunk_size]); + }); + backend.finish_chunk(&mut recovery_blocks, block_size); + } + + recovery_blocks + .iter() + .for_each(|(exponent, recovery_data)| { + let refs = inputs.iter().map(Vec::as_slice).collect::>(); + let expected = encoder.encode_recovery_block(*exponent, &refs).unwrap(); + assert_eq!(recovery_data, &expected); + }); + } + #[test] fn backend_reuses_fixed_transfer_buffers() { let encoder = RecoveryBlockEncoder::new(64, 2); diff --git a/src/create/context.rs b/src/create/context.rs index fb8550cd..ca382a6a 100644 --- a/src/create/context.rs +++ b/src/create/context.rs @@ -125,7 +125,6 @@ fn encode_and_hash_files( .map_err(|err| CreateError::Other(format!("failed to create thread pool: {err}")))?; let mut file_handles: Vec = Vec::with_capacity(source_files.len()); - let mut file_md5_states: Vec = Vec::with_capacity(source_files.len()); let mut file_16k_buffers: Vec> = Vec::with_capacity(source_files.len()); let mut block_md5_states: Vec = Vec::with_capacity(source_block_count as usize); @@ -137,7 +136,6 @@ fn encode_and_hash_files( for file in source_files { file_handles.push(open_for_reading(&file.path)?); - file_md5_states.push(Md5::new()); file_16k_buffers.push(vec![0u8; (file.size as usize).min(16 * 1024)]); let block_count = file.calculate_block_count(block_size); @@ -204,7 +202,6 @@ fn encode_and_hash_files( file_16k_buffers[file_idx][capture_start..capture_end] .copy_from_slice(&chunk[..capture_len]); } - file_md5_states[file_idx].update(&chunk[..bytes_to_read]); } block_md5_states[file_block_idx].update(&chunk[..chunk_len]); block_crc32_states[file_block_idx].update(&chunk[..chunk_len]); @@ -228,17 +225,6 @@ fn encode_and_hash_files( Ok::<_, CreateError>(recovery_blocks) })?; - // Finalize file MD5s and block checksums - let finalized_file_md5s: Vec<[u8; 16]> = file_md5_states - .into_iter() - .map(|s| { - let h = s.finalize(); - let mut b = [0u8; 16]; - b.copy_from_slice(&h); - b - }) - .collect(); - let file_16k_hashes = file_16k_buffers .iter() .map(|bytes| crate::domain::Md5Hash::new(Md5::digest(bytes).into())) @@ -249,7 +235,12 @@ fn encode_and_hash_files( for (file_idx, file) in source_files.iter().enumerate() { let (block_count, g_offset) = file_block_meta[file_idx]; - let full_md5 = crate::domain::Md5Hash::new(finalized_file_md5s[file_idx]); + let full_md5 = crate::checksum::calculate_file_md5(&file.path).map_err(|e| { + CreateError::FileReadError { + file: file.path.to_string_lossy().to_string(), + source: e, + } + })?; let hash_16k = file_16k_hashes[file_idx]; let filename = file.packet_name().as_bytes(); let file_id = compute_file_id(&hash_16k, file.size, filename);