Property and fuzz testing
Three tiers, each for a different shape of bug. Pick the tier by what you're
testing, then open the per-language reference for the harness.
| Testing… |
Tier |
Why |
| A pure function with an invariant ("always/never") |
property (Tier 1) |
Generated inputs + shrinking to a minimal counterexample |
| Any untrusted-input boundary — parser, decoder, config/manifest loader, wire-format reader, or native C++/Rust module — that must not crash/hang/corrupt on any input |
coverage-guided fuzzing (Tier 2) |
Finds crash / memory-safety / security inputs with no spec; replayable corpus |
A stateful SUT invariant over synthetic worlds (cascade parity, check↔fix idempotence) |
hand-rolled seeded (Tier 3) |
Drives the real SUTs over generated repo trees; deterministic replay |
Property/fuzz tests complement example tests — they don't replace them.
Keep a few concrete test('…') cases as living documentation; add a property
or fuzz test for the "for all inputs" claim.
Correctness vs safety are different fuzzers, both wanted. A property test
checks correctness against a property you write (finds wrong answers); a
coverage-guided fuzzer finds crashes / memory / security violations with no
spec, using coverage feedback to reach deep paths random sampling misses. A
function can be crash-free but wrong (property catches) or correct-on-samples
but crash on a deep-path input (fuzzer catches). For a native parser do
both: libFuzzer + ASan on the native code (memory safety) AND a JS-surface
fuzz through the boundary (the integration contract).
Pick your language
Each reference carries the tool, file layout, run commands, escalation to
stronger engines, and CI/gate wiring for that ecosystem. The Rust lane is the
canonical reference where a surface is implemented in several languages (see
"Lock-step" below).
| Language |
Tier 1 (property) |
Tier 2 (coverage-guided) |
Reference |
| JS / TS |
fast-check |
vitiate (SWC-instrumented) |
references/javascript-typescript.md |
| Rust |
proptest / quickcheck |
cargo-fuzz (libFuzzer) + ASan/UBSan |
references/rust.md |
| Go |
table props / testing/quick |
go test -fuzz (native) |
references/go.md |
| C++ |
assertion harness |
libFuzzer + -fsanitize=address,fuzzer,undefined |
references/cpp.md |
The high-value fleet targets
"A bug here = red CI everywhere" surfaces. Property/fuzz them first:
- Version + pin math —
compareSemver, extractPinVersion, derivePins,
applyPins (sync-package-manager-pins.mts), majorBoundedRange.
Properties: total order; derivePins always yields a valid >=x <y;
applyPins is idempotent.
- Config / marker validation —
readSocketWheelhouseConfig,
validateBundleBlock. Property: never throws on arbitrary input; accepts iff
genuinely valid.
- Cascade
check ↔ fix idempotence (Tier 3) — for arbitrary synthetic
trees: collectFindings(applyFixes(x)) is clean (a fix never leaves a
fixable finding), applyFixes∘applyFixes = applyFixes, and a fixer never
mutates a path outside its finding's scope.
- Untrusted-input boundaries (Tier 2) — sdxgen's manifest/lockfile parsers,
ultrathink's
acorn JS/TS parser (every lane), envrypt's .env parse
pipeline + ECIES decrypt, decmpfs's compression reader, and abitious's hybrid
.node reader. A native module is the top priority — a crafted input can
overflow/UAF the C++/Rust, which a JS-level test never sees — so fuzz the
native code directly with libFuzzer/cargo-fuzz + ASan/UBSan, not only through
the JS boundary.
Lock-step (multi-language surfaces)
When ONE surface is implemented in several languages (acorn's parser: Rust /
Go / C++ / TS), the fuzzers are lock-step, exactly like the parsers: Rust is
the canonical fuzzer and the source of truth; the other lanes are ports. The
shared contract — corpus format, adversarial dictionary, classification
taxonomy, repro-dump shape — is defined ONCE at the package level and every
lane's harness points at it, so a seed added for one lane is a seed for all.
The reference implementation is packages/acorn/fuzz/ (the shared substrate)
with per-lane harnesses under lang/<lang>/fuzz/. Any accept-here /
reject-there split across lanes is a divergence finding, not a silent fixup.
A single-language surface (envrypt, decmpfs, abitious, sdxgen) needs no shared
substrate — its one lane's fuzz/ tree IS the source of truth.
Beyond the default engine
The natives above (cargo-fuzz, go test -fuzz, libFuzzer) are the right
DEFAULT — lowest friction, lock-step across lanes, replayable corpus. Escalate
only for a proven need; each reference names the concrete tool + when:
- Continuous fuzzing (biggest lever): ClusterFuzzLite — runs the SAME
libFuzzer/AFL++/honggfuzz targets in CI (GitHub Actions) on every PR + a
batch cron, with corpus accretion, coverage reports, and crash bisection.
Language-agnostic (C/C++/Rust/Go). This is the upgrade that matters most —
a fuzzer that runs once in CI finds little; one that runs continuously with a
growing corpus finds the deep bugs. Adopt it before reaching for a fancier
local engine.
- Alternative engines — AFL++ (better mutators/schedulers, persistent
mode), Honggfuzz (hardware-feedback), and LLVM Centipede (distributed,
the modern libFuzzer successor) beat libFuzzer on some targets. In Rust,
bolero lets ONE harness run under libFuzzer / AFL++ / honggfuzz / the Kani
model-checker without a rewrite — the cheapest way to A/B engines.
- Structure-aware fuzzing — for grammars/wire formats, mutate the STRUCTURE
not raw bytes: Rust
arbitrary (already used), libprotobuf-mutator (C++),
fuzzed-typed Go harnesses. Reaches valid-but-adversarial inputs a byte
mutator wastes cycles rediscovering.
- More sanitizers — ASan (heap/stack), UBSan (UB), MSan (uninitialized
reads — C++ only, needs an instrumented libc++), TSan (data races). Layer
ASan+UBSan by default; add MSan/TSan for a specific bug class.
Conventions (all languages)
- A discovered counterexample becomes a pinned regression test (example
test / committed corpus entry) next to the property — regression +
documentation.
- Corpus + dictionary are committed so a crash is replayable and coverage
accrues across runs. Seed with real inputs (conformance suites, fixtures).
- Seed + iteration/time budget are env-/flag-overridable so CI can widen and a
failure replays deterministically (
FUZZ_SEED / FUZZ_ITERS for seeded
harnesses; -max_total_time / -fuzztime / --fuzz-time for the native
fuzzers).
- Read the exit-code contract in the language reference before wiring CI:
0 = clean, a crash code = fail + upload the minimized artifact, a timeout
code = hang. Never treat "exit 1 = crash found" — the codes are tool- and
version-specific.
- A fuzzer build/run that can't find its toolchain reports the exact install
command and exits non-zero — it never fabricates a pass.
1---2name: property-and-fuzz-testing3description: Pick a property/fuzz tier and harness for parsers, decoders, and native addons.4---56# Property and fuzz testing78Three tiers, each for a different shape of bug. Pick the tier by what you're9testing, then open the per-language reference for the harness.1011| Testing… | Tier | Why |12|---|---|---|13| A **pure function** with an invariant ("always/never") | **property** (Tier 1) | Generated inputs + shrinking to a minimal counterexample |14| **Any untrusted-input boundary** — parser, decoder, config/manifest loader, wire-format reader, or **native C++/Rust module** — that must not crash/hang/corrupt on any input | **coverage-guided fuzzing** (Tier 2) | Finds crash / memory-safety / security inputs with no spec; replayable corpus |15| A **stateful SUT invariant** over synthetic worlds (cascade parity, `check↔fix` idempotence) | **hand-rolled seeded** (Tier 3) | Drives the real SUTs over generated repo trees; deterministic replay |1617Property/fuzz tests **complement** example tests — they don't replace them.18Keep a few concrete `test('…')` cases as living documentation; add a property19or fuzz test for the "for all inputs" claim.2021**Correctness vs safety are different fuzzers, both wanted.** A property test22checks *correctness* against a property you write (finds wrong answers); a23coverage-guided fuzzer finds *crashes / memory / security* violations with **no24spec**, using coverage feedback to reach deep paths random sampling misses. A25function can be crash-free but wrong (property catches) or correct-on-samples26but crash on a deep-path input (fuzzer catches). For a **native parser do27both**: libFuzzer + ASan on the native code (memory safety) AND a JS-surface28fuzz through the boundary (the integration contract).2930## Pick your language3132Each reference carries the tool, file layout, run commands, escalation to33stronger engines, and CI/gate wiring for that ecosystem. The Rust lane is the34canonical reference where a surface is implemented in several languages (see35"Lock-step" below).3637| Language | Tier 1 (property) | Tier 2 (coverage-guided) | Reference |38|---|---|---|---|39| JS / TS | `fast-check` | `vitiate` (SWC-instrumented) | [references/javascript-typescript.md](references/javascript-typescript.md) |40| Rust | `proptest` / `quickcheck` | `cargo-fuzz` (libFuzzer) + ASan/UBSan | [references/rust.md](references/rust.md) |41| Go | table props / `testing/quick` | `go test -fuzz` (native) | [references/go.md](references/go.md) |42| C++ | assertion harness | libFuzzer + `-fsanitize=address,fuzzer,undefined` | [references/cpp.md](references/cpp.md) |4344## The high-value fleet targets4546"A bug here = red CI everywhere" surfaces. Property/fuzz them first:4748- **Version + pin math** — `compareSemver`, `extractPinVersion`, `derivePins`,49 `applyPins` (`sync-package-manager-pins.mts`), `majorBoundedRange`.50 Properties: total order; `derivePins` always yields a valid `>=x <y`;51 `applyPins` is idempotent.52- **Config / marker validation** — `readSocketWheelhouseConfig`,53 `validateBundleBlock`. Property: never throws on arbitrary input; accepts iff54 genuinely valid.55- **Cascade `check ↔ fix` idempotence** (Tier 3) — for arbitrary synthetic56 trees: `collectFindings(applyFixes(x))` is clean (a fix never leaves a57 fixable finding), `applyFixes∘applyFixes = applyFixes`, and a fixer never58 mutates a path outside its finding's scope.59- **Untrusted-input boundaries (Tier 2)** — sdxgen's manifest/lockfile parsers,60 ultrathink's `acorn` JS/TS parser (every lane), envrypt's `.env` parse61 pipeline + ECIES decrypt, decmpfs's compression reader, and abitious's hybrid62 `.node` reader. A **native module is the top priority** — a crafted input can63 overflow/UAF the C++/Rust, which a JS-level test never sees — so fuzz the64 native code directly with libFuzzer/cargo-fuzz + ASan/UBSan, not only through65 the JS boundary.6667## Lock-step (multi-language surfaces)6869When ONE surface is implemented in several languages (acorn's parser: Rust /70Go / C++ / TS), the fuzzers are lock-step, exactly like the parsers: **Rust is71the canonical fuzzer and the source of truth; the other lanes are ports.** The72shared contract — corpus format, adversarial dictionary, classification73taxonomy, repro-dump shape — is defined ONCE at the package level and every74lane's harness points at it, so a seed added for one lane is a seed for all.75The reference implementation is `packages/acorn/fuzz/` (the shared substrate)76with per-lane harnesses under `lang/<lang>/fuzz/`. Any accept-here /77reject-there split across lanes is a `divergence` finding, not a silent fixup.7879A single-language surface (envrypt, decmpfs, abitious, sdxgen) needs no shared80substrate — its one lane's `fuzz/` tree IS the source of truth.8182## Beyond the default engine8384The natives above (cargo-fuzz, `go test -fuzz`, libFuzzer) are the right85DEFAULT — lowest friction, lock-step across lanes, replayable corpus. Escalate86only for a proven need; each reference names the concrete tool + when:8788- **Continuous fuzzing (biggest lever): ClusterFuzzLite** — runs the SAME89 libFuzzer/AFL++/honggfuzz targets in CI (GitHub Actions) on every PR + a90 batch cron, with corpus accretion, coverage reports, and crash bisection.91 Language-agnostic (C/C++/Rust/Go). This is the upgrade that matters most —92 a fuzzer that runs once in CI finds little; one that runs continuously with a93 growing corpus finds the deep bugs. Adopt it before reaching for a fancier94 local engine.95- **Alternative engines** — AFL++ (better mutators/schedulers, persistent96 mode), Honggfuzz (hardware-feedback), and LLVM **Centipede** (distributed,97 the modern libFuzzer successor) beat libFuzzer on some targets. In Rust,98 **bolero** lets ONE harness run under libFuzzer / AFL++ / honggfuzz / the Kani99 model-checker without a rewrite — the cheapest way to A/B engines.100- **Structure-aware fuzzing** — for grammars/wire formats, mutate the STRUCTURE101 not raw bytes: Rust `arbitrary` (already used), `libprotobuf-mutator` (C++),102 fuzzed-typed Go harnesses. Reaches valid-but-adversarial inputs a byte103 mutator wastes cycles rediscovering.104- **More sanitizers** — ASan (heap/stack), UBSan (UB), MSan (uninitialized105 reads — C++ only, needs an instrumented libc++), TSan (data races). Layer106 ASan+UBSan by default; add MSan/TSan for a specific bug class.107108## Conventions (all languages)109110- A discovered counterexample becomes a **pinned regression test** (example111 test / committed corpus entry) next to the property — regression +112 documentation.113- **Corpus + dictionary are committed** so a crash is replayable and coverage114 accrues across runs. Seed with real inputs (conformance suites, fixtures).115- Seed + iteration/time budget are env-/flag-overridable so CI can widen and a116 failure replays deterministically (`FUZZ_SEED` / `FUZZ_ITERS` for seeded117 harnesses; `-max_total_time` / `-fuzztime` / `--fuzz-time` for the native118 fuzzers).119- **Read the exit-code contract** in the language reference before wiring CI:120 `0` = clean, a crash code = fail + upload the minimized artifact, a timeout121 code = hang. Never treat "exit 1 = crash found" — the codes are tool- and122 version-specific.123- A fuzzer build/run that can't find its toolchain **reports the exact install124 command and exits non-zero — it never fabricates a pass.**