Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c5e7147
Match turbo verify repair extra-file behavior
mjc Apr 21, 2026
4265918
Accept turbo verify repair skipping options
mjc Apr 21, 2026
86315e7
Honor turbo basepath for verify repair
mjc Apr 21, 2026
22abb4a
Match turbo purge behavior for verify repair
mjc Apr 21, 2026
28dcc45
Ignore foreign extra PAR2 sets
mjc Apr 21, 2026
6c46ea4
Match turbo par2verify repair exit codes
mjc Apr 21, 2026
48e512d
Match turbo repair impossible exit code
mjc Apr 21, 2026
729d503
Match turbo single quiet verify output
mjc Apr 21, 2026
a7ce06e
Suppress repair progress in quiet mode
mjc Apr 21, 2026
6d9a11f
Match turbo single quiet repair output
mjc Apr 21, 2026
0d593d1
Report opening files during verify
mjc Apr 21, 2026
e9727f3
Match turbo healthy verify summary
mjc Apr 21, 2026
add0d0e
Reduce normal repair summary duplication
mjc Apr 21, 2026
78b03d7
Backfill normal verify file statuses
mjc Apr 21, 2026
c971016
Report opening repaired files
mjc Apr 21, 2026
1dac8c5
Deduplicate loading progress output
mjc Apr 21, 2026
a07af33
Report Reed Solomon construction progress
mjc Apr 21, 2026
b35e36f
Match turbo duplicate loading order
mjc Apr 21, 2026
0c9b102
Label reconstruction progress as solving
mjc Apr 21, 2026
ee67f81
Report repaired data write phase
mjc Apr 21, 2026
53e05d6
Report repaired file scan progress
mjc Apr 21, 2026
6301ef8
Report construction progress tick
mjc Apr 21, 2026
ae14a1f
Report repair progress before writing
mjc Apr 21, 2026
595184d
Fix PR review comments
mjc Apr 22, 2026
11fab30
Fix verify repair rebase integration
mjc Apr 22, 2026
36b65e9
Address PR review comments
mjc Apr 24, 2026
4e68cb3
Address verify and repair review comments
mjc Apr 24, 2026
e6b5b44
Handle complete corrupted files during repair
mjc Apr 24, 2026
42142b3
Fix concise verify repair summary
mjc Apr 24, 2026
625dd60
Fix chunked create file hashes
mjc Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions src/bin/par2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))?;
Expand All @@ -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);
}
}
22 changes: 17 additions & 5 deletions src/bin/par2repair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))?;
Expand All @@ -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);
}
}
54 changes: 32 additions & 22 deletions src/bin/par2verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
Expand Down
34 changes: 34 additions & 0 deletions src/create/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
})
.collect::<Vec<_>>();

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::<Vec<_>>();
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);
Expand Down
21 changes: 6 additions & 15 deletions src/create/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<File> = Vec::with_capacity(source_files.len());
let mut file_md5_states: Vec<Md5> = Vec::with_capacity(source_files.len());
let mut file_16k_buffers: Vec<Vec<u8>> = Vec::with_capacity(source_files.len());

let mut block_md5_states: Vec<Md5> = Vec::with_capacity(source_block_count as usize);
Expand All @@ -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);
Expand Down Expand Up @@ -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]);
Expand All @@ -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()))
Expand All @@ -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);
Expand Down
13 changes: 12 additions & 1 deletion src/create/file_naming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading