Integration Test Suite¶
The instar project includes a Python-based integration test suite that verifies
instar info produces output identical to qemu-img info, that instar check
correctly detects structural corruption in QCOW2 images, that instar compare
produces byte-for-byte identical output to qemu-img compare, and that
instar convert produces output identical to qemu-img convert. Since instar
aims to be a drop-in replacement, any difference in output is considered a bug.
Architecture¶
The test suite uses:
- testtools - Extended unittest framework with better assertions
- testscenarios - Parameterized test scenarios
- stestr - Parallel test runner with result storage
Tests compare instar output against either:
1. Live qemu-img output (for safe images - info, compare, convert)
2. Stored expected output files (for malicious images)
Test Categories¶
Safe Images (test_info_safe.py)¶
Tests against known-safe disk images. These run qemu-img directly and
compare outputs character-for-character.
Malicious Images (test_info_malicious.py)¶
Tests against images designed to exploit vulnerabilities (e.g., backing file
references to /etc/passwd). These use pre-stored expected output files
instead of running qemu-img, since running qemu-img on malicious images
defeats the security purpose of instar.
Check Validation Tests (test_check_formats.py)¶
Tests for the instar check operation:
- Format detection: Verifies check correctly identifies QCOW2, VMDK, VHD formats
- Per-format refusal: TestCheckVdiRefusal, TestCheckParallelsRefusal,
TestCheckDmgRefusal, and TestCheckQedRefusal verify check refuses each
read-only-input format with the correct exit code and message
- Corrupt images: Tests against deliberately corrupt format headers (VMDK, VHDX, VHD)
- QCOW2 structural validation: Uses 4 script-generated corrupt QCOW2 images:
- Clean baseline (should pass with 0 errors)
- Overlapping clusters (two L2 entries pointing to same host cluster)
- Refcount-zero (referenced cluster with refcount=0)
- Leaked cluster (refcount>0 but no L2 reference)
- Unsafe quirks mode: Verifies non-QCOW2 formats are treated as raw with
--unsafe-quirks
Corrupt QCOW2 test images are generated by
instar-testdata/custom/check-validation/create-corrupt-images.py, which creates
images with qemu-img/qemu-io and then surgically corrupts specific QCOW2
structures via binary manipulation.
Repair Tests (test_check_repair.py)¶
Tests for instar check --repair, codifying the phase-7-verified
behaviour against the corrupt-fixture matrix (each on a tempdir
copy, never the committed fixture):
- Leaks/all tiers repair to clean:
leaked+--repair=leaks, andrefcount-zero/refcount-too-high/stale-copied+--repair=all, are assertedqemu-img check-clean afterward with the surviving data patterns still readable viaqemu-io read -P. - Refuse paths stay byte-identical:
corrupt-bit-set,snapshot-leak, andcompressed-leakare sha256-identical after repair, with the snapshot and compressed data intact. - Overlapping is a safe partial repair: the genuine leak is
reclaimed, the structural overlap remains, no new error classes
appear, and instar exits 2 with
repair-incomplete. - CLI:
--repair+--chainrejection, clean-image no-op, qcow2-only not-supported on a raw image, and idempotence. - Repaired counters (
TestRepairCounters): the per-classrepaired-leaks/repaired-refcounts/repaired-corruptionscounts reach the host over theCheckResultMessageprotobuf and render in both the JSON and human--repairoutput, and are omitted on a read-only check so its schema is unchanged.
The structural tests assert post-repair state (qemu-img
check-clean, data reads, byte-identity) rather than instar's exit
code, since the exit code still reflects the detected corruption
after a clean repair; the repaired-counter output is asserted
separately by TestRepairCounters now that those counts travel on
the guest→host wire.
Compare Tests (test_compare.py)¶
Tests for the instar compare operation, cross-validated against qemu-img
compare:
Raw-vs-raw (TestCompareRawIdentical, TestCompareRawDifferent,
TestCompareRawSizeMismatch, TestCompareRawJson):
- Identical images: Self-compare and two identical files
- Different content: Mismatch at offset 0 and at mid-file offsets
- Size mismatch: Non-strict (zeros = identical), non-strict (non-zero =
differs), and strict mode (always fails on size difference)
- JSON output: Validates identical, first-mismatch-offset,
total-bytes-compared, and size-mismatch fields
QCOW2-vs-raw (TestCompareQcow2VsRaw):
- Identical content across formats (including all-zeros)
- Different content reports correct mismatch offset
- Cross-validated against qemu-img compare
QCOW2-vs-QCOW2 (TestCompareQcow2VsQcow2):
- Identical and different content between two QCOW2 images
- Virtual size mismatch handling
- Cross-validated against qemu-img compare
Compressed QCOW2 (TestCompareQcow2Compressed):
- Compressed QCOW2 vs raw with same content (zlib decompression)
- Compressed vs uncompressed QCOW2 with same content
- Cross-validated against qemu-img compare
Backing chains (TestCompareBackingChain):
- QCOW2 overlay with raw backing file vs flattened raw (identical)
- QCOW2 overlay vs different raw (mismatch detected)
- Deep chain (3-level: top -> mid -> base) vs flattened raw (identical)
- Two different QCOW2 backing chains with same virtual content (identical)
- All scenarios cross-validated against qemu-img compare
Test images are created at runtime using qemu-img create, qemu-io write,
qemu-img convert -c (for compressed), and qemu-img create -b (for backing
chains), so no external testdata is needed.
qemu-img cross-validation: Every scenario verifies byte-for-byte
identical stdout and matching exit codes with qemu-img compare.
Convert Tests (test_convert.py)¶
Tests for the instar convert operation (QCOW2 to raw), cross-validated
against qemu-img convert. Per-format convert matrices cover VDI
(TestConvertVdiToRaw), Parallels (TestConvertParallelsToRaw), QCOW1
(TestConvertQcow1ToRaw), and DMG (TestConvertDmgToRaw) input; QCOW1
also gets a dedicated smoke test module, tests/test_qcow1_smoke.py.
Basic conversion (TestConvertBasicQcow2ToRaw):
- Empty QCOW2 image to raw
- QCOW2 with written data to raw
- Output size matches virtual size
- All cross-validated against qemu-img convert
Compressed QCOW2 (TestConvertCompressed):
- Compressed QCOW2 to raw (zlib decompression)
- Compared against original raw source
Backing chains (TestConvertBackingChain):
- QCOW2 overlay with raw backing flattened to raw
- Deep chain (3-level: top -> mid -> base) flattened to raw
- Cross-validated against qemu-img convert
Raw passthrough (TestConvertRawToRaw):
- Raw to raw identity conversion
Error handling (TestConvertErrors):
- Unsupported output format rejected
- Nonexistent input file rejected
Manifest images (TestConvertManifestImages):
- Converts real-world QCOW2 images from the test manifest
- Cross-validates against qemu-img convert output
- Skips images with cluster_size > 64KB (unsupported)
- Skips images whose virtual_size exceeds available temp space
Resize Tests (test_resize.py)¶
Tests for the instar resize operation, structured around six
surfaces totalling 114 tests:
Schema-drift tripwire
(TestResizeBaselineMatrix.test_resize_cases_match_baselines):
- Walks instar-testdata/expected-outputs/resize-info-json/<target>/<version>/
and asserts the on-disk case set matches the in-test RESIZE_CASES
mirror. Catches drift between this mirror and the testdata
generator.
Cross-version baseline matrix (TestResizeBaselineMatrix):
- Per-(target, case) factory diffs instar create → instar resize
output against the phase-10 qemu-img info JSON baseline.
- ~22 active cases for qcow2 + raw; ~20 skipped (vmdk/vhd/vhdx where
qemu rejects, -no-shrink rejection cases that surface 6 covers,
and KNOWN_RESIZE_DIVERGENCES carry-forwards).
Live cross-validation (TestResizeCrossValidation):
- 7 curated qcow2 + raw cases comparing instar end-to-end against
the system qemu-img via instar info on both outputs.
Round-trip check (TestResizeRoundTripCheck):
- instar create → resize → check for ~30 non-raw cases.
- Catches resize-emitter regressions that produce files qemu-img
info accepts but instar check flags.
Internal consistency for vmdk/vpc/vhdx (TestResizeConsistency):
- 14 cases for the formats qemu can't resize, verified via
instar info virtual-size match + instar check.
Targeted error paths (TestResizeErrorPaths):
- 9 fixed tests pinning the host CLI rejection contracts:
shrink-without-flag for raw + qcow2, subtractive-size without
--shrink, invalid size strings, --preallocation=metadata on
raw, --preallocation=falloc + --shrink, --object,
--image-opts.
Wall clock: ~32 s serial / ~10 s with make test-container's
--concurrency 4.
Snapshot Tests (test_snapshot.py)¶
Tests for the instar snapshot subcommand (phase 11 of
PLAN-snapshot), 94 tests across five families:
- List matrix (human): per baselined image,
TZ=UTC instar snapshot -lbyte-equals the host-resolved profile baseline frominstar-testdata/expected-outputs/snapshot-list-human/(80 qemu-img versions captured; instar tracks the modern ≥9.0 layout); plus the bare-filename-defaults-to-list check. - List goldens (JSON):
--output=jsonbyte-equals the instar-side self-baselines intests/golden/snapshot-list/, plus a structural vmstate cross-check and a QMP-key schema test. - Mutation round-trips: create / delete / apply on tempdir
copies with post-op
qemu-img checkclean, content verified viaqemu-img compare, and structural behaviour on the name-collision / duplicate-name / cap-boundary fixtures. - Error paths: qcow2-only enforcement across raw / vmdk /
vhdx, feature-gate refusals (zstd, dirty bit, external data
file, LUKS), not-found exit codes,
-U+mutating refusal,--image-optsrejection. - Empty table: empty stdout + exit 0; JSON emits
[].
The suite is the CI regression net. The snapshot shell
harnesses (tools/snapshot-{create,delete,apply}-{matrix,
refusals}.sh and tools/snapshot-cli-parity.sh — seven
scripts, 241 assertions) are the live differential layer:
byte-identity against the host qemu-img from identical
inputs, run with make snapshot-harnesses (requires a built
instar and /dev/kvm; CI runs it in the functional-tests
workflow's snapshot-harnesses job). They overlap by design.
Adversarial Image Tests (test_adversarial.py)¶
Tests verifying that instar safely handles malicious and malformed images
without crashing, hanging, or consuming excessive resources. Uses the
run_adversarial() helper in base.py which enforces timeouts (hang
detection), memory limits via RLIMIT_AS (resource exhaustion), and signal
checks (crash detection).
Phase 1 — CVE-adjacent attacks: - Compression bombs: Zlib and ZSTD compressed QCOW2 images with extreme expansion ratios. Verifies decompression buffer bounds are enforced and output files stay small. - Circular backing chains: 2-level cycle (A→B→A), 3-level cycle (A→B→C→A), and self-referencing (A→A). Verifies chain discovery detects the cycle and rejects it. - Deep backing chains: Chains at 16 levels (device limit) and 17 levels (exceeds limit). Verifies depth enforcement and correct rejection. - Integer overflow: L1 table size near u32::MAX, L1 size = 0, cluster_bits below minimum (8) and above maximum (22). Verifies checked arithmetic prevents undefined behavior.
Phase 2 — boundary value cases: - Refcount order edges: refcount_order = 7 (128-bit, invalid) and 255 (extreme value). Verifies clamping or rejection. - Oversized virtual size: 1 petabyte and u64::MAX virtual sizes. Verifies info reports the size and check doesn't allocate based on virtual size alone. - VMDK grain size: Zero and huge (2^63) grain sizes. Verifies checked_mul prevents division by zero and overflow. - VHDX conflicting headers: Dual headers with different sequence numbers and valid CRC-32C checksums. - BAT beyond EOF: VHD and VHDX images with BAT entries pointing past end of file. Verifies I/O error handling.
Phase 3 — format confusion: - Polyglot files: QCOW2 magic header with VMDK descriptor body, and QCOW2 magic with ELF binary content. Verifies format detection works (magic wins) and structural validation catches inconsistencies. - Truncated headers: QCOW2 v2 header cut at 32 bytes, VMDK with only 8 bytes (magic + version), VHD footer at 48 bytes. Verifies all operations fail gracefully with no crash. - VMDK descriptor attacks: Null bytes in descriptor, multiple extent declarations, and inflated 1MB descriptor size claim. Verifies parser handles adversarial text safely.
Test images are generated by scripts in instar-testdata/scripts/ (the private
testdata repository). Scripts that generate adversarial or CVE-reproducer images
must always be placed in instar-testdata, never in the public instar repository.
All generated images live in instar-testdata/custom/audit/.
Security Tests (test_security.py)¶
Tests verifying instar's security properties: - Backing file references are detected but not followed - External data file references are reported but not read - VMDK descriptor extent paths are not accessed
Running Tests¶
Make Targets¶
# Create Python virtual environment (first time only)
make test-venv
# Run safe tests (default, suitable for development)
make test
# Run CI-suitable tests
make test-ci
# Run all tests including malicious images (explicit opt-in)
make test-malicious
# Run with verbose output (useful for debugging diffs)
make test-report
# Clean test artifacts
make clean-tests
Direct stestr Usage¶
cd tests
source .venv/bin/activate
# Run all tests
stestr run
# Run specific test module
stestr run test_info_safe
# Run with verbose output
stestr run --serial -- --verbose
# List available tests
stestr list
Running against an installed package (distro matrix)¶
The make targets above run the suite against the in-tree build
(src/target/release/instar). The distro-matrix CI instead runs it
against the installed .deb/.rpm, inside a target-distro
container, using that distro's own qemu-img as the differential
oracle. tools/test-package-functional.sh is the per-distro runner:
# Full suite, against the .deb installed on Debian 12 (qemu-img 7.2.x)
tools/test-package-functional.sh src/target/debian/instar_*.deb debian:12
# Fast subset, against the .rpm installed on Rocky 9 (proves the dnf path)
tools/test-package-functional.sh --smoke \
src/target/generate-rpm/instar-*.rpm rockylinux:9
# Dial concurrency down when several matrix containers share one KVM host
tools/test-package-functional.sh --concurrency 2 \
src/target/debian/instar_*.deb ubuntu:22.04
# Replay one failure on one distro without paying for the whole suite
tools/test-package-functional.sh --select 'test_convert\.' \
src/target/debian/instar_*.deb debian:13
To reproduce a CI matrix entry exactly as the merge queue runs it — same package resolution, same summary line — use the wrapper CI calls rather than the runner directly:
make package
PACKAGE_DIR=src/target/debian \
TESTDATA_PATH=../instar-testdata \
MATRIX_SELECT='test_map\.' \
tools/ci/run-matrix-entry.sh 'Debian 12' debian:12 deb
MATRIX_SELECT exists for exactly this and is never set in CI: it
makes the run partial, and a partial run must not be able to report as
a green matrix entry. The wrapper prints a warning whenever it is set.
Classify before you attribute. Running two matrix containers at
once — or any other heavy job on the host — starves the suite, and the
resulting failures do not look like resource problems. The large
test_convert re-encodes fail under load with
Error: "convert operation failed" and Content mismatch at offset 0!,
which reads as data corruption, and they abort early (~26s) rather than
timing out at the ~110s they need when passing. Nine such failures
appeared across the 2026-08-09 matrix inventory and every one passed
on an idle host. A failure counts as a divergence only once it
reproduces with the host otherwise quiet; --select gives you that
replay cheaply. Same discipline as the differential-fuzzing
spurious-divergence rule.
The design is tests from the source tree, binary from the package:
the whole tests/ tree is copied into the container and driven by
stestr, while the harness is pointed at the installed
/usr/bin/instar via INSTAR_BINARY_PATH (so the packaged binary and
its packaged guest binaries under /usr/lib/instar/ are what runs).
The container runs as root (package + prerequisite install need it) and
the repo is mounted read-only, so no .stestr/ artefacts leak into the
host worktree. test_info_malicious and test_bench are excluded from
the matrix run; --smoke restricts to a fast version/create/map/info
subset for local one-distro checks.
TESTDATA_PATH (default ../instar-testdata) must already be git-LFS
materialised — in CI that is tools/ci/prepare-testdata.sh; the script
canary-checks for pointer files and refuses to run against them. This
runner is distinct from tools/test-package-install.sh, which stays the
fast packaging smoke check (file layout, --help, info/create/map).
Because this exercises instar against older qemu-img versions than the dev host's, it is the first thing to surface output-format parity gaps that single-version CI cannot: any command whose qemu-img output changed across versions, where instar hard-codes the newest form, will diverge here. See "qemu-img version profiles and the distro matrix" below.
Pull requests versus the merge queue¶
functional-tests.yml runs two different sets of jobs depending on the
event, and the split is deliberate:
| Event | Jobs |
|---|---|
pull_request |
ci-tooling, build-and-test, package-smoke, the three integration-* jobs, snapshot-harnesses, oslo-crossval-master, automated_reviewer, can_enqueue — all against the in-tree build |
merge_group |
build-and-test, package-build, package-matrix (seven distros), can_merge |
workflow_dispatch |
everything except ci-tooling and can_merge — this is how you dry-run the matrix without enqueuing anything. Note automated_reviewer also skips here, because it needs ci-tooling and a skipped dependency skips its dependents; the two aggregates use always() precisely so they report anyway |
The merge queue does not re-run the PR integration jobs. That is a
coverage argument rather than a cost one: each matrix entry runs the
full Python suite against the installed package on its own distro,
against that distro's qemu-img, so the queue's coverage is a superset
of the PR jobs on seven distros instead of one. build-and-test is the
deliberate exception — it stays in both as the cheap fast-fail, and
package-build depends on it.
package-build builds one .deb and one .rpm and uploads them as a
single artifact that all seven entries consume. This works only because
the release binary is built on debian:bullseye (symbol floor
GLIBC_2.30), so one artifact set installs everywhere down to Rocky
9's glibc 2.34. If a matrix entry fails while installing the
package, that is a glibc-floor regression in the build image, not a
test failure.
can_enqueue and can_merge are the two aggregate gates, and they are
the names configured as required checks — never the individual jobs or
matrix entries, whose names change whenever the distro list does.
can_enqueue aggregates the pull-request jobs ("may this PR enter the
queue?"); can_merge aggregates the merge-queue jobs ("is this merge
group good?"). Both use always() plus an event test and a jq
expression asserting every dependency ended success or skipped,
because a required check that never reports leaves the queue waiting
forever. See development.md for the ruleset itself.
Each entry writes a row to the job summary naming the distro, its
live qemu-img version, and the test totals. The version is what
distinguishes "instar broke" from "instar diverges at this qemu version
boundary", so read it before attributing a red row.
Flake quarantine. Every matrix entry carries an explicit
allow_failure: false. An entry that fails twice consecutively for an
established environmental reason may be flipped to true with a linked
issue, so one flaky distro cannot block every merge; the flag comes off
when the issue closes. Note the sharp edge: continue-on-error makes
the job report success to the needs context, so a quarantined entry
stops gating merges entirely rather than merely tolerating its own
failure. Never leave a flag behind after its issue is fixed.
Because merge_group does not inherit the pull_request trigger's
paths: filter, this workflow always runs in the queue — including for
docs-only changes. That is deliberate (a required check that never runs
hangs the queue), at the cost of matrix latency on doc merges.
CI job layout and the partition guard¶
On a pull request the integration suite is split across several jobs
(in .github/workflows/functional-tests.yml) so the long-running
convert tests can run in parallel with everything else. Each job
selects a subset with stestr regex filters defined in the Makefile:
| Job | Make target | Selection |
|---|---|---|
integration-core |
test-container-core |
everything except test_convert., test_compare., test_info_malicious. (the catch-all) |
integration-convert-qcow2 |
test-container-convert-qcow2 |
test_convert./test_compare. minus Vhd |
integration-convert-vhd |
test-container-convert-vhd |
test_convert.TestConvert.*Vhd |
oslo-crossval-master |
(inline) | test_oslo_crossval re-run against oslo.utils master |
test_info_malicious.py is intentionally excluded from every PR job
and runs only via make test-malicious.
Because integration-core is exclude-based, it silently absorbs
any new test_*.py module — convenient, but it means a future
refactor (e.g. turning it into an include-list like the convert jobs)
could drop a whole module from CI with no test failure. The
class-level convert split has a subtler gap: a test_convert class
containing Vhd but not matching TestConvert.*Vhd would be excluded
by the qcow2 job and missed by the vhd job.
The ci-tooling CI job guards against both. It runs
tools/ci/check-test-partition.sh, which enumerates the suite with
stestr list, reads the actual job selectors from the Makefile and
workflow (no duplicated copy to drift), and fails if any test is run
by zero jobs. The only hand-maintained input is the allowlist of
intentional exclusions in tools/ci/check-test-partition.py
(currently just the malicious suite); each entry carries a documented
reason. Run it locally with:
Its own logic is unit-tested (stdlib only, no venv) via:
When you add a new integration job or change a test-container selector, the guard validates the new partition automatically; if it reports an orphan, either add the test to a job or (rarely) to the documented allowlist.
Test Image Manifest¶
Test images are defined in tests/manifest.json:
{
"id": "cirros-qcow2",
"path": "downloaded/cirros/cirros-0.6.3-x86_64-disk.img",
"format": "qcow2",
"safety": "safe",
"run_in_ci": true,
"description": "CirrOS minimal cloud image",
"tags": ["qcow2", "cloud-image"]
}
Manifest Fields¶
| Field | Description |
|---|---|
id |
Unique identifier for the test image |
path |
Path relative to testdata root |
format |
Expected disk format (qcow2, vmdk, vhd, etc.) |
safety |
safe, caution, or malicious |
run_in_ci |
Whether to include in CI test runs |
unsafe_quirks_required |
If true, requires --unsafe-quirks flag for qemu-img compatibility |
description |
Human-readable description |
tags |
Searchable tags for filtering |
expected_override |
Path to expected output file (for malicious images) |
Unsafe Quirks Testing¶
Images marked with unsafe_quirks_required: true do not have valid format
headers or partition tables. In default (secure) mode, instar rejects these
files as "unknown format" rather than accepting them as raw images.
To test qemu-img compatibility for these images, use --unsafe-quirks:
# Default mode: rejects files without valid structure
instar info random-garbage.raw
# Error: Unknown format (no valid disk image header or partition table)
# Unsafe quirks mode: matches qemu-img behavior
instar info --unsafe-quirks random-garbage.raw
# file format: raw
See configuration.md and quirks.md for details on safe vs unsafe quirks.
Test Data Location¶
Test images are stored in a separate repository (instar-testdata) to keep
the main repository small. The location is resolved in order:
INSTAR_TESTDATA_PATHenvironment variable../instar-testdata(sibling directory)
Binary fixtures are git-LFS backed¶
Every binary fixture in instar-testdata (*.qcow2, *.raw, *.vhd,
*.vhdx, *.vmdk, *-backing, the bundled qemu-img binaries) is tracked
with git-LFS. A checkout whose LFS objects have not been materialised
leaves each fixture on disk as a ~131-byte pointer file, and the whole suite
then fails with file format: unknown for every image — a failure that
looks like a mass instar regression but is really a testdata problem. If you
clone instar-testdata by hand, run git lfs pull after cloning; a working
tree with real images (the fixture starts with its true magic, e.g. QFI\xfb
for qcow2) rather than pointer text is required.
CI does this through tools/ci/prepare-testdata.sh — the single,
LFS-aware testdata-prep helper shared by functional-tests.yml,
coverage-fuzz.yml, and test-drift-fix.yml. It clones or updates the
cached checkout, runs git lfs pull, and then guards against the pointer
failure mode: it inspects known canary fixtures and hard-fails the job with
an explicit "infrastructure problem, not a test regression" message if any is
still a pointer, so a git-LFS outage can never be silently misattributed to
instar (see GitHub issue #451 for the incident this guards against).
Expected Output Overrides¶
For malicious images where running qemu-img would be dangerous, store the
expected output in tests/expected_outputs/:
Reference this file in the manifest:
{
"id": "qcow2-backing-passwd",
"expected_override": "expected_outputs/qcow2_backing_etc_passwd.txt"
}
Image Notes¶
The docs/image_notes/ directory documents which test images exposed
specific quirks or implementation details. When a test image reveals
unexpected qemu-img behavior that requires compatibility work, create a
markdown file documenting:
- The specific values that revealed the behavior
- How qemu-img handles the case
- How instar now handles it
- Links to relevant quirks documentation
See Image Notes for existing documentation.
Adding New Test Images¶
- Add the image to
instar-testdatarepository - Add entry to
tests/manifest.json - For safe images: add scenario to
test_info_safe.py - For malicious images:
- Create expected output file in
tests/expected_outputs/ - Add scenario to
test_info_malicious.py - If the image exposes new quirks: create
docs/image_notes/<image-id>.md
Output Comparison¶
The test suite performs exact string comparison. On failure, it shows:
- Unified diff with whitespace made visible
␣for trailing spaces→for tabs↵for trailing newlines- Raw repr() of both outputs for debugging
Before comparing, the harness normalises the fields that legitimately vary between runs rather than between qemu versions:
- Disk size / actual-size is substituted from the live filesystem
(
st_blocks * 512,helpers/comparators.get_disk_size), because it reflects the current file's allocation, not a format decision. instar computes it the same way, so it always matches. - vmdk
cid/parent-cid, the dirty flag, and vhdx log-size are stripped byassert_info_equivalent— a vmdk content-ID is a random nonce written at creation, not a stable output.
This matters for the version matrix: two adjacent baseline profiles often differ only in these normalised fields (see below), so a profile mismatch is invisible to the assertions until a genuinely version-gated field changes.
qemu-img version profiles and the distro matrix¶
instar emulates the qemu-img build installed on the host so its output
is byte-identical to the real tool. Two mechanisms cooperate:
Runtime model (src/vmm/src/version.rs). instar runs
qemu-img --version, parses major.minor.patch, and derives just two
booleans: include_child_node (qemu ≥ 8.0 adds the Child node '/file'
section) and include_dirty_flag (qemu ≥ 6.1 exposes the dirty flag).
If qemu-img is absent it falls back to the newest profile. --qemu-version
overrides detection.
Baseline profiles (instar-testdata). Because qemu changed some output
within stable series, expected-outputs/<command>/version-map.json
records several empirical profiles (e.g. profile-6-1-0, profile-7-2-19,
profile-8-0-0, profile-8-1-0, profile-10-0-0, profile-10-2-0) and a
version_to_profile map from full version strings to profile names.
Selecting a profile for the host qemu. base.py
get_profile_for_installed_qemu / _pick_baseline_version_dir resolve
the host version against the map with full major.minor.patch matching
(_select_version_match): exact match, else the highest enumerated
version ≤ the host within the same major.minor, else the highest
version ≤ the host overall. A {major}.{minor}. prefix match (the
previous behaviour) could not tell 7.2.0 (profile-6-1-0) from 7.2.22
(profile-7-2-19) and mis-selected on any 7.2.19+ host such as Debian 12.
The single-qemu-version CI never exposed this; the distro matrix does.
Portable vs version-specific tests. test_info_safe and the
--qemu-version baseline suites (create/resize/amend/commit/bitmap)
drive instar with an explicit version per profile and compare to the
stored baseline — they are qemu-version-independent and pass identically
on every distro. The live-oracle suites (test_convert, test_compare,
test_dd, test_check_*, test_map, test_measure,
test_oslo_crossval) use the host's real qemu-img as the differential
oracle and pick their comparison profile from the detected version, so
they are the ones the matrix actually exercises.
The CI matrix (tools/probe-qemu-versions.sh). The qemu-img
version each matrix distro ships, and the profile it selects:
| Distro | qemu-img --version |
Profile |
|---|---|---|
| Debian 12 (bookworm) | 7.2.22 | profile-7-2-19 |
| Debian 13 (trixie) | 10.0.11 | profile-10-0-0 |
| Ubuntu 22.04 (jammy) | 6.2.0 | profile-6-1-0 |
| Ubuntu 24.04 (noble) | 8.2.2 | profile-8-0-0 |
| Fedora latest | 10.2.2 | profile-10-2-0 |
| Rocky/RHEL 9 | 10.1.0 | profile-10-0-0 |
| Rocky/RHEL 10 | 10.1.0 | profile-10-0-0 |
Run tools/probe-qemu-versions.sh to refresh this table (it pulls each
image and prints its qemu-img --version). Rocky 10 is pulled from the
rockylinux/rockylinux org repo — the Docker Official rockylinux
library stops at 9. Regression tests pinning the parser and the selection
rule against these exact strings live in tests/test_version_detection.py
and src/vmm/src/version.rs.
Profile selection is not the whole story. Debian 12's 7.2.22 is the
only distro whose major.minor (7.2) transitions profile mid-series (at
7.2.19), so it is the only one the full-version fix re-points, and the
profile-6-1-0 vs profile-7-2-19 baselines differ only in normalised
fields (disk size, vmdk cid). Running the live-oracle suites against
every distro's real qemu-img (via
tools/test-package-functional.sh) then found divergences the baseline
comparison never could — see below.
Known divergences (found 2026-08-09, fixed in phase 2b 2026-08-10). instar used to hard-code its newest-qemu behaviour for three things, so it diverged on every distro shipping an older qemu. All three are now version-gated:
| Divergence | Affected distros | Boundary | Status |
|---|---|---|---|
map --output=json emitted compressed unconditionally |
Debian 12 (7.2.22), Ubuntu 22.04 (6.2.0) — 19 tests each | 8.2.0 (absent at 8.1.5) | Fixed: gated on include_map_compressed |
map --output=json emitted present unconditionally |
none in the matrix (oldest is 6.2.0) | 6.1.0 (absent at 6.0.1) | Fixed: gated on include_map_present |
snapshot -l used the newer column layout unconditionally |
Debian 12, Ubuntu 22.04, Ubuntu 24.04 (8.2.2) | 9.0.0 (8.2.2 is old-form) | Fixed: gated on snapshot_underscored_columns |
| Every VHD instar wrote declared a size its CHS geometry did not cover, so qemu < 10.0 read it short and truncated the tail | Debian 12, Ubuntu 22.04, Ubuntu 24.04, RHEL 9 | reader default changed at 10.0.0 | Fixed: the writer now stamps creator app qem2 |
The third was not an output-format divergence at all but silent data
loss in the VHD writer, found because the qcow1→vpc test compares a
flattened round-trip. See docs/quirks.md ("VHD Virtual Size
Calculation") and phase 2b's plan.
One divergence remains documented rather than fixed: instar applies the qemu 10.0+ VHD size rule to VHDs with an unrecognised creator app, where a pre-10.0 qemu-img would use the CHS product. It needs an image whose creator app is outside the known table and whose CHS disagrees with its disk_size, and no such image exists in the corpus. The rule is evaluated guest-side, so gating it would mean widening the guest ABI.
How the boundaries were measured. instar-testdata ships 80 static
per-version qemu-img builds at qemu-img-binaries/x86_64/<version>/,
covering 6.0.0 to 10.2.0. Run the real binary rather than reasoning
from the version map or from qemu source — two of the three boundaries
above were initially recorded wrongly from indirect evidence:
TD=../instar-testdata
$TD/qemu-img-binaries/x86_64/8.1.5/qemu-img map --output=json image.qcow2
$TD/qemu-img-binaries/x86_64/9.0.0/qemu-img snapshot -l image.qcow2
Both map and snapshot accept --qemu-version (as info already
did), so both sides of every boundary are exercisable on the dev host
rather than only inside the matrix.
Cross-profile baseline tests. The ordinary baseline tests compare
only against the profile matching the installed qemu-img, so a
single-version host checks exactly one side of every boundary — which
is how the compressed divergence survived to the distro matrix.
TestMapCrossProfile drives --qemu-version for every profile the
version map declares and compares against that profile's own
baselines, so a new boundary is caught wherever the suite runs. It
immediately found one the matrix could not: map --output=json gained
present in 6.1.0, and no matrix distro is old enough to notice.
qemu capability is not the same as qemu version. Distro qemu builds
do not all carry the same block drivers, and the version-profile model
says nothing about this. RHEL-family qemu-kvm (Rocky/RHEL 9 and 10) is
built without the qed, qcow (qcow1), parallels, dmg, bochs
and cloop drivers that Debian's qemu-utils carries — so on those
distros qemu-img cannot act as the differential oracle for those formats
at all. Tests that need such an oracle call
skip_unless_qemu_supports(fmt) (tests/base.py), which probes the
driver by opening a nonexistent file with an explicit format and looking
for Unknown driver; tests that exercise instar alone (the check-refusal
and adversarial suites) keep running there. Detect capability this way
rather than from qemu-img --help, whose "Supported formats:" line qemu
10.x no longer prints.
A truncated run must never look green. subunit v2 refuses to write a
packet larger than ~4MB, so a failing assertion that renders a
multi-megabyte image buffer raises ValueError: Length too long and
kills the stestr worker. Every test still queued on that worker silently
never runs, and stestr exits 0 if nothing else failed — a partial run
that reports as a pass (one Rocky run "passed" having executed 454 of
3253 tests). Two defences: compare image buffers with
assert_bytes_identical (tests/base.py), which reports sizes and the
first differing offset rather than the buffers, and
tools/test-package-functional.sh, which fails the run outright on the
crash marker, on any worker reporting N/A elapsed time, or on a
full-suite test count far below the expected ~3250.
Environment Variables¶
| Variable | Description |
|---|---|
INSTAR_TESTDATA_PATH |
Override default testdata location |
INSTAR_BINARY_PATH |
Override default instar binary location |
Differential Fuzzing¶
The project includes a differential fuzzer (scripts/differential-fuzz.py)
that compares instar against qemu-img on randomly generated images. This is
Phase 3 of the security audit plan (PLAN-audit.md).
How it works¶
For each iteration the fuzzer:
- Picks a random seed (logged for reproducibility).
- Generates a random disk image with
qemu-img create, varying format (qcow2, raw, vmdk, vpc), virtual size (1M-1G), cluster size, compression, and data patterns (zeros, random, sparse, MBR). - Creates separate copies for instar and qemu-img.
- Runs a random chain of 2-4 operations (info, check, convert, compressed convert, create, measure, resize, rebase, commit, map, snapshot) against both tools.
- Compares outputs at each stage: exit codes, normalized JSON info output, and converted file content (SHA-256 of raw-flattened output).
The op_snapshot arm (phase 13 of PLAN-snapshot) generates a
fresh qcow2, then applies an identical random chain of
snapshot -c / -d / -a and qemu-io write elements to the
instar and qemu-img copies, asserting byte-identity of the
whole image after every chain element (the qemu side runs with
file.discard=ignore; dates and dead padding bytes are
normalized per docs/quirks.md). Its first runs surfaced a real
multibyte list-padding bug and delete's missing surviving-L2
COPIED refresh, both since fixed.
The op_repair arm (phase 10 of PLAN-check-repair) builds a fresh
qcow2 with known data, injects one random qcow2 corruption
(refcount-zero / too-high / leaked / stale-copied / overlapping),
forks the corrupt file to two byte-identical copies, and repairs
one with instar check --repair and the other with qemu-img check
-r at a random tier. A three-tier oracle asserts: safety
(unconditional — instar must never produce check-errors or raise
the corruption/leak count above the original, even when it
refuses); convergence (all tier only, where the two tools
have matching scope — when instar claims a complete repair via
repair-incomplete == false, the image must be qemu-img
check-clean); and data equivalence (when both reach clean, the
raw-flattened guest data must match). instar's deliberate
refuse/partial behaviour is recorded as conservative, never a
divergence; the leaks tier is excluded from convergence because it
is intentionally narrower than qemu-img -r leaks (see
quirks.md).
The op_commit, op_rebase and op_bench arms draw a snapshot flag
(40% probability, phase 8 of PLAN-qcow2-write-infrastructure) and, when
qemu-io is present, build a snapshot-bearing fixture that
exercises the copy-on-write paths phase 7 opened — commit over a
backing- or overlay-snapshot span, safe rebase of a snapshot-bearing
overlay, and bench -w over a snapshot-shared span. For those fixtures
the oracle gains the phase-7 read-back triple: active-view qemu-img
compare identical + qemu-img check clean (with a refcount=1
reference=2 scan) + per-carrier snapshot read-back instar == qemu
twin (via tests/helpers/snapshot_readback.py), the last of which the
active-view compare alone cannot see. Non-snapshot fixtures keep their
existing oracle. This folds in and retires the standalone
scripts/cow-soak.py soak (phase 7e); a 300-iteration local soak ran
0 divergences.
Known quirks (see quirks.md) are excluded from comparison: disk size fields and format-specific metadata.
libyal cross-validation¶
When libyal tools are installed in the environment (libvmdk-utils,
libvhdi-utils, libqcow-utils), the fuzzer adds two additional
comparison layers:
- Info cross-check: Parsed fields from
vmdkinfo,vhdiinfo, andqcowinfo(virtual size, format version, cluster size, etc.) are compared against instar's JSON output for the same image. - Parse-success consistency: For each format, if the libyal tool successfully parses the image, instar check should report no errors (and vice versa). Disagreements are flagged as divergences.
This closes the gap where VMDK/VHD/VHDX had no differential reference
for check validation (qemu-img check only supports QCOW2), and
provides a third independent opinion for QCOW2. libyal tools are
optional — the fuzzer degrades gracefully when they are unavailable.
Running locally¶
python3 scripts/differential-fuzz.py \
--instar src/target/release/instar \
--iterations 100 \
--seed 42 \
--log-dir ./fuzz-logs
CI integration¶
The fuzzer runs automatically via .github/workflows/differential-fuzz.yml
at three tiers:
| Trigger | Iterations | When |
|---|---|---|
pull_request |
100 | PR changes fuzzer script or workflow |
push to develop |
200 | Post-merge smoke test |
schedule |
1000 | Nightly at 02:00 UTC |
workflow_dispatch |
configurable | Manual trigger |
On failure, the workflow uploads logs as artifacts and auto-files GitHub
Issues with the security-audit label, including the seed, iteration,
image attributes, and a reproduction command.
Reproducing a divergence¶
Each divergence report includes the seed and iteration number. To reproduce:
python3 scripts/differential-fuzz.py \
--instar src/target/release/instar \
--iterations <ITERATION + 1> \
--seed <SEED> \
--fail-fast
Phase 6: Coverage-Guided Fuzzing¶
Coverage-guided fuzzing uses cargo-fuzz (libFuzzer) to exercise the
no_std parser crates directly, without the full VMM/KVM stack. A
mock CallTable backed by fuzzer input provides the I/O layer,
allowing libFuzzer to explore malformed input space that differential
fuzzing (Phase 3) cannot reach.
Fuzz targets¶
40 targets across the parser and planner crates, organized in
src/fuzz/:
| Target | Crate | Type |
|---|---|---|
fuzz_format_detect |
shared | Buffer-based |
fuzz_qcow2_header |
qcow2 | Buffer-based |
fuzz_qcow2_l1l2 |
qcow2 | CallTable |
fuzz_qcow2_refcount |
qcow2 | CallTable |
fuzz_qcow2_decompress |
qcow2 | CallTable |
fuzz_vmdk_header |
vmdk | Buffer-based |
fuzz_vmdk_grain |
vmdk | CallTable |
fuzz_vhd_footer |
vhd | Buffer-based |
fuzz_vhd_bat |
vhd | CallTable |
fuzz_vhdx_header |
vhdx | Buffer-based |
fuzz_vhdx_metadata |
vhdx | CallTable |
fuzz_raw_partition |
raw | Buffer-based |
fuzz_luks_header |
luks | Buffer-based |
fuzz_vdi_header |
vdi | Buffer-based |
fuzz_vdi_bat |
vdi | CallTable |
fuzz_parallels_header |
parallels | Buffer-based |
fuzz_parallels_bat |
parallels | CallTable |
fuzz_qcow1_header |
qcow1 | Buffer-based |
fuzz_qcow1_table |
qcow1 | CallTable |
fuzz_dmg_table |
dmg | CallTable |
fuzz_dmg_chunk |
dmg | CallTable |
fuzz_measure_calc |
measure | Buffer-based |
fuzz_measure_scan |
all parsers | CallTable |
fuzz_create_emitters |
create | Buffer-based |
fuzz_resize_planners |
resize | Buffer-based |
fuzz_rebase_planners |
rebase | Buffer-based |
fuzz_commit_planners |
commit | Buffer-based |
fuzz_amend_planners |
amend | Buffer-based |
fuzz_map_iter |
all parsers | CallTable |
fuzz_snapshot_parse |
qcow2 | CallTable |
fuzz_snapshot_refcount |
snapshot | Buffer-based |
fuzz_check_repair |
check | Buffer-based |
fuzz_dd_window |
dd | Buffer-based |
fuzz_chs_rounded_size |
dd | Buffer-based |
fuzz_dd_read |
dd | CallTable |
fuzz_bitmap_parse |
qcow2 | CallTable |
fuzz_bitmap_planners |
bitmap | Buffer-based |
fuzz_bench_schedule |
bench | Buffer-based |
fuzz_qcow2_write |
qcow2-write | Sim harness |
fuzz_qcow2_write_growth |
qcow2-write | Buffer-based |
Buffer-based targets call parser functions that take &[u8]
directly (e.g. QcowHeader::parse(data)). CallTable targets
use the mock CallTable from src/fuzz/src/lib.rs to simulate
sector-based I/O from the fuzzer input. Sim harness targets
(only fuzz_qcow2_write) drive a crate's own Vec-backed simulation
harness — here crates/qcow2-write's sim module, feature-gated
#[cfg(any(test, feature = "sim"))] and OFF in the production build.
The two snapshot targets (phase 12 of PLAN-snapshot) cover the
streaming snapshot-table parser (fuzz_snapshot_parse drives
for_each_snapshot_entry and the planner converter against
adversarial qcow2 fragments) and the mutator primitives
(fuzz_snapshot_refcount dispatches one of seven ops per exec —
refcount increment/decrement/swap, the COPIED-flag walker, the
contiguous allocator, the flag helpers, and the
table-serialisation round-trip — asserting semantic invariants
such as precheck-never-mutates, inc/dec byte-identity, and
allocator containment).
fuzz_check_repair (phase 9 of PLAN-check-repair) dispatches one
of four crates/check repair planners per exec — leak
reclamation, count accumulation, refcount correction, and COPIED
reconciliation — asserting sub-byte-masked containment (co-resident
refcount entries in a shared byte are preserved at widths 1/2/4),
raised/lowered/freed tally correctness, the overflow→AmbiguousCorruption
and bounds→MisalignedAccess error classifications, idempotence,
and the "correct generalises reclaim" cross-check. The COPIED
walker's deep invariants are delegated to fuzz_snapshot_refcount.
The two crates/qcow2-write targets (phase 8 of
PLAN-qcow2-write-infrastructure) fuzz the write planner rather than a
parser. fuzz_qcow2_write decodes a fixture archetype (clean /
backing-present / shared-data / shared-L2 nested / owned-L2 /
zero-flag-target) at a cluster size {512, 4 KiB, 64 KiB, 2 MiB} and
runs a bounded plan_write / plan_flush sequence through the sim
harness, asserting the copy-on-write invariant oracle after every
operation: max_rc < 3 (the COW corruption signature — the single
most important invariant), snapshot-shared clusters byte-preserved and
never freed, no dangling / past-EOF L1/L2 pointer, and — after a flush
— OFLAG_COPIED set iff refcount is exactly 1. A WriteError refusal
is a valid outcome, not a crash. fuzz_qcow2_write_growth feeds
geometry to the growth module's plan_refcount_growth, asserting no
overflow, the self-coverage invariant, and cap adherence. Both are
registered in the fast tier (tools/ci/fuzz-tier.sh); their bring-up
shake-out found no planner bug. (Note: plan_refcount_growth has a
debug-only non-convergence debug_assert! guard that fires only in
debug builds on out-of-envelope petabyte-scale geometry; in release it
returns GrowthOverflow gracefully, and production only passes bounded
geometry — softening that guard to always return overflow is a recorded
follow-up, not a bug.)
Running locally¶
# Inside the instar-build container:
cd src/fuzz
# Run a single target for 60 seconds
cargo fuzz run fuzz_qcow2_header -- -max_total_time=60
# Run with specific corpus
cargo fuzz run fuzz_qcow2_header corpus/fuzz_qcow2_header/
# Minimize a crash
cargo fuzz tmin fuzz_qcow2_header artifacts/fuzz_qcow2_header/<crash>
# Generate coverage report
cargo fuzz coverage fuzz_qcow2_header
Corpus seeding¶
The seed corpus is extracted from instar-testdata using:
This copies test images into per-target corpus directories under
src/fuzz/corpus/, filtered by format. Header-only targets receive
truncated copies. Hand-crafted minimal valid inputs are also generated
for each format.
It additionally restores the accumulated corpus pushed by prior
nightly runs. Each nightly run pushes coverage-increasing inputs to
instar-testdata/custom/fuzz-corpus/<target>/; on the next run those
entries are copied straight back into corpus/<target>/ by target
name (restore_pushed_corpus()), so coverage compounds across runs.
This matters most for the targets whose inputs are not recognizable
image formats — the window-math, CHS-geometry and planner fuzzers —
which would otherwise re-seed cold every night because format-based
routing cannot place their entries. Entries are content-addressed, so
restoration is idempotent.
CI integration¶
The CI workflow (.github/workflows/coverage-fuzz.yml) runs:
- Nightly at 04:00 UTC, all targets, with tiered per-target
durations (see below).
- PR validation: single-target smoke test when fuzz/parser code
changes.
- Post-merge (push to develop): 15s per target.
- Manual dispatch: configurable duration and target selection.
Tiered nightly durations¶
The nightly run has a fixed wall-clock budget (450 min, inside the
480 min job timeout). Rather than splitting it evenly, the run plan is
computed by tools/ci/fuzz-tier.sh: the fast-saturating targets (pure
window math, CHS rounding, and the planner/emitter crates) take a short
fixed slice (300s — they reach steady coverage in well under a minute),
and the deep parser/format targets split the remainder. With the
current 27 targets that gives the 17 deep targets ~24 min each versus
~17 min under an even split.
This is one of two levers for keeping per-target time useful as the
target count grows; the other is corpus persistence (see Corpus
seeding). When the deep-tier share computed by fuzz-tier.sh falls
to the fast-tier floor (~300s), stop cutting time and shard the targets
across multiple CI jobs instead. Sharding only adds real throughput
if the self-hosted runner pool has spare physical cores during the
nightly window, since each libFuzzer target pins a core — confirm core
availability before adding jobs.
Crash reporting¶
Crashes are minimized with cargo fuzz tmin and filed as GitHub
Issues with the security-audit label immediately when found, by
tools/ci/report-fuzz-crash.sh. New corpus entries are pushed to
instar-testdata/custom/fuzz-corpus/ after nightly runs, and restored
by target name on the next run so coverage compounds.
These rules make that reporting safe, most of them learned from a month of silently broken nightlies:
- The log excerpt is bounded in bytes, and never travels on a command
line.
cargo fuzzprints the failing input as astd::fmt::Debugdump on a single line, so with-max_len=4194304one crash can produce a 370KB log line. The excerpt is therefore clipped per line (cut -b), then per line count, then per byte, scrubbed to valid UTF-8, and handed tojqwith--rawfile. The original code passed atail -30throughjq --arg, and an 81KB crash input infuzz_rebase_plannersmade that a 371KB argv entry — over Linux's 128KiBMAX_ARG_STRLEN, sojqexited with "Argument list too long". - A reporting failure is never fatal to the run. The fuzz step runs
under
bash -e, so thatjqfailure aborted the whole step at the first crash: 21 of the 40 targets were never fuzzed, no issue was filed, and the corpus push was skipped — every night from 2026-07-16 to 2026-08-11. Reporting failures are now counted and warned about per target, and the loop continues to the remaining targets. The failure is raised by a finalFail on unreported crashesstep that runs withif: always()after the corpus push and the log upload, so an unreportable crash still turns the run red without costing the night's corpus — and without hiding the artifacts that are the only remaining way for that crash to reach a human. - Anything read out of a fuzz log is treated as hostile bytes. Both
the excerpt and the signature are NUL-stripped, length-bounded and
re-encoded with
iconv -c, because a panic message can itself carry fuzz data andjq— like the GitHub API — rejects invalid UTF-8. Log scraping usesgrep -a: without itgrepdecides a log containing raw mutated bytes is binary, prints nothing, and every such crash silently degrades to a signature ofunknown crash. - The excerpt is windowed on the crash, not on the end of the log.
A real
cargo fuzz runfailure prints the panic, then ~30 stack frames, theSUMMARY, the artifact path, the Debug dump and cargo-fuzz's reproduction block. In the 379KB log that motivated this change the panic is line 30 of 91, so atail -n 30window starts at line 62 and contains no panic line and no panic message at all. The window is five lines before the firstpanicked at/SUMMARY:and 25 after, trimmed from the end so the panic survives the byte cap; with no such line to anchor on — a build failure, a truncated log — it falls back to the tail. - The same crash is not refiled every night. Since the run no longer
stops at the first crash, a recurring crash would otherwise file one
issue per target per night, and
fuzz-autofix.ymlonly drains one issue per day. An opensecurity-auditissue whose title matches the target and whose body carries the samededup_keygets a comment instead of a duplicate. A lookup that fails falls through to filing, because a duplicate issue is a much smaller problem than an unreported crash;--no-dedupforces that behaviour by hand.
The signature is the first panicked at/SUMMARY: line and the line
after it: Rust prints the location and the message separately, and
panicked at fuzz_rebase_planners.rs:278:17 on its own does not
identify a crash.
Dedup matches on dedup_key rather than on that signature, because a
panic message interpolates the fuzz-derived values that provoked it —
the real crash behind this change reads Write patch 0
(72057594037927944..72057594037928200) exceeds total_file_size
(281076066929798). Two inputs hitting one assertion produce two
different signatures, so exact matching would file a fresh issue every
night for a single bug. The key is the location plus the
message with standalone numbers collapsed to N. Digits inside
identifiers are left alone, so qcow2 and qcow3 do not merge: a
duplicate issue is only noise, whereas two different bugs sharing one
issue loses a crash. For the same reason the file:line:col is never
normalized — two assertion sites in one file stay two issues — but the
thread id in thread '<unnamed>' (47) panicked at … is dropped, since
it varies run to run.
Not every fuzz failure is a Rust panic. An OOM, a timeout or a deadly
signal anchors on libFuzzer's SUMMARY: line instead, and the line
after that is the MS: mutation line, which ends in a per-input
base unit: <hex>. Hex survives digit collapsing, so for a
SUMMARY-anchored failure the key is the SUMMARY: line alone;
including the MS: line would give every recurring OOM a new key and a
new issue every night. Issues filed before dedup_key existed are
still matched on their signature.
Run the reporter by hand against a downloaded coverage-fuzz-logs
artifact to check what an issue would say, without filing anything:
tools/ci/report-fuzz-crash.sh fuzz_rebase_planners \
src/fuzz/artifacts/fuzz_rebase_planners/crash-<hash> \
coverage-fuzz-logs/fuzz_rebase_planners.log --dry-run
tools/ci/test-report-fuzz-crash.sh exercises the reporter against
synthetic logs — the 370KB single line, raw mutated bytes, a missing
log, a missing crash file, and the dedup decisions with a stubbed gh
— and asserts the emitted body still satisfies the field predicate
fuzz-autofix.yml validates against. Its fixture reproduces the
layout of a real libFuzzer log, not just its content, because an
earlier ten-line fixture let a tail-anchored excerpt look correct while
on a real log it captured 28 stack frames and no panic.
Both suites run on pull requests in the ci-tooling job, and need only
bash and jq:
Choosing which artifact to report¶
tools/ci/pick-fuzz-artifact.sh decides which file in
src/fuzz/artifacts/<target>/ is the reproducer. It used to be inline
YAML in the workflow, which is where two of the three bugs behind the
broken month were hiding, so it is a script with tests now.
Do not pass -max_len to cargo fuzz tmin: it supplies its own, and a
second one trips libFuzzer's assert(MaxInputLen == 0), so
minimization fails and leaves a 0-byte minimized-from-* artifact
behind — which an unsorted find | head -1 would then happily report as
the reproducer for a later crash. The picker therefore skips
minimized-from-* when choosing what to minimize, and afterwards is
asked separately for the non-empty minimized-from-* that tmin
produced; without that second step the issue would quote the size of,
and the reproducer point at, the original large artifact, and
minimization would cost CI time without improving anything.
The empty-file filter applies only to minimized-from-*, where an
empty file means tmin failed. A 0-byte crash-* is a real crash —
libFuzzer writes one when a target panics on the empty input — and
filtering it out would send the workflow down its no-artifact branch,
filing nothing and telling the reader to go and check the build.
Artifacts are taken in an explicit order of preference — crash-,
oom-, leak-, timeout-, slow-unit-, then anything else — rather
than by sorting the names. Lexicographic order happens to put crashes
first, but it also puts slow-unit- ahead of timeout-, and libFuzzer
writes a slow-unit- file for any input over 10s (its default
-report_slow_units). Since these targets run against 4MB inputs, a
slow unit can easily be sitting in the directory when a real timeout
arrives, and the reported reproducer would then be the wrong file.
Automated bug fixes¶
The CI workflow (.github/workflows/fuzz-autofix.yml) runs daily
at 06:00 UTC and picks up open security-audit issues. It invokes
Claude Code (30-turn limit) to diagnose and fix the crash, then
verifies the fix by rebuilding and running core tests. Two attempts
per issue; failed issues are labelled autofix-failed for human
attention. Complexity guardrails prevent runaway fixes (max 3 files,
no cross-crate changes, no new dependencies).
Related Documentation¶
- Format Coverage - Comparison with oslo.utils format_inspector
- Format Detection Safety - Security model for format auto-detection
- Security Analysis - CVE analysis and threat model