Skip to content

verify() undercounts recovery blocks in large .volNNN+MMM.par2 files, causing false repair_possible: false #4

Description

@edgetools

Summary

rust_par2::verify() sometimes reports a much lower recovery_blocks_available than a .volNNN+MMM.par2 file actually contains, once that volume file crosses a size threshold somewhere between roughly 7.9 MB and 10.5 MB (in my testing; likely a byte-size threshold in whatever reads/counts the file rather than anything about the source data itself, since triggering it required no source-file damage at all — it shows up on a fully intact file). This makes verify()'s repair_possible flag wrong: it reports false (not repairable) for sets that par2cmdline-turbo confirms are comfortably repairable, and that rust-par2's own repair() presumably could also handle if it were told the truth about how many recovery blocks are available.

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. Real-world recovery volumes at realistic redundancy levels and file sizes easily exceed this threshold, so this isn't an edge case — it's the common case for anything beyond a small test fixture.

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 to create the recovery set and as an independent correctness check.

Reproduction

A single 6-block source file (block size 524288 B / 512 KiB) with a .par2 recovery set forced to exactly 20 recovery blocks (-c20), producing one test.vol00+20.par2 file of ~10 MB. rust-par2 reports only 4 recovery blocks available in that file instead of 20. With 5 of the 6 source blocks damaged, rust-par2 then reports repair_possible: false (needs 5, "has" 4) while par2cmdline-turbo confirms the set is fully repairable (and does repair it, producing the original checksum).

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-bug2"
path = "src/bin/repro_bug2.rs"

[dependencies]
rand = "0.8"
rand_chacha = "0.3"
rust-par2 = "0.1.2"

src/bin/gen_payload.rs and src/bin/corrupt.rs are the same deterministic payload generator / byte-range corruption tool as in the sibling repair-bug report:

// src/bin/gen_payload.rs -- 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 -- corrupt <file> <start:len> [start:len ...]
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_bug2.rs (repro-bug2 <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!("all_correct={}", verify_result.all_correct());
    println!("repair_possible={}", verify_result.repair_possible);
    println!("blocks_needed={}", verify_result.blocks_needed());
    println!("recovery_blocks_available (rust-par2 reports)={}", verify_result.recovery_blocks_available);
}

Driver script (run.sh):

#!/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
# 6 source blocks @ 512 KiB block size = 3 MiB source payload.
"$BIN/gen-payload" 4300 3145728 fixture/payload.bin
# Force exactly 20 recovery blocks (-c20) so the resulting single volume file
# (test.vol00+20.par2, ~10 MB) crosses the size threshold where the undercount appears.
( cd fixture && "$OLDPWD/$TURBO" create -q -s524288 -c20 -n1 test.par2 payload.bin )
ls -la fixture/test.vol00+20.par2

echo
echo "### Undercount on a fully intact set (no damage at all) ###"
"$BIN/repro-bug2" fixture/test.par2 fixture

echo
echo "### False repair_possible: false once damage exceeds the undercounted total ###"
rm -rf work && cp -r fixture work
# Damage 5 of the 6 source blocks (block 0 left intact) -- true recovery
# capacity (20 blocks) covers this easily; the undercounted capacity (4) does not.
"$BIN/corrupt" work/payload.bin 524288:2621440
echo "-- rust-par2 verify --"
"$BIN/repro-bug2" work/test.par2 work
echo "-- par2cmdline-turbo verify, for comparison --"
"$TURBO" verify -q work/test.par2 || true
echo "-- par2cmdline-turbo repair, to confirm the set really is repairable --"
"$TURBO" repair -q work/test.par2
echo "sha256 after turbo repair:"
sha256sum work/payload.bin
echo "expected:"
sha256sum fixture/payload.bin

Observed output

-rw-r--r-- 1 ethan ethan 10489348 Jul  5 17:22 fixture/test.vol00+20.par2

### Undercount on a fully intact set (no damage at all) ###
all_correct=true
repair_possible=true
blocks_needed=0
recovery_blocks_available (rust-par2 reports)=4

### False repair_possible: false once damage exceeds the undercounted total ###
-- rust-par2 verify --
all_correct=false
repair_possible=false
blocks_needed=5
recovery_blocks_available (rust-par2 reports)=4

-- par2cmdline-turbo verify, for comparison --
Loading "test.par2".
Loading "test.vol00+20.par2".
Target: "payload.bin" - damaged. Found 1 of 6 data blocks.

Repair is required.
Repair is possible.

-- par2cmdline-turbo repair, to confirm the set really is repairable --
Loading "test.par2".
Loading "test.vol00+20.par2".
Target: "payload.bin" - damaged. Found 1 of 6 data blocks.

Repair is required.
Repair is possible.

Verifying repaired files:

Target: "payload.bin" - found.

Repair complete.
sha256 after turbo repair:
094a81e5fc7bcffb985dd71f9a11c0d71994561ded5923f1e577c6c5ab72b163  work/payload.bin
expected:
094a81e5fc7bcffb985dd71f9a11c0d71994561ded5923f1e577c6c5ab72b163  work/payload.bin

test.vol00+20.par2 genuinely contains 20 recovery blocks (par2cmdline-turbo created it with -c20 and confirms it can use all of them), but rust-par2 reports only 4 — even on a completely undamaged data set, so this is purely a parsing/counting issue in the volume file itself, not something that depends on which or how many source blocks are damaged. Once 5 blocks are damaged (more than the undercounted 4, well within the true 20), verify() flips repair_possible to false, and par2cmdline-turbo repairs the identical set without any trouble, landing on the correct checksum.

I did some quick binary search on the threshold: at block size 393216 B (a ~7.9 MB .vol00+20.par2) the count was correct (20); at block size 524288 B (a ~10.5 MB .vol00+20.par2) it dropped to 4. So the threshold is somewhere in the ~8-10 MB range for the volume file's own size in my testing, though I didn't dig further into verify.rs's count_recovery_blocks_in_dir to find the exact cause (looks like it could be a fixed-size read buffer, chunked-read loop boundary, or similar — a guess from the outside, not something I traced in the source).

Expected behavior

recovery_blocks_available should equal the true number of recovery blocks present across the .par2/.volNNN+MMM.par2 files (20 in this case), and repair_possible should be true whenever the true count covers blocks_needed, matching par2cmdline-turbo's verdict.

Notes

Checked the issue tracker first — only two open/closed issues exist (both dependabot version bumps), so this doesn't look like a duplicate. This one seems like it'd bite almost any real-world use case, since redundancy sets for anything bigger than a small test fixture will commonly produce volume files past the ~8-10 MB range where the undercount kicks in — worth prioritizing over the repair bug reported separately, since this one produces a wrong verdict even when verify-only usage is all that's needed. Happy to share the small repro project as a tarball or git repo, and can help narrow down the exact byte threshold further if useful.

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