Summary
rust_par2::repair() produces incorrect reconstructed bytes when damaged blocks are spread across more than one file in the file set. To its credit, the crate's own post-repair verification catches the mismatch and returns an error (RepairError::VerifyFailed) instead of writing corrupted data to disk — but repair itself does not succeed. Damage confined to a single file, even across multiple blocks, repairs correctly in my testing; it's specifically cross-file damage that breaks it.
Found while evaluating rust-par2 as a candidate to replace shelling out to par2cmdline-turbo for PAR2 verify/repair in a home media server project. The crate's verify path matched par2cmdline-turbo and par2cmdline exactly across every fixture I tried (nice work there!), but repair failed on the first multi-file-damage fixture I threw at it, and self-detected the failure rather than writing bad output.
Environment
rust-par2 = "0.1.2" (from crates.io)
- rustc 1.96.1 (31fca3adb 2026-06-26)
- Ubuntu 24.04.4 LTS (Noble Numbat), x86_64, Linux 6.18 kernel (WSL2)
- Reference tool: par2cmdline-turbo v1.4.0 (linux-amd64 release binary), used only to create the recovery set and as an independent correctness check — it repairs the same fixture perfectly.
Reproduction
Two 10-block (40 KiB) files, one damaged block in each (2 damaged blocks total, split across the two files). The recovery set has 4 recovery blocks (20% redundancy) — more than enough for 2 damaged blocks in principle, and par2cmdline-turbo repairs it without issue.
Cargo.toml:
[package]
name = "repro"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "gen-payload"
path = "src/bin/gen_payload.rs"
[[bin]]
name = "corrupt"
path = "src/bin/corrupt.rs"
[[bin]]
name = "repro-bug1"
path = "src/bin/repro_bug1.rs"
[dependencies]
rand = "0.8"
rand_chacha = "0.3"
rust-par2 = "0.1.2"
src/bin/gen_payload.rs (deterministic payload generator, gen-payload <seed> <size> <out>):
use std::env;
use std::fs::File;
use std::io::{BufWriter, Write};
use rand::RngCore;
use rand_chacha::ChaCha8Rng;
use rand_chacha::rand_core::SeedableRng;
fn main() {
let args: Vec<String> = env::args().collect();
let seed: u64 = args[1].parse().unwrap();
let size: u64 = args[2].parse().unwrap();
let out = &args[3];
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let f = File::create(out).unwrap();
let mut w = BufWriter::with_capacity(1 << 20, f);
let mut remaining = size;
let mut buf = [0u8; 1 << 16];
while remaining > 0 {
let chunk = remaining.min(buf.len() as u64) as usize;
rng.fill_bytes(&mut buf[..chunk]);
w.write_all(&buf[..chunk]).unwrap();
remaining -= chunk as u64;
}
w.flush().unwrap();
println!("wrote {size} bytes (seed={seed}) to {out}");
}
src/bin/corrupt.rs (deterministic corruption, corrupt <file> <start:len> [start:len ...], fills the range with a repeating 0xDE 0xAD pattern):
use std::env;
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
fn main() {
let args: Vec<String> = env::args().collect();
let path = &args[1];
let mut f = OpenOptions::new().read(true).write(true).open(path).unwrap();
for spec in &args[2..] {
let (start, len) = spec.split_once(':').unwrap();
let start: u64 = start.parse().unwrap();
let len: u64 = len.parse().unwrap();
let pattern: Vec<u8> = (0..len).map(|i| if i % 2 == 0 { 0xDE } else { 0xAD }).collect();
f.seek(SeekFrom::Start(start)).unwrap();
f.write_all(&pattern).unwrap();
println!("overwrote {len} bytes at offset {start} in {path}");
}
f.flush().unwrap();
}
src/bin/repro_bug1.rs (repro-bug1 <par2-index-path> <data-dir>):
use std::env;
use std::path::PathBuf;
fn main() {
let args: Vec<String> = env::args().collect();
let par2_path = PathBuf::from(&args[1]);
let data_dir = PathBuf::from(&args[2]);
let file_set = rust_par2::parse(&par2_path).expect("parse failed");
let verify_result = rust_par2::verify(&file_set, &data_dir);
println!("verify: all_correct={} repair_possible={}", verify_result.all_correct(), verify_result.repair_possible);
println!("damaged files: {:?}", verify_result.damaged.iter().map(|d| d.filename.clone()).collect::<Vec<_>>());
println!("blocks_needed={} recovery_blocks_available={}", verify_result.blocks_needed(), verify_result.recovery_blocks_available);
match rust_par2::repair(&file_set, &data_dir) {
Ok(result) => println!("repair() returned Ok: {result}"),
Err(e) => {
println!("repair() returned Err: {e}");
println!("repair() Debug: {e:?}");
}
}
}
Driver script (run.sh) — builds the repro binaries, creates the recovery set with par2cmdline-turbo, damages one block in each of the two files, then runs rust-par2 repair and, separately, confirms par2cmdline-turbo repairs the identical damaged fixture:
#!/usr/bin/env bash
# Assumes tools/bin/par2-turbo is par2cmdline-turbo v1.4.0 (linux-amd64), e.g.:
# curl -sL -o turbo.zip https://github.com/animetosho/par2cmdline-turbo/releases/download/v1.4.0/par2cmdline-turbo-1.4.0-linux-amd64.zip
# unzip -o turbo.zip -d turbo && install -m0755 turbo/par2 tools/bin/par2-turbo
set -euo pipefail
TURBO=tools/bin/par2-turbo
cargo build --release --quiet
BIN=target/release
rm -rf fixture && mkdir -p fixture
"$BIN/gen-payload" 3001 40960 fixture/file_a.bin # 10 blocks @ 4096 B
"$BIN/gen-payload" 3002 40960 fixture/file_b.bin # 10 blocks @ 4096 B
( cd fixture && sha256sum file_a.bin file_b.bin > SHA256SUMS )
( cd fixture && "$OLDPWD/$TURBO" create -q -s4096 -r20 -n1 test.par2 file_a.bin file_b.bin )
echo "### rust-par2 ###"
rm -rf work-rust && cp -r fixture work-rust
"$BIN/corrupt" work-rust/file_a.bin 5000:4096 # 1 block damaged in file_a
"$BIN/corrupt" work-rust/file_b.bin 5000:4096 # 1 block damaged in file_b (2 damaged total, cross-file)
"$BIN/repro-bug1" work-rust/test.par2 work-rust
echo "sha256 after rust-par2 repair attempt:"
sha256sum work-rust/file_a.bin work-rust/file_b.bin
echo "expected:"
cat fixture/SHA256SUMS
echo
echo "### par2cmdline-turbo, same damage, for comparison ###"
rm -rf work-turbo && cp -r fixture work-turbo
"$BIN/corrupt" work-turbo/file_a.bin 5000:4096
"$BIN/corrupt" work-turbo/file_b.bin 5000:4096
"$TURBO" repair -q work-turbo/test.par2
echo "sha256 after turbo repair:"
sha256sum work-turbo/file_a.bin work-turbo/file_b.bin
Observed output
### rust-par2 ###
verify: all_correct=false repair_possible=true
damaged files: ["file_a.bin", "file_b.bin"]
blocks_needed=4 recovery_blocks_available=4
repair() returned Err: Verification after repair failed: 0 intact, 2 damaged, 0 missing — 4 blocks needed, 4 available
repair() Debug: VerifyFailed("0 intact, 2 damaged, 0 missing — 4 blocks needed, 4 available")
sha256 after rust-par2 repair attempt:
8a4912f20b45e97841c2b17992b41c6772f70685cc040b8b665a5b5239c39954 work-rust/file_a.bin
c0369613d0cf4096683fb180a1e578d7e4a0742591727b3a2bdcac90cc5bbd63 work-rust/file_b.bin
expected:
60a3d2e39b2d39c2f686333699f59802c30cb59cfee46d27b2eb13fa66ec1f1b file_a.bin
d0ce26d4c864bc1da27e17d73c2cbaea7e3c78fd7ffaf72591d4863ca00c4a92 file_b.bin
### par2cmdline-turbo, same damage, for comparison ###
Loading "test.par2".
Loading "test.vol0+4.par2".
Target: "file_a.bin" - damaged. Found 8 of 10 data blocks.
Target: "file_b.bin" - damaged. Found 8 of 10 data blocks.
Repair is required.
Repair is possible.
Verifying repaired files:
Target: "file_a.bin" - found.
Target: "file_b.bin" - found.
Repair complete.
sha256 after turbo repair:
60a3d2e39b2d39c2f686333699f59802c30cb59cfee46d27b2eb13fa66ec1f1b work-turbo/file_a.bin
d0ce26d4c864bc1da27e17d73c2cbaea7e3c78fd7ffaf72591d4863ca00c4a92 work-turbo/file_b.bin
Both files fail to reconstruct correctly under rust-par2 — the post-repair checksums don't match the originals — and this is caught by the crate's own verification, returning VerifyFailed rather than silently leaving corrupted files in place. par2cmdline-turbo repairs the identical damaged fixture (same .par2/.vol files, same corruption) perfectly, confirming the recovery set itself is sufficient and correct, and the discrepancy is in rust-par2's repair computation.
Additional data point: damage confined to a single file — even 3 damaged blocks in one file, more than the 2 blocks that fail here — repaired correctly and produced matching checksums in my testing. It's specifically damage split across multiple files in the set that triggers this. That pattern (file_a.bin needing 2 blocks, file_b.bin needing 2 blocks, split across two files) is exactly what a real multi-file Usenet release with a couple of corrupted articles would look like.
Expected behavior
repair() should reconstruct both files' damaged blocks correctly and return Ok, matching par2cmdline-turbo's output, when the recovery set has sufficient recovery blocks for the total damage (4 needed, 4 available here — right at the edge, but sufficient, and par2cmdline-turbo repairs it fine).
Notes
Checked the issue tracker first — only two open/closed issues exist (both dependabot version bumps), so this doesn't look like a duplicate. Really appreciate the pure-Rust approach here (no C/C++ deps is a big deal for a use case like ours) and the fact that the post-repair check catches this rather than writing bad bytes silently — that safety net is doing exactly what it should. Happy to share the small repro project as a tarball or a git repo if that's easier to work with than the inlined script above.
Summary
rust_par2::repair()produces incorrect reconstructed bytes when damaged blocks are spread across more than one file in the file set. To its credit, the crate's own post-repair verification catches the mismatch and returns an error (RepairError::VerifyFailed) instead of writing corrupted data to disk — but repair itself does not succeed. Damage confined to a single file, even across multiple blocks, repairs correctly in my testing; it's specifically cross-file damage that breaks it.Found while evaluating
rust-par2as a candidate to replace shelling out topar2cmdline-turbofor PAR2 verify/repair in a home media server project. The crate's verify path matched par2cmdline-turbo and par2cmdline exactly across every fixture I tried (nice work there!), but repair failed on the first multi-file-damage fixture I threw at it, and self-detected the failure rather than writing bad output.Environment
rust-par2= "0.1.2" (from crates.io)Reproduction
Two 10-block (40 KiB) files, one damaged block in each (2 damaged blocks total, split across the two files). The recovery set has 4 recovery blocks (20% redundancy) — more than enough for 2 damaged blocks in principle, and par2cmdline-turbo repairs it without issue.
Cargo.toml:src/bin/gen_payload.rs(deterministic payload generator,gen-payload <seed> <size> <out>):src/bin/corrupt.rs(deterministic corruption,corrupt <file> <start:len> [start:len ...], fills the range with a repeating 0xDE 0xAD pattern):src/bin/repro_bug1.rs(repro-bug1 <par2-index-path> <data-dir>):Driver script (
run.sh) — builds the repro binaries, creates the recovery set with par2cmdline-turbo, damages one block in each of the two files, then runsrust-par2repair and, separately, confirms par2cmdline-turbo repairs the identical damaged fixture:Observed output
Both files fail to reconstruct correctly under
rust-par2— the post-repair checksums don't match the originals — and this is caught by the crate's own verification, returningVerifyFailedrather than silently leaving corrupted files in place. par2cmdline-turbo repairs the identical damaged fixture (same.par2/.volfiles, same corruption) perfectly, confirming the recovery set itself is sufficient and correct, and the discrepancy is inrust-par2's repair computation.Additional data point: damage confined to a single file — even 3 damaged blocks in one file, more than the 2 blocks that fail here — repaired correctly and produced matching checksums in my testing. It's specifically damage split across multiple files in the set that triggers this. That pattern (
file_a.binneeding 2 blocks,file_b.binneeding 2 blocks, split across two files) is exactly what a real multi-file Usenet release with a couple of corrupted articles would look like.Expected behavior
repair()should reconstruct both files' damaged blocks correctly and returnOk, matching par2cmdline-turbo's output, when the recovery set has sufficient recovery blocks for the total damage (4 needed, 4 available here — right at the edge, but sufficient, and par2cmdline-turbo repairs it fine).Notes
Checked the issue tracker first — only two open/closed issues exist (both dependabot version bumps), so this doesn't look like a duplicate. Really appreciate the pure-Rust approach here (no C/C++ deps is a big deal for a use case like ours) and the fact that the post-repair check catches this rather than writing bad bytes silently — that safety net is doing exactly what it should. Happy to share the small repro project as a tarball or a git repo if that's easier to work with than the inlined script above.