Skip to content

test(kerykeion): add the fuzz targets, corpus, and the job that runs them - #428

Merged
forkwright merged 3 commits into
mainfrom
test/95-fuzz-harnesses
Aug 21, 2026
Merged

test(kerykeion): add the fuzz targets, corpus, and the job that runs them#428
forkwright merged 3 commits into
mainfrom
test/95-fuzz-harnesses

Conversation

@forkwright

Copy link
Copy Markdown
Owner

Summary

Adds items 1–3 of #95 — the fuzz/ crate, three targets, and a seed corpus — plus the CI job that
runs 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/** or crates/kerykeion/**,
weekly, and on workflow_dispatch. That is a smoke test — it proves the targets build, load their
corpus 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

Target Surface Property
frame_decode codec::MeshCodec never panics, and a yielded frame consumed input
message_parse FromRadio / ToRadio fails cleanly, and an accepted message re-encodes losslessly
routing_decision RoutingProcessor::process_routing every decodable packet gets a verdict

The 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 accepts
more than its type can represent produces a value the encoder cannot express, silently.

Layout

fuzz/ sits outside the workspace — the root manifest is members = ["crates/*"], and the crate
carries its own empty [workspace] table. So cargo check --workspace and the gate never try to
build it, and the repository toolchain stays stable.

MeshCodec becomes pub. codec was already pub mod and lib.rs:8 already documents
codec::MeshCodec as the frame codec, so a pub(crate) type made that module export nothing and
contradicted 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 length
with 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.rs plus protobuf wire encoding, so each byte is traceable to something in the tree rather
than 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

forkwright 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.
@forkwright
forkwright merged commit 675e280 into main Aug 21, 2026
11 checks passed
@forkwright
forkwright deleted the test/95-fuzz-harnesses branch August 21, 2026 05:16
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test: add fuzz harnesses for Meshtastic codec and protocol parsing

1 participant