diff --git a/src/bin/par2.rs b/src/bin/par2.rs index 9fef314d..0bafdc7a 100644 --- a/src/bin/par2.rs +++ b/src/bin/par2.rs @@ -691,6 +691,19 @@ 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(), + ); + 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) @@ -744,16 +757,13 @@ 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 { - 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 { @@ -819,6 +829,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))?; @@ -838,13 +850,22 @@ 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() { + 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 059838ff..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))?; @@ -81,15 +83,25 @@ 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 - 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/par2verify.rs b/src/bin/par2verify.rs index 6ee3aab8..e6e4a3f2 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 { @@ -134,27 +148,23 @@ 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 { - 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())?; + 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())?; + } + } 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/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); 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..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; @@ -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/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/par2_files.rs b/src/par2_files.rs index b0950eab..261dc7fd 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,12 @@ pub fn load_par2_packets( include_recovery_slices: bool, show_progress: bool, ) -> PacketSet { - use std::sync::atomic::{AtomicUsize, Ordering}; + 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 total_recovery_blocks = AtomicUsize::new(0); let output_lock = Mutex::new(()); let all_packets: Vec> = par2_files @@ -460,11 +477,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 +491,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 +511,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 +521,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 +544,150 @@ 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 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 loading_progress_order(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 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, + 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/reed_solomon/codec.rs b/src/reed_solomon/codec.rs index 4b35bbbb..3b6e4aa9 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,24 +1461,26 @@ 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!("\rSolving: 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; 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 } 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); + print!("\rSolving: {:.1}%", percentage); std::io::Write::flush(&mut std::io::stdout()).ok(); } @@ -1544,9 +1557,9 @@ 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); + print!("\rSolving: {:.1}%", percentage); std::io::Write::flush(&mut std::io::stdout()).ok(); } @@ -1640,10 +1653,11 @@ 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!("\rSolving: done."); + println!(); + std::io::Write::flush(&mut std::io::stdout()).ok(); + } debug!("Chunked reconstruction completed successfully"); 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/src/repair/mod.rs b/src/repair/mod.rs index 31a84dac..1c4b9b9f 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,7 @@ 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,13 +234,30 @@ 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 { crate::verify::FileStatus::Present => FileStatus::Present, - crate::verify::FileStatus::Renamed => FileStatus::Corrupted, // Treat renamed as corrupted for repair + 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, }; @@ -268,8 +286,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 { @@ -290,6 +315,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, @@ -299,7 +325,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 +351,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", @@ -374,14 +400,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::Present + && self.file_needs_rewrite_without_missing_slices(file_info, block_sources_map) + { debug!( - "File {} has all valid slices but MD5 doesn't match", - 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 { + verified_files.push(file_info.file_name.clone()); } - verified_files.push(file_info.file_name.clone()); continue; } @@ -405,7 +434,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 +457,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 +468,7 @@ impl RepairContext { file_info, valid_slice_indices, &file_reconstructed, - &block_positions, + &block_sources, ) { Ok(()) => { self.reporter() @@ -474,6 +502,7 @@ impl RepairContext { files_failed, files_verified: files_verified_count, verified_files, + exit_code: 1, message, }); } @@ -503,6 +532,15 @@ 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); } @@ -539,7 +577,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 +631,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 +643,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 +676,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 +698,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( @@ -707,6 +744,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(); @@ -812,7 +850,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 +877,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 +888,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 +917,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,22 +968,26 @@ 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)); } } 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 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 { @@ -972,9 +1016,50 @@ impl RepairContext { "✓ Wrote {} bytes to {:?}, MD5 verified: {:02x?}", bytes_written, file_path, computed_md5 ); + self.reporter().report_bytes_written(bytes_written); 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, + 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 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 @@ -1006,12 +1091,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, ) } @@ -1022,6 +1109,48 @@ 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)> { + 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); @@ -1030,8 +1159,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); @@ -1039,16 +1177,35 @@ pub fn repair_files_with_base_path_and_extra_files( // 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); } - // 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()); + // Create repair context before verification so normal repair output prints + // the set summary once before source verification. + let mut repair_builder = RepairContextBuilder::new() + .packets(context_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. @@ -1065,6 +1222,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; @@ -1072,33 +1231,28 @@ 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); - - // 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 mut verification_results = run_repair_verification( + &par2_files, + &repair_verify_config, + &base_path, + extra_files, + verification_reporter, + ); + 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 { + 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_results = run_repair_verification( + &par2_files, + &repair_verify_config, + &base_path, + extra_files, + verification_reporter, + ); if repair_verification_is_complete(&verification_results) { return Ok(( @@ -1138,22 +1292,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, ) @@ -1166,6 +1320,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, @@ -1206,6 +1366,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/progress.rs b/src/repair/progress.rs index bbe7094a..bacbce91 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); @@ -79,12 +85,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,13 +112,7 @@ 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) { @@ -135,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(()); } @@ -158,7 +165,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; } @@ -199,8 +206,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."); } } @@ -224,16 +231,20 @@ impl ProgressReporter for ConsoleReporter { if self.quiet { return; } + print!("Constructing: 0.0%\r"); println!("Constructing: done."); } 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!(); @@ -257,21 +268,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."); } } @@ -368,6 +390,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/src/repair/types.rs b/src/repair/types.rs index 1d093e41..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, }, } @@ -141,6 +142,14 @@ 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 { exit_code, .. } => *exit_code, + } + } + /// Get the files that were successfully repaired pub fn repaired_files(&self) -> &[String] { match self { diff --git a/src/reporters/console.rs b/src/reporters/console.rs index 54b8f1ba..90a7cc50 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 @@ -13,6 +14,39 @@ 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 +/// par2cmdline-turbo's quiet-but-not-silent mode. +pub struct ConciseVerificationReporter { + output_lock: Mutex<()>, + reported_files: Mutex>, +} + +impl Default for ConciseVerificationReporter { + fn default() -> Self { + Self::new() + } +} + +impl ConciseVerificationReporter { + pub fn new() -> Self { + Self { + output_lock: Mutex::new(()), + 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 { @@ -25,6 +59,7 @@ impl ConsoleVerificationReporter { pub fn new() -> Self { Self { output_lock: Mutex::new(()), + reported_files: Mutex::new(HashSet::new()), } } } @@ -76,12 +111,17 @@ 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) { 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), @@ -102,6 +142,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 @@ -111,6 +155,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); @@ -127,6 +189,103 @@ 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(); + 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), + 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() { + self.reported_files + .lock() + .unwrap() + .insert(file_name.to_string()); + 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(); + 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!(); + + 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) + ); + } + } + + fn report_scanning_progress(&self, _fraction: f64) {} +} + // Base Reporter implementation for ConsoleRepairReporter impl Reporter for ConsoleRepairReporter { fn report_progress(&self, message: &str, progress: f64) { @@ -186,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 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}; diff --git a/src/verify/config.rs b/src/verify/config.rs index c2016fff..8b72178f 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,10 @@ 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, + /// Base directory for resolving protected data files. + pub base_path: Option, } impl Default for VerificationConfig { @@ -34,6 +39,8 @@ impl Default for VerificationConfig { data_skipping: false, skip_leeway: 0, rename_only: false, + extra_files: Vec::new(), + base_path: None, } } } @@ -49,6 +56,8 @@ impl VerificationConfig { data_skipping: false, skip_leeway: 0, rename_only: false, + extra_files: Vec::new(), + base_path: None, } } @@ -63,9 +72,21 @@ impl VerificationConfig { data_skipping: false, skip_leeway: 0, rename_only: false, + extra_files: Vec::new(), + base_path: None, } } + pub fn with_extra_files(mut self, extra_files: Vec) -> Self { + self.extra_files = extra_files; + 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 @@ -128,6 +149,18 @@ 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_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, @@ -143,9 +176,22 @@ impl VerificationConfig { .flatten() .copied() .unwrap_or(false), + extra_files, + base_path, }) } + fn normalize_arg_path(value: &str) -> PathBuf { + let path = PathBuf::from(value); + 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..b1baef13 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(), }) } @@ -189,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, @@ -202,17 +207,32 @@ 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, extra_files: &[PathBuf], ) -> VerificationResults { // Note: report_verification_start and report_files_found should be called by the caller + let mut 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 = Self::dedupe_extra_files(&combined_extra_files); + &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 +240,7 @@ impl GlobalVerificationEngine { &file_statuses, &scan_metadatas, &renamed_matches, + &block_locations, ); let block_results = self.create_block_verification_results(&available_blocks); @@ -237,7 +258,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, @@ -247,6 +268,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 +301,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 +364,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,10 +379,11 @@ impl GlobalVerificationEngine { file_statuses, scan_metadatas, renamed_matches, + block_locations, ) } - fn process_extra_file( + fn process_extra_file( &self, file_path: &Path, reporter_lock: &Mutex<&R>, @@ -394,7 +422,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>, @@ -404,6 +432,7 @@ impl GlobalVerificationEngine { FileId, FileStatus, FileScanMetadata, + PathBuf, ) { use crate::verify::types::FileSize; @@ -453,11 +482,27 @@ 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( + fn scan_single_file_with_progress( &self, file_path: &Path, file_size: FileSize, @@ -915,7 +960,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, @@ -1166,7 +1211,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, @@ -1202,6 +1247,7 @@ impl GlobalVerificationEngine { file_statuses: &FileStatusMap, scan_metadatas: &HashMap, renamed_matches: &RenamedFileMatches, + block_locations: &BlockLocationMap, ) -> Vec { let mut file_results = Vec::new(); @@ -1270,6 +1316,18 @@ impl GlobalVerificationEngine { .collect() }) .unwrap_or_default(); + let mut block_sources = HashMap::default(); + 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) + { + if let Some(source) = block_locations.get(&(*fid, *block_number)) { + block_sources.insert(*block_number, source.clone()); + } + } + } // Just create the result record (reporting already done inline) @@ -1284,6 +1342,7 @@ impl GlobalVerificationEngine { matched_path: (status == FileStatus::Renamed) .then(|| renamed_matches.get(&file_description.file_id).cloned()) .flatten(), + block_sources, }); } @@ -1427,6 +1486,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(); @@ -1507,6 +1567,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(); @@ -1564,6 +1625,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(); @@ -1628,6 +1690,7 @@ mod tests { base_dir: std::path::PathBuf::from("."), file_order: Vec::new(), rename_only: false, + extra_files: Vec::new(), }; // Test 1: Direct insertion @@ -2002,6 +2065,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 @@ -2151,6 +2215,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 @@ -2217,6 +2282,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); @@ -2279,6 +2345,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); @@ -2331,6 +2398,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 +2453,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); @@ -2440,6 +2509,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 @@ -2562,6 +2632,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); @@ -2626,6 +2697,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); @@ -2705,6 +2777,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(); @@ -2775,6 +2848,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); @@ -2825,6 +2899,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 @@ -2898,6 +2973,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(); @@ -3077,6 +3153,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); @@ -3132,6 +3209,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/src/verify/mod.rs b/src/verify/mod.rs index 8c06d41a..07a91805 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; @@ -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, diff --git a/src/verify/types.rs b/src/verify/types.rs index 4feb058d..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 { @@ -410,6 +417,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_binaries.rs b/tests/test_binaries.rs index 20d0b3e7..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()); } @@ -1234,6 +1290,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"); @@ -1546,6 +1626,117 @@ 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"); + 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_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"); @@ -1766,6 +1957,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"); @@ -1867,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_console_verification_reporter.rs b/tests/test_console_verification_reporter.rs index 39bb7cd6..72b42f5a 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_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 dbdde0ef..bce17692 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,37 @@ 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"); + 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])), + 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()); + assert!(foreign_par2.exists()); +} + #[test] fn test_repair_context_purge_multiple_par2_files() { let dir = TempDir::new().unwrap(); @@ -440,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])), @@ -455,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] 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_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(); 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(); diff --git a/tests/test_silent_verification_reporter.rs b/tests/test_silent_verification_reporter.rs index 5c0f6281..3d7a43ff 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..d79ba3d7 --- /dev/null +++ b/tests/test_turbo_verify_repair_parity.rs @@ -0,0 +1,144 @@ +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_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"); + + 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_eq!(fs::read(misplaced).unwrap(), b"abcdefghijkl"); +} + +#[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_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(); + 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..a77ca1b2 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..adcb7d91 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(); @@ -192,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")); } @@ -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 {