mkit ships a small number of bounded property tests that exercise the
binary parsers from adversarial inputs. They live in the mkit-fuzz
crate at rust/fuzz/ and run in two modes:
cd rust && cargo test --manifest-path fuzz/Cargo.toml— plain unit-test harness over the target bodies; stable Rust, no extra tooling. CI runs this.cd rust/fuzz && cargo +nightly fuzz run <target>— libfuzzer-sys harness for coverage-guided runs. Same target bodies, same guardrails.
The property-test approach is deliberate: an earlier attempt that ran inputs through a page-backed allocator ballooned to hundreds of GiB on a pathological corpus. Everything in this document exists to prevent that from recurring.
| Target file | Target function |
|---|---|
fuzz_targets/delta.rs |
delta::decode |
fuzz_targets/pack.rs |
pack::PackReader::read |
fuzz_targets/tree.rs |
serialize::deserialize |
fuzz_targets/software_key_record.rs |
EncryptedKeyRecord::decode |
fuzz_targets/git_commit_parse.rs |
mkit-git-bridge gitparse::parse_commit (untrusted upstream bytes, SPEC-GIT-IMPORT §2) |
fuzz_targets/git_tag_parse.rs |
mkit-git-bridge gitparse::parse_tag |
fuzz_targets/git_tree_parse.rs |
mkit-git-bridge gitparse::parse_tree plus map_mode |
fuzz_targets/rpc_decode.rs |
SignerFrame / SshFrame wire decode (never panics) plus Arbitrary-driven encode/decode roundtrip |
fuzz_targets/sparse_verify.rs |
sparse::build_sparse / sparse::verify_sparse (never panics on adversarial manifest/proof bytes) |
Targets that exercise crate-private parser surfaces should expose a minimal
#[cfg(feature = "fuzzing")] wrapper from that crate and enable the feature in
rust/fuzz/Cargo.toml, as software_key_record does for mkit-keystore.
restore(symlink resolution) — the file-system side-effect surface made it too easy to accidentally create symlink cycles. Revisit once a virtual-FS shim exists to sandbox the target.- SSH / URL parser wire decoding — covered by crate-level unit tests for now.
All targets share the same base invariants:
- No panic.
- No out-of-memory that is not an explicit
Vec::with_capacity-caught allocation failure at the parser boundary. - Every iteration completes in under 100 ms of wall-clock.
Target-specific invariants:
- Packfile: declared entry count plus declared entry lengths are bounds-checked against the actual input length before the parser allocates the entries vector. The most important regression case is "pack header claims count = 9 999 999, body is 0 bytes": the parser must reject before pre-allocating any large structure.
- Tree: every
TreeEntry.nameaccepted by the parser is non-empty, contains no path separators and no NUL bytes, is not.or.., and is at most 255 bytes. Every mode is one of the defined enumerated values. - Delta: a
COPYinstruction'soffset + lengthstays within the base slice; a truncatedCOPYheader orINSERTliteral producesDeltaCorrupt; opcode0x00is always rejected.
cd rust
cargo test --manifest-path fuzz/Cargo.tomlThat runs the target bodies as ordinary unit tests with a seeded PRNG — ~30 cases total, each wall-clock capped so a regression that introduces an accidental loop aborts rather than hanging CI.
For coverage-guided runs (nightly toolchain required):
cd rust/fuzz
cargo +nightly fuzz run delta
cargo +nightly fuzz run pack
cargo +nightly fuzz run tree
cargo +nightly fuzz run software_key_record
cargo +nightly fuzz run rpc_decodeThe default in-process harness is a bespoke splitmix64 loop
(run_iterated_unit in src/lib.rs). The rpc_decode target's unit
test instead drives the shared body through
minifuzz — the same
in-process property-test harness upstream commonware uses for its own
in-tree tests. It is a mutational fuzzer (smarter than uniform-random
bytes) yet still a plain #[test] on stable, and on failure prints a
MINIFUZZ_BRANCH = 0x... token that replays the exact case via
Builder::default().with_reproduce("0x...").
commonware-invariants is a dev-dependency only (pinned to the
commonware 2026.5.x train); the libfuzzer binaries never link it. The
same six guardrails apply: with_search_limit(MAX_ITER) caps iterations,
with_seed(RNG_SEED) keeps the run deterministic, and the body still
truncates to MAX_INPUT and runs under the per-iteration wall-clock cap.
This is a pilot — the other targets stay on the splitmix loop until the
pattern proves out (commonware-invariants is alpha upstream).
Every target body must satisfy all six:
- At most 100 iterations per invocation.
- Every iteration's input is at most 64 KiB.
- Bounded allocations. Input cap (2) plus per-op output caps inside
mkit-corekeep the worst-case heap under ~1 MiB per iteration:delta::decodecaps its initialVec::with_capacityatresult_len.min(base.len() + 2 * stream.len())so a 9-byte stream claimingresult_len = u32::MAXcannot pre-allocate 4 GiB.pack::PackReaderenforcesMAX_ENTRIES = 10_000_000andMAX_PAYLOAD = 4 GiBwith count × entry-frame-length lower-bound checks against the input slice before anyVec::with_capacity.software_key_recordtruncates each input to 64 KiB before callingEncryptedKeyRecord::decode. The decoder's cursor checks every length-prefixed field against the remaining input before copying, so allocation is bounded by bytes already present in the capped input rather than by untrusted declared lengths.- The harness adds defensive pre-checks
(
claimed_result_len > MAX_INPUT * 4,claimed_entries > 100_000) that short-circuit before calling into core, so the most pathological inputs don't even reach the parsers.
- Every iteration is timed; if it takes more than 100 ms the test aborts the remainder.
- No
loop {}orwhile true {}without a bounded iteration counter. - Seeded deterministic PRNG. Inputs are synthesized from a fixed
u64seed (RNG_SEED) so any failure reproduces exactly. Most targets use a splitmix64 PRNG (run_iterated_unit); therpc_decodepilot drives the body throughminifuzz, whose ChaCha8 sampler is seeded with the same constant viawith_seed— also deterministic — plus a splitmix64 large-input sweep so the 8 KiB–64 KiB range stays covered (see the In-process minifuzz section).
- PRNG-driven coverage is shallow — 100 iterations of random bytes
will not find bugs that require deeply structured input. The fixed
seed cases exist to cover the structurally interesting shapes
(oversize counts, truncated headers, invalid modes, etc.) that
random bytes rarely hit. Coverage-guided
cargo fuzzruns fill in the rest. - Fuzz outputs are only checked for "no panic, no runaway". This does not diff against a reference decoder.
Use this checklist before landing a new target:
- Pick a parser with a clear input → output contract.
- Add the target body to
rust/fuzz/src/lib.rsas apub fn run_<name>(rng_seed: u64)that enforces the six guardrails. - Add a libfuzzer shim under
fuzz_targets/<name>.rscalling the same body. - Add a
#[test]shim that invokes the body with fixed seeds. - Add at least three hand-crafted fixed-seed cases: the happy path, one structural malformation, and one bounds-stress case (oversize-count / oversize-length style).
If you cannot satisfy the guardrails, do not land the target — leave a note in this file explaining why it was deferred.