Phase 8: coverage-guided fuzzing for crates/measure/ and the scanners¶
Master plan: PLAN-measure.md · Previous phase: PLAN-measure-phase-07-integration-tests.md
Status: Not started¶
Mission¶
Stand up two new cargo-fuzz (libFuzzer) targets under
src/fuzz/fuzz_targets/ that exercise the code introduced in
phases 1 and 2:
-
fuzz_measure_calc.rs— pure-function fuzzing of the fivecrates/measure/calculators (measure_raw,measure_qcow2,measure_vmdk,measure_vhd,measure_vhdx). Decodes the fuzzer's input bytes into a(target_format, AllocationSummary, options)tuple, calls every function for every target, and asserts a small set of universal invariants. No I/O; no mock CallTable; the harness sits cleanly inside the calculator crate's signature. -
fuzz_measure_scan.rs— image-bytes fuzzing of the per-parserscan_allocationentry points added in phase 2 (Qcow2State::scan_allocation,VmdkState::scan_allocation,VhdState::scan_allocation,VhdxState::scan_allocation). Reuses the existinginstar_fuzz::build_call_table()infrastructure to feed fuzzer-supplied bytes through the parser's cached sector reader and into the metadata-walking scan path.
Both targets are added to src/fuzz/Cargo.toml so the
existing CI workflow (.github/workflows/coverage-fuzz.yml)
picks them up alongside the 13 existing harnesses without any
workflow edits.
Why this is its own phase¶
Phases 1-7 made measure work and proved it matches qemu-img
on a curated set of cases. Phase 8 hardens it against the
adversarial input space libFuzzer explores. The two
classes — pure calculator math and metadata-walking scanners
— need different harness shapes:
- Calculators are deterministic pure functions of bounded-size struct inputs. The harness's job is to decode fuzzer bytes into a structured tuple and call the function. Cheap, parallelisable, no I/O.
- Scanners consume parser state machines that follow on-
disk pointers. The mock CallTable in
src/fuzz/src/lib.rsalready serves this exact need for the existingfuzz_qcow2_l1l2,fuzz_vmdk_grain, etc. targets; phase 8 reuses the infrastructure without changes.
Splitting from phase 9 (differential fuzzing extension) is clean: phase 8 finds crashes / hangs / panics, phase 9 finds disagreements with qemu-img. Different harnesses, different oracles, different CI workflows.
Architecture¶
fuzz_measure_calc.rs: decoding the fuzz input¶
The calculators take (virtual_size, allocated_bytes, options)
tuples. We want libFuzzer to drive every code path, including
overflow checks, validation failures, and the qcow2 fixed-point
refcount loop. The simplest deterministic decoder:
byte 0 target format selector (mod 5)
0 = raw, 1 = qcow2, 2 = vmdk, 3 = vhd, 4 = vhdx
byte 1 qcow2 refcount_bits selector (mod 8): picks one of
{1, 2, 4, 8, 16, 32, 64, 0xff (invalid)}
byte 2 flag bits: bit 0 = extended_l2, bit 1 = lazy_refcounts,
bit 2 = compat_v3, bit 3 = compress
byte 3 preallocation enum (mod 4): Off/Metadata/Falloc/Full
byte 4 vmdk subformat (mod 3): MonolithicSparse / StreamOpt / Flat
byte 5 vhd subformat (mod 2): Dynamic / Fixed
bytes 6-13 virtual_size (u64 little-endian)
bytes 14-21 allocated_bytes (u64 little-endian)
bytes 22-25 cluster_size (u32 LE)
bytes 26-29 grain_size (u32 LE)
bytes 30-33 block_size (u32 LE)
bytes 34-41 luks_header_overhead (u64 LE)
Total: 42 bytes minimum. Shorter inputs return early.
Including 0xff as an "invalid" refcount_bits selector means
libFuzzer exercises the InvalidOption rejection path. Same
trick for non-power-of-two cluster_size/grain_size/block_size
values: the fuzzer naturally generates them and the calculator's
validation rejects them with InvalidOption.
fuzz_measure_calc.rs: invariants¶
For every successful return (Ok(MeasureOutput)):
required <= fully_allocated(the sparse size is never larger than the fully-allocated size).- For
target = raw:fully_allocated >= virtual_size(raw has no overhead, so this is exact equality, but>=lets the assertion survive future raw-padding changes). - For
target != raw:fully_allocated > 0whenevervirtual_size > 0(every non-raw format has at least header overhead). - Overflow sanity:
required + fully_allocateddoesn't wrap onchecked_add— i.e.required.checked_add(fully_allocated).is_some(). Catches a class of latent overflow where each result is individually small but their sum exceedsu64::MAX.
Errors are not failures — the calculator is explicitly
allowed to return MeasureError::Overflow / InvalidOption /
InvalidSize and libFuzzer only catches panics. The
Result::is_ok() branch is where invariants apply.
fuzz_measure_scan.rs: dispatch on a format prefix byte¶
byte 0 format selector (mod 4): 0=qcow2, 1=vmdk, 2=vhd, 3=vhdx
bytes 1.. image data, fed through set_fuzz_input()
The raw scanner is trivial (returns virtual_size as
allocated_bytes; no I/O); skipping it has zero coverage cost.
Per-format flow:
fuzz_target!(|data: &[u8]| {
if data.len() < 2 { return; }
let format = data[0] % 4;
let image_data = &data[1..];
instar_fuzz::set_fuzz_input(image_data);
let call_table = instar_fuzz::build_call_table();
let sector_size = 512;
let input_capacity = instar_fuzz::input_capacity();
// Two cache buffers — every scanner needs them, name varies.
let mut cache_a = vec![0u8; shared::MAX_SECTOR_SIZE];
let mut cache_b = vec![0u8; shared::MAX_SECTOR_SIZE];
let mut bytes_read = 0u64;
unsafe {
match format {
0 => { /* qcow2 */
let state = qcow2::Qcow2State::init(
&call_table, 0, sector_size, input_capacity,
cache_a.as_mut_ptr(), cache_b.as_mut_ptr(),
&mut bytes_read,
);
if let Some(mut s) = state {
// Recover virtual_size by re-parsing the first sector.
let mut buf = [0u8; shared::MAX_SECTOR_SIZE];
if (call_table.read_input_sector)(0, 0, buf.as_mut_ptr(), sector_size) {
let hdr = qcow2::QcowHeader::parse(&buf[..sector_size]);
if let Some(h) = hdr {
let _ = s.scan_allocation(
&call_table, sector_size, input_capacity,
h.virtual_size, &mut bytes_read,
);
}
}
}
}
1 => { /* vmdk */ ... }
2 => { /* vhd */ ... }
3 => { /* vhdx */ ... }
_ => unreachable!(),
}
}
});
Invariants asserted when the scan returns Some(summary):
summary.allocated_bytes <= summary.virtual_sizesummary.allocated_bytes >= 0(free; type system enforces)- No panic (libFuzzer's default oracle)
None returns are fine — the parser rejected the image.
Cargo manifest additions¶
# src/fuzz/Cargo.toml
[[bin]]
name = "fuzz_measure_calc"
path = "fuzz_targets/fuzz_measure_calc.rs"
doc = false
test = false
[[bin]]
name = "fuzz_measure_scan"
path = "fuzz_targets/fuzz_measure_scan.rs"
doc = false
test = false
[dependencies]
# ... existing entries ...
measure = { path = "../crates/measure" } # NEW
The measure crate must be added as a fuzz dependency; it's
currently not in the fuzz crate's manifest because
crates/measure only landed in phase 1.
Existing CI workflow integration¶
.github/workflows/coverage-fuzz.yml enumerates targets via:
- workflow_dispatch.inputs.targets (manual override)
- Auto-discovery for the empty case (the script lists every
[[bin]] entry in Cargo.toml)
So adding the two new [[bin]] entries is sufficient — no
workflow edit needed. Verify during 8b by reading the script
that emits the matrix; if it hard-codes target names, extend
it.
Corpus seeding¶
Phase 8 ships with no curated corpus. libFuzzer starts with
an empty seed and discovers inputs on its own. For
fuzz_measure_scan, the existing
scripts/extract-fuzz-corpus.py already produces
format-prefixed inputs from instar-testdata for the other
scanner harnesses; phase 8's brief asks the script to be
extended only if a smoke run shows poor initial coverage.
Defer to 8b's verification step.
Smoke runs¶
Each target's first run should be ~60s locally during step
8a/8b to confirm:
- It builds (cargo +nightly fuzz build <target>).
- It actually runs without immediate libFuzzer setup panics
(cargo +nightly fuzz run <target> -- -runs=10000 -max_total_time=60).
- No crashes within the smoke window.
If any crashes appear, treat them as real bugs to file
and fix before committing the harness. A crashing harness
on first run probably means the fuzzer found a genuine panic
in crates/measure/ or one of the scanners that the existing
unit tests didn't catch.
What we are NOT fuzzing in this phase¶
- The host CLI surface (clap parsing) — that's standard library code, well-tested.
- The protobuf decoder — covered by existing
fuzz_qcow2_*and friends transitively. - The convert writer — out of scope; the measure-vs-convert round-trip is phase 7d's job.
- The
-o key=value,...parser — string parsing of CLI args, could be added later as a small extra target. - Cross-version baseline comparison — that's phase 9's differential fuzzer.
Open questions¶
-
Should
fuzz_measure_scanexercise raw too? Phase 2'sraw::scan_allocationispub fn scan_allocation(virtual_size: u64) -> AllocationSummary. It's pure, takes a single u64, and is trivial. Coverage benefit is near zero. Recommendation: skip; letfuzz_measure_calccover it via the calculator dispatch. -
One combined harness vs two separate ones? The two surfaces have different signatures (one takes structured bytes, the other takes raw image data) so a single harness would need a top-level dispatch on the first byte. Recommendation: two separate harnesses — cleaner coverage attribution, simpler corpus management, parallelism in the CI matrix.
-
fuzz_measure_calcinvariant for qcow2Preallocation: forFalloc/Fullthe calculator setsrequired == fully_allocated. Should the harness assert that? Recommendation: yes — adds a meaningful invariant for free. (Skip forMetadata, where the rule is subtler — phase 1's "metadata equals off" finding means the invariant would actually fire asrequired == off_requirednotmetadata_required. Pass-through is safest.) -
fuzz_measure_scanand the qcow2 virtual_size recovery: the harness re-parsesQcowHeaderto extractvirtual_sizebefore callingscan_allocation. If the header parse fails the call is skipped. This matches the real measure operation's behaviour (phase 3f's guest binary does the same dance), but means a fuzzer can't directly drivescan_allocationwith a bogus virtual_size. Recommendation: that's fine — the bogus-input coverage is already had viacluster_lookupfuzzing in the existingfuzz_qcow2_l1l2target. -
Should
crates/measureget apub(crate)helper for the refcount fixed-point loop, exposed via a#[cfg(fuzzing)]gate so the harness can drive it directly? Recommendation: no — the loop is exercised through the publicmeasure_qcow2entry point, and the fuzzer will explore it via the(used_clusters, cluster_size, refcount_bits)parameter space thatmeasure_qcow2constructs internally. -
Sanitizers: the existing fuzz harnesses run under libFuzzer's default address-sanitizer-equivalent. Phase 8 inherits that; no additional sanitizer config needed.
-
Nightly CI duration: phase 6 uses
cron: '0 4 * * *'with 1 h per target. Adding two new targets adds 2 h to nightly. Acceptable for the runner pool. Verify the workflow'stimeout-minutes: 480ceiling still accommodates 15 targets × 1 h.
Execution¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 8a | medium | sonnet | none | Create src/fuzz/fuzz_targets/fuzz_measure_calc.rs per the "decoding the fuzz input" + "invariants" sections above. Add measure = { path = "../crates/measure" } to src/fuzz/Cargo.toml [dependencies] and add the matching [[bin]] entry. The harness decodes the first 42 bytes into a structured tuple, dispatches to measure_raw / measure_qcow2 / measure_vmdk / measure_vhd / measure_vhdx, and asserts the four invariants only on Ok returns. Errors are silently ignored. Verify with cd src/fuzz && cargo +nightly fuzz build fuzz_measure_calc and then cargo +nightly fuzz run fuzz_measure_calc -- -runs=10000 -max_total_time=60 (or however the project's local fuzz workflow runs — read .github/workflows/coverage-fuzz.yml for the canonical incantation). If crashes appear, stop and file them as real bugs before continuing. Touch only src/fuzz/Cargo.toml and the new target file. |
| 8b | medium | sonnet | none | Create src/fuzz/fuzz_targets/fuzz_measure_scan.rs per the "dispatch on a format prefix byte" section above. Format-selector byte chooses qcow2/vmdk/vhd/vhdx, remaining bytes fed through instar_fuzz::set_fuzz_input and the existing build_call_table() mock. For each format: call <Format>State::init, then <Format>State::scan_allocation; assert allocated_bytes <= virtual_size on Some return. Match the exact init signature variations the phase 2 scanners use (qcow2 takes l1+l2 cache; vmdk takes gd+gt; vhd takes bat+data; vhdx takes bat+data; qcow2's scan_allocation needs virtual_size recovered via QcowHeader::parse). Add the [[bin]] entry to src/fuzz/Cargo.toml. Same smoke-run verification as 8a. If the CI workflow at .github/workflows/coverage-fuzz.yml enumerates targets by name rather than auto-discovering them, also update that workflow's target list — but read it first; it likely auto-discovers via cargo metadata or a ls fuzz_targets/ glob. |
| 8c | low | sonnet | none | Update ARCHITECTURE.md: the existing "Coverage-Guided Fuzzing" subsection (around the "Differential Fuzzing" area) lists 13 fuzz targets. Update the count to 15 and add fuzz_measure_calc / fuzz_measure_scan to the enumeration. Update CHANGELOG.md Unreleased / Added with: "Coverage-guided fuzz targets for crates/measure/ (fuzz_measure_calc) and the per-parser scan_allocation entry points (fuzz_measure_scan). Picked up automatically by the nightly coverage-fuzz workflow. (PLAN-measure-phase-08-fuzz-coverage.md)". Run pre-commit run --all-files. Touch only ARCHITECTURE.md and CHANGELOG.md. |
Total: 3 commits.
Out of scope for phase 8¶
- Differential fuzzing (phase 9 — that's a separate workflow comparing instar against qemu-img).
- Corpus seeding (defer to 8b verification; only add if initial coverage is poor).
- Refactoring
crates/measureto expose internal helpers for fuzzing (none needed). -o key=value,...parser fuzzing (potential follow-up; small scope but not blocking).- Extending the workflow's per-target time budget (1 h default is fine).
Success criteria¶
src/fuzz/Cargo.tomllistsfuzz_measure_calcandfuzz_measure_scan[[bin]]entries plus themeasuredependency.cargo +nightly fuzz build fuzz_measure_calcsucceeds.cargo +nightly fuzz build fuzz_measure_scansucceeds.cargo +nightly fuzz run <target> -- -runs=10000 -max_total_time=60completes without crashes for both targets.- The nightly CI workflow's auto-discovery picks up the new
targets (manual verification by reading
.github/workflows/coverage-fuzz.yml). - ARCHITECTURE.md and CHANGELOG.md updated.
Risks and mitigations¶
- Initial fuzz run finds a real panic. Mitigation: 8a/8b briefs say "stop and file the bug". The expected outcome is no findings because phases 1 and 2 covered the math and scanner logic with extensive unit tests; if the fuzzer does find something, it's high-value signal worth pausing for.
- Mock CallTable signature drift: phase 2 left
Qcow2State::scan_allocationtakingvirtual_sizeas an argument (not a field). The harness must callQcowHeader::parseto recover it; getting the offset wrong results in scan_allocation reading bogus values and potentially asserting. Mitigation: 8b's brief calls this out and points at phase 3f (the guest binary) for the canonical recipe. - CI workflow doesn't auto-discover targets: if the workflow hard-codes the target list, 8b needs to extend it. Mitigation: 8b's brief says "read it first".
- libFuzzer's default 4 GB memory limit: phase 1's
fixed-point loop is bounded at 16 iterations. The qcow2
metadata-walking scanner could blow memory on adversarial
L1 sizes. Mitigation: the existing
fuzz_qcow2_l1l2target survives this, suggesting the scanner's bounds checks are adequate. If a crash hits the memory limit, file it as a real bug and add an explicit early-return in the scanner.
Back brief¶
Before executing any step, the executing agent should back-brief: which fuzz target file is being added, which crates from phases 1/2 it exercises, and what invariants the harness asserts. The reviewer should verify the harness exercises every public entry point listed in the master plan's success criteria for phase 8 — both calculators (5 functions) and scanners (4 non-raw functions).