Skip to content

repair() reconstructs the wrong bytes when damage spans more than one file (self-detected via VerifyFailed) #3

Description

@edgetools

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions