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.
Summary
rust_par2::verify()sometimes reports a much lowerrecovery_blocks_availablethan a.volNNN+MMM.par2file 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 makesverify()'srepair_possibleflag wrong: it reportsfalse(not repairable) for sets that par2cmdline-turbo confirms are comfortably repairable, and thatrust-par2's ownrepair()presumably could also handle if it were told the truth about how many recovery blocks are available.Found while evaluating
rust-par2as a candidate to replace shelling out topar2cmdline-turbofor 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)Reproduction
A single 6-block source file (block size 524288 B / 512 KiB) with a
.par2recovery set forced to exactly 20 recovery blocks (-c20), producing onetest.vol00+20.par2file of ~10 MB.rust-par2reports only 4 recovery blocks available in that file instead of 20. With 5 of the 6 source blocks damaged,rust-par2then reportsrepair_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:src/bin/gen_payload.rsandsrc/bin/corrupt.rsare the same deterministic payload generator / byte-range corruption tool as in the sibling repair-bug report:src/bin/repro_bug2.rs(repro-bug2 <par2-index-path> <data-dir>):Driver script (
run.sh):Observed output
test.vol00+20.par2genuinely contains 20 recovery blocks (par2cmdline-turbo created it with-c20and confirms it can use all of them), butrust-par2reports 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()flipsrepair_possibletofalse, 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 intoverify.rs'scount_recovery_blocks_in_dirto 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_availableshould equal the true number of recovery blocks present across the.par2/.volNNN+MMM.par2files (20 in this case), andrepair_possibleshould betruewhenever the true count coversblocks_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.