test(kerykeion): add the fuzz targets, corpus, and the job that runs them - #428
Merged
Conversation
added 3 commits
August 20, 2026 23:58
…them Items 1 to 3 of #95 have been absent since the proptest half landed: no fuzz directory, no targets, no seed corpus. This adds all three over the surfaces that parse untrusted radio input, and the CI job that executes them. The job is part of the fix rather than an extra. These targets need nightly and a sanitizer, so the stable gate never compiles them; without a job that builds and runs them they would be code that is never exercised, which looks from the outside exactly like code that works. Sixty seconds per target on changes to this crate or to kerykeion, weekly, and on demand -- a smoke test, not a search. Two of the targets assert more than absence of panic. frame_decode requires that a yielded frame consumed input, because a decoder that reports progress while standing still turns the Framed loop into a hang that a panic-only check cannot see. message_parse requires that an accepted message re-encodes losslessly, so a parser admitting more than the type can represent fails here instead of quietly producing a value the encoder cannot express. MeshCodec becomes pub. The module was already `pub mod codec` and lib.rs already documents `codec::MeshCodec` as the frame codec, so the type being pub(crate) made the module export nothing and contradicted its own documentation. The corpus is nineteen hand-built seeds named for the shape each exercises, derived from the frame layout documented in codec.rs and from protobuf wire encoding: valid frames, a lone magic byte, a length exceeding MAX_PACKET_SIZE, a truncated varint, an unknown field number. Refs #95
All three targets failed to build. cargo-fuzz takes its default --target from the triple it was itself built for rather than from the host, and install-action ships the musl build, so it tried to build for x86_64-unknown-linux-musl. A sanitizer cannot be linked against a static libc, so that fails before any fuzzing happens. Naming the host triple removes the installer's choice of binary flavour from the build. The README notes the same caveat for a local musl install.
The message_parse target asserted that a decoded message equals the message decoded from its own re-encoding. That is wrong for these types: they carry f32 fields and NaN is not equal to itself, so a byte-perfect round trip of a NaN NodeInfo.snr failed the assertion. The fuzzer found it in under a minute, which is the target working rather than the parser failing. Comparing the encoded bytes states the property that was meant -- encoding reaches a fixed point -- and is unaffected by NaN while still catching a field that survives one round and not two. The reproducer is kept as a corpus seed so the case stays covered.
This was referenced Aug 21, 2026
forkwright
pushed a commit
that referenced
this pull request
Aug 21, 2026
The first cargo-deny run that could see the fuzz workspace rejected libfuzzer-sys 0.4.13: it is `(MIT OR Apache-2.0) AND NCSA`, and NCSA was in no allow list. That is a real licensing fact about a dependency this repo has carried since #428 and nothing could report until now, which is the whole argument for extending the check. NCSA is the University of Illinois/NCSA Open Source License — permissive, OSI-approved, FSF Free/Libre, and compatible with this workspace's AGPL-3.0. libfuzzer-sys carries it because it wraps LLVM's libFuzzer, which predates LLVM's relicensing to Apache-2.0-with-LLVM-exception. Recorded as a per-crate exception rather than a twelfth entry in `allow`. The grant is sound for this dependency; a global entry would also accept NCSA from any future crate in the shipped runtime graph, which is a different and unexamined question. Nothing outside the detached fuzz workspace depends on libfuzzer-sys.
forkwright
added a commit
that referenced
this pull request
Aug 21, 2026
…and osv (#453) `fuzz/` was invisible to every repo-wide check. Five of them. I found this while producing lint evidence for #377: a `kanon lint . --all` at `origin/main` returned 48 findings, and **15 were in `fuzz/`** — every one introduced when that crate landed in #428, earlier tonight. Nothing was ever going to catch them. ## Why nothing saw it The crate declares its own empty `[workspace]` table so a nightly sanitizer build stays out of the stable workspace. That is deliberate and correct. It also means every check keyed to the root manifest resolves a graph that does not contain it: | check | why it missed `fuzz/` | |---|---| | `cargo clippy --workspace` / the CI gate | the root workspace excludes the directory | | `kanon lint` | ran, and nobody had looked at its output | | dependabot | `directory: /` only | | `cargo audit` | reads one lockfile | | `cargo deny` | follows the manifest it is given | | `osv-scanner` | the step named `--lockfile=Cargo.lock` explicitly | So `libfuzzer-sys`, `prost`, `tokio-util` and `bytes` have had **no vulnerability scanning and no dependency updates** since the crate was added. Three separate security jobs, each looking like it covered the repository. ## What changed **The lint debt is cleared** — `kanon lint . --all` now reports zero findings under `fuzz/`, repo total 48 → 33. Manifest sections follow the canonical order the rule encodes (workspace, package, bin, dependencies, package.metadata), the single-key `[dependencies.kerykeion]` block folds into `[dependencies]`, and a LICENSE sits at this crate's own workspace root — which is what `MANIFEST/license-spdx-match` was asking for, since `[workspace]` makes `fuzz/` a root in its own right and the repository LICENSE one directory up does not reach it. **One fix was worth more than the lint that prompted it.** `message_parse` had an `.expect()` flagged as library code; replacing it with `panic!` traded one rule for another, which is the tell that neither was the fix. The property wanted is *a message this parser produced decodes again to the same bytes* — one comparison, not a match arm plus an assertion. It is now a single `assert_eq!` over `Option<&[u8]>`, which also states the decode-succeeds half the previous shape left implicit. **The coverage gap is closed at each site**: a `/fuzz` dependabot entry, a second `cargo deny` run with `--manifest-path fuzz/Cargo.toml`, a second `cargo audit` with `--file fuzz/Cargo.lock`, and `--lockfile=fuzz/Cargo.lock` added to the osv step. Each is a second invocation rather than a widened first one, because no flag on any of the three spans two workspaces — the comments say so at each site, since "why is this run twice" is the obvious question and collapsing them is the obvious wrong answer. `fuzz/Cargo.lock` is now committed, because dependabot updates a binary crate by moving its lockfile and cannot act without one. It also removes the untracked file any local `cargo check` in that directory leaves behind. ## Verification - `kanon lint . --all` at this branch: zero findings under `fuzz/`. - `cargo +nightly check --target x86_64-unknown-linux-gnu` in `fuzz/`: `Finished`, exit 0 — the reordered manifest and rewritten target still build. - Both YAML files parse. `pre-push-verify.sh` derives crates from the branch diff and finds none: this touches only `fuzz/` (outside the workspace) and two CI files. The real verification is the security jobs themselves reporting on this PR, which is the thing that could not happen before it. --------- Co-authored-by: forkwright <cody@forkwright.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds items 1–3 of #95 — the
fuzz/crate, three targets, and a seed corpus — plus the CI job thatruns them. Item 4 (proptest round-trips) landed previously and is untouched.
The job is part of the fix
These targets need nightly and a sanitizer, so the stable gate never compiles them. Without something
that builds and runs them, they would be code that is never exercised — which from the outside looks
exactly like code that works. The workflow is what makes them evidence rather than decoration.
Scoped rather than universal: 60 seconds per target, on changes to
fuzz/**orcrates/kerykeion/**,weekly, and on
workflow_dispatch. That is a smoke test — it proves the targets build, load theircorpus and execute. Finding new defects is the scheduled run's job. Fuzzing every unrelated PR would
spend CI minutes, the fleet's binding constraint, on a question that PR did not raise.
This PR touches
fuzz/**, so the job runs here and its result is the verification.The targets assert properties, not just absence of panic
frame_decodecodec::MeshCodecmessage_parseFromRadio/ToRadiorouting_decisionRoutingProcessor::process_routingThe two emphasised clauses are the ones a panic-only harness would miss. A decoder that reports a
frame without consuming bytes turns
Framed's loop into a hang, not a crash. A parser that acceptsmore than its type can represent produces a value the encoder cannot express, silently.
Layout
fuzz/sits outside the workspace — the root manifest ismembers = ["crates/*"], and the cratecarries its own empty
[workspace]table. Socargo check --workspaceand the gate never try tobuild it, and the repository toolchain stays stable.
MeshCodecbecomespub.codecwas alreadypub modandlib.rs:8already documentscodec::MeshCodecas the frame codec, so apub(crate)type made that module export nothing andcontradicted its own documentation.
Corpus
Nineteen seeds, each named for the shape it exercises: valid frames, two frames in one buffer, leading
garbage before the magic pair, a lone magic byte, a length field exceeding
MAX_PACKET_SIZE, a lengthwith no body, a truncated varint, an unknown field number.
Hand-built rather than captured, and derived from the frame layout documented at the top of
codec.rsplus protobuf wire encoding, so each byte is traceable to something in the tree ratherthan to a guess. libFuzzer grows it from there.
Not claimed
This does not close #95. The issue asks that the targets be "exercised by an appropriate CI/manual
fuzz-smoke mechanism" — that is satisfied here — but I would rather the first scheduled run and a
reviewer's read stand behind the closure than my own assertion on the PR that opened it.
Refs #95