Skip to content

fix(rust): seed libFuzzer fuzz_target! harnesses as reachability entry points - #228

Merged
gadievron merged 8 commits into
masterfrom
fuzz-target-fix
Aug 13, 2026
Merged

fix(rust): seed libFuzzer fuzz_target! harnesses as reachability entry points#228
gadievron merged 8 commits into
masterfrom
fuzz-target-fix

Conversation

@gadievron

Copy link
Copy Markdown
Collaborator

What

Treat libFuzzer fuzz_target! harnesses as reachability entry points in the Rust parser, so a library's fuzzed untrusted-decode surface is analyzed instead of pruned as dead code.

Why

OpenAnt's reachability filter seeds a forward BFS from entry points (main / route / CLI handler). A pure library ships none of those, so its public decode API — the exact surface a fuzz harness drives — was pruned before analysis. A crate's fuzz_target! harnesses are the authors' in-code declaration of that untrusted-input surface, but the Rust parser saw them as empty macro bodies: tree-sitter leaves a macro's token-tree opaque, so the closure body emitted no function_item and its decode calls never entered the call graph (libs/openant-core/parsers/rust/function_extractor.py).

How

  • Synthesize an entry-point unit from each fuzz_target! closure body, lifted as ordinary Rust and walked so nested fn/impl/struct become their own same-file units (function_extractor.py _handle_fuzz_target). Seed-only: the harness is kept in analyzer_output for call-graph symmetry but excluded from Stage-1 analysis units (unit_generator.py:51).
  • Keep-all safety net when the only entry points are synthetic harnesses (pure-library-plus-fuzz), so a crate whose real API is macro-hidden is not silently blacked out (parsers/rust/test_pipeline.py).
  • Recover scoped Type::method( calls inside scannable macros (assert! / assert_eq! / …), so the most common harness idiom assert!(Type::roundtrip(d)) seeds its target rather than zero edges (call_graph_builder.py _MACRO_CALL_RE + _scan_macro_body, routed through the existing _resolve_scoped).
  • Harden the expr-form lift: a trailing // comment no longer swallows the body terminator and drops the harness, and a struct-destructure closure param (|Wrap { inner }: Wrap| …) is no longer mis-lifted as the body (function_extractor.py).
  • Keep scoped recovery add-only on a leaf-name collision: when a scoped qualifier binds nothing and the leaf is shared by a free fn and a method, delegate the fallback to the bare resolver so no edge master produced is dropped (call_graph_builder.py _resolve_scoped Step 4).
  • Forward --library-mode through the Go CLI on both the parse and scan paths (apps/openant-cli/cmd/parse.go, scan.go).

Reachability impact

Measured whole-branch vs master, production config (--skip-tests), via the real pipeline:

  • neqo: reachable 854 → 967 (+113, −0 removed); edges +305, the 5 removed edges all bare→scoped retargets to the correct method (e.g. tests::is_known_type → WebTransportFrame.is_known_type), 0 pure loss.

The change adds real reachability (recovers the fuzz/decode/scoped surface) and removes phantom production→test reachability. On hyper the branch is reachable −2 (tests::length, Encoder.is_eof) — a correct de-phantoming, not a regression: master mis-resolved a production Encoder::length(...) call to a same-named #[cfg(test)] helper length, and scoped recovery now binds it to the real method. Both de-reached nodes were reachable only through that production→test phantom (verified: tests::length's sole master inbound was the production Server.encode_headers). No genuinely-reachable production code is de-reached.

Tests

$ /Users/gadievron/.openant/venv/bin/python -m pytest tests/ -q   # from libs/openant-core
2688 passed, 30 skipped, 1 warning in 34.26s

$ git diff master..HEAD -- 'libs/openant-core/tests/**' | grep -cE '^\+\s*def test_'
19

19 new test functions (16 in test_rust_fuzz_target.py, 3 in test_rust_macro_string_literal.py), each a RED→GREEN regression for one behavior above. Static analysis on the changed files: ruff 0, go vet 0, semgrep 9 files / 373 rules / 0 findings, codeql python-security-and-quality 174 queries / 0 findings.

Compatibility

No breaking change. fuzz_target! seeding is default-on and structural (no LLM call). --library-mode is opt-in. The keep-all net only widens output (never prunes more) for pure-library-plus-fuzz repos.

Notes

  • Scope: 2 concern areas (Rust parser + Go CLI wiring for --library-mode); 607 LOC. Cohesive around one feature (library-reachability via fuzz harnesses).
  • Pre-existing gap, not introduced here: the repo has no tests/parsers/rust/test_callgraph_symmetry.py (only Python ships that canonical file today). Suggest a follow-up infra PR rather than bundling it.

Author notes

  • Which lines implement the fix: function_extractor.py _handle_fuzz_target (harness synthesis + expr-form lift); call_graph_builder.py _MACRO_CALL_RE/_scan_macro_body (scoped-call recovery) and _resolve_scoped Step 4 (add-only fallback); unit_generator.py:51 (seed-only skip); test_pipeline.py (keep-all net).
  • One input the old code handled wrong: a hybrid crate (real bin + fuzz harness) with fuzz_target!(|d| { assert!(Codec::roundtrip(d)); }) — old code seeded zero edges and silently pruned Codec::roundtrip and its transitive decode sink; new code seeds them.
  • Likely reviewer pushback + answer: "Does this reduce reachability precision / lose edges?" — No: measured add-only on neqo (reachable −0); the only reachability removed anywhere is a production→test phantom (hyper −2), which is a precision gain. Verified first-hand against a genuine master worktree across 8 repos.

gadievron and others added 8 commits August 12, 2026 21:46
…Go-wire --library-mode

Recovers the untrusted decode surface a QUIC/parser library exposes via its
libFuzzer harnesses (neqo 782->903), by lifting each fuzz_target! closure body
into a synthetic entry-point unit whose nested fns/impls become their own
same-file units. Keeps the blackout safety net for pure-fuzz-only libraries.
Wires the pre-existing --library-mode flag into the Go parse/scan CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lifted fuzz_target! harness seeds reachability so its decode callees are
analyzed, but the harness body is synthetic instrumentation (references the
dropped closure param) — emitting it as a Stage-1 unit shipped non-compiling
code to the LLM and re-analyzed the decoder. Skip it in dataset unit generation
(kept in analyzer_output for call-graph symmetry). neqo 903 reachable, 891 units.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nesses

test_every_extracted_function_has_a_unit asserted unit_ids == all extracted
functions; seed-only makes that conditionally false (a fuzz_target! harness is
in functions but not a dataset unit). The fixture has no harness so it passed,
but the invariant was latent-broken. Exclude synthetic_harness from the RHS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The macro-body call scanner (_MACRO_CALL_RE / _scan_macro_body) recovered
only bare and dotted call names from a macro's opaque token tree, dropping
the `::` qualifier on scoped calls. So `assert!(Type::method(d))` degraded
to a bare `method` resolved same-file-only, yielding no edge for a
cross-file associated/module fn. On synthetic fuzz_target! harnesses — whose
most common idiom is `assert!(Type::method(fuzz_input))` — this seeded zero
edges, silently pruning the decode target in hybrid repos (a real entry
point present, so the keep-all net does not fire).

Extend the regex to capture a scoped prefix and emit a `scoped` site routed
through the existing _resolve_scoped path (resolver unchanged). Add-only:
neqo (production config) reachable set 966->966 unchanged, +211 edges, the
5 removed edges all phantom retargets to the correct scoped target.

Tests: 3 added (harness reachability + macro-scanner scoped/bare units),
via `grep -c "def test_"` -> fuzz_target.py 13->14, macro_string_literal.py
2->4. Full suite: pytest tests/ -q -> 2685 passed, 30 skipped (was 2682).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding parseLibraryMode widened the var() block; re-align the existing
fields so gofmt -l is clean (master baseline was clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/work-audit caught B-docs drift: the docstring still said the scanner
'can only find bare/dotted names' after the fix added scoped (Type::method)
recovery. Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…param)

The expr-form closure lift in _handle_fuzz_target had two silent
false-negatives found by an adversarial bug-hunt:

- A trailing `//` line comment inside the macro parens
  (`fuzz_target!(|d| Frame::decode(d) // note\n)`) was swallowed by the
  appended `;}`, making the synthesized `fn` unparseable -> the harness was
  dropped with no warning, removing the fuzzed path from reachability in
  hybrid repos.
- A struct-destructure closure param (`|Wrap { inner }: Wrap| body`) was
  mis-lifted: the param's `{ inner }` brace was taken as the body, dropping
  the real expression.

Both stem from not respecting the closure-param boundary. Compute the
second top-level `|` first; only braces AFTER it are body candidates
(excludes the param destructure), and terminate the expr body on a fresh
line so a trailing line comment can't swallow the terminator.

Add-only: neqo (production config) reachable set unchanged (harnesses there
are block-form); this only recovers previously-dropped expr-form harnesses.

Tests: 2 added (grep -c def test_ -> test_rust_fuzz_target.py 14->16).
Full suite: pytest tests/ -q -> 2687 passed, 30 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The F1 macro scoped-call recovery could DROP an edge master produced: when a
scoped call's qualifier binds nothing in _resolve_scoped Steps 1-3 and the
leaf name is shared by a free fn AND a type method, Step 4's raw unique-name
fallback saw len(candidates)==2 and returned [] -- whereas the pre-scoped
bare capture ran _resolve_bare, which free-filters and returns the unique
free function. So `assert!(alpha::beta::c(1))` with a free `c` and a method
`S::c` lost the `-> c` edge (master had it).

Delegate Step 4's ambiguous branch to _resolve_bare(leaf) -- exactly what the
bare path returns (free-filter + import/external handling). Purely additive:
only reached where Step 4 previously returned [].

Measured neqo (production config) master -> HEAD: reachable 854->967
(+113, -0), edges +305 with the 5 removed all bare->scoped RETARGETS to the
correct method (0 pure loss, 0 reachability lost) -- add-only now holds at
the reachable-set level and the edge collision-loss is closed.

Tests: 1 added (grep -c def test_ -> test_rust_macro_string_literal.py 4->5).
Full suite: pytest tests/ -q -> 2688 passed, 30 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gadievron
gadievron merged commit 5449b72 into master Aug 13, 2026
9 checks passed
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.

1 participant