ADR 0002 — Fuzz Strategy
- Status: Accepted
- Date: 2026-07-05
- Phase: 11 (per
docs/IMPLEMENTATION-PLAN-v0.1.0.md) - Deciders: repository maintainers
- Related: ADR 0003 (property tests) — same invariant surface, different exploration strategy.
Context
Four public API entry points in the workspace deserialise or scan untrusted input:
rlg_cli::parse_record— parses a single JSON-shape record from a stream line. Called byrlg-cli,rlg-mcp, andrlg-reporton every input line.<LogFormat as FromStr>::from_str— parses the format identifier for the--formatflag and for the MCP tool arguments.rlg::config::Configdeserialisation — parses TOML config files loaded at process start.rlg_redact::Redactor::with_defaults().scrub— scans arbitrary log strings against six built-in regexes. A pathological input could trigger regex catastrophic backtracking or an unexpected panic in the regex engine.
Unit and integration tests exercise the happy path and a handful of edge cases. Neither systematically explores the input space.
Decision
Adopt cargo-fuzz (libFuzzer-backed) as the fuzz driver. Author
four targets — one per entry point above — under fuzz/, excluded
from the workspace so libfuzzer-sys and nightly-only build flags
never leak into the normal cargo build / cargo test toolchain.
CI workflow .github/workflows/fuzz-smoke.yml provides an
on-demand smoke run via workflow_dispatch. The initial intent was
a 30-second-per-target gate on every PR, but the GHA Ubuntu image’s
Rust toolchain layout does not play well with cargo-fuzz’s
-Zbuild-std step (five iterations of RUSTFLAGS / target-scoped
Cargo config / --sanitizer none / rust-src install did not
converge on a green PR run). Rather than sink more time into a
CI-image workaround that adds no unique coverage, we split the
responsibility:
- Continuous fuzz coverage — OSS-Fuzz, post-onboarding. Runs
each target for hours per day against the shared corpus with
ASan / MSan / UBSan variants. Files crashes as private
GitHub Security Advisories. See
docs/OSS-FUZZ.md. - UB detection per PR — Miri
(
.github/workflows/miri.yml). Catches the same class of bugs ASan would surface in a smoke run. - On-demand smoke — the
fuzz-smokeworkflow trigger, invoked manually by maintainers via the Actions tab (target + duration inputs). Used to verify a target after touching its driver or the underlying API.
This split ships full fuzz-target coverage of every untrusted-input entry point without paying the cost of debugging GHA-specific build-std issues that add nothing unique on top of Miri + OSS-Fuzz.
Target contracts
Every fuzz target satisfies:
#![no_main]— libfuzzer-sys entrypoint.- UTF-8 gate — non-UTF-8 bytes are rejected at the boundary via
std::str::from_utf8before touching workspace code. Fuzzing that rejection would exercisestdinternals, not our code. - No panics allowed — every wrapped API is documented as
fallible.
Result::Erris the correct response to invalid input. A panic under any input is a bug. - Deterministic — no clock reads, no thread spawns, no filesystem writes. Fuzz targets must be pure functions of their input.
Corpus policy
- Initial seeds for each target are drawn from the integration
test fixtures in
crates/rlg-cli/tests/,crates/rlg-mcp/tests/,crates/rlg-redact/tests/. Every green test line is a valid seed input. - New crashes are triaged within one working day. The fix ships
as a regular PR with a regression test derived from the crash
artefact, added to the crate’s
tests/and to the fuzz corpus. - Corpus size cap: 10 MB per target. Beyond that, run
cargo fuzz cmin(corpus minimisation) as part of the fix PR.
OSS-Fuzz integration
Onboarding runbook: docs/OSS-FUZZ.md. Summary:
- Draft the
project.yamlnaming the fuzz targets and the maintainer email. - Draft the
Dockerfilethat clones this repo and installs the nightly toolchain. - Draft the
build.shthat compiles each target withcargo fuzz build --release. - Open a PR against
google/oss-fuzzreferencing this ADR. - Once accepted, Google runs the fuzz corpus continuously and files crashes as GitHub Security Advisories.
Timeline for OSS-Fuzz acceptance is Google’s — typically 2–6 weeks. Phase 11 lands the local + smoke-gate coverage regardless.
What is not covered
- Concurrency bugs. Fuzz targets are single-threaded by design. Concurrent invariants belong to Loom (ADR 0001).
- Panics inside
crossbeam-queue,serde_json,regex, ortoml. Third-party crates carry their own fuzz coverage. A crash discovered in a transitive dep gets reported upstream. - Long-tail input patterns. 30 s per PR is a smoke gate. Deep bug-hunting is OSS-Fuzz’s role.
Consequences
-
CI cost. ~2 min per PR (30 s × 4 targets, plus nightly install + cache priming).
-
Contributor cost. Local reproducer:
cargo install cargo-fuzz --locked cd fuzz && cargo +nightly fuzz run parse_recordDocumented in
CONTRIBUTING.mdandfuzz/README.md. -
Nightly dependency. libFuzzer requires nightly. This is contained to the fuzz workflow — no impact on the rest of CI which runs on stable.
-
Excluded workspace.
fuzz/cannot use workspace-wide[lints]or[patch]. It sets its own minimal lints infuzz/Cargo.toml.
Alternatives considered
- AFL++. Slower to instrument in Rust than libFuzzer; harder to integrate with OSS-Fuzz. Rejected.
- Property tests only (Phase 12). Complementary, not equivalent. Proptest generates structured inputs; fuzzing generates raw byte strings. Both catch different bug classes.
- In-repo continuous fuzzing without OSS-Fuzz. GitHub Actions minutes budget does not sustain hours-per-day per-target fuzzing affordably. OSS-Fuzz is free for open-source projects.
References
cargo-fuzzbook- OSS-Fuzz new-project process
- Rustsec advisories with root cause in
serde_json::from_strpanics — historical precedent for this class of finding.