SIGN IN SIGN UP

feat: fuzz harness for the untrusted-input parsers + nightly sanitizer CI (LEO-414) (#37)

* feat: fuzz harness for the untrusted-input parsers + nightly sanitizer CI (LEO-414)

Adds the fuzzing the repo's own docs prescribe (C_CODING_STANDARDS "Fuzzing the
Parser", SECURITY.md control row 5, the dormant SW_BUILD_FUZZ stub): a fuzz target
for each untrusted-input surface, both under AddressSanitizer + UBSan, plus the
repo's first scheduled CI lane.

- C libFuzzer (tests/fuzz/fuzz_parse.c) over sw_parse_buffer, wired behind the
  now-live SW_BUILD_FUZZ CMake option (clang-only guard; fuzzer coverage +
  ASan/UBSan instrumentation; links sensorwatch_static to reach the internal
  parser, the same way the cmocka tests do).
- Rust cargo-fuzz (rust/sensorwatch-cli/fuzz) with parse_line and
  fixup_python_tokens targets over the JSONL replay parser. To reach the private
  parsers, sensorwatch-cli becomes lib+bin (src/lib.rs owns the modules plus a
  narrow `fuzz` surface; main.rs is a one-line shim). The fuzz crate is a
  detached workspace, so the MSRV and publish jobs are untouched.
- Three adversarial C parser cases (tests/c/test_parse.c) the malformed-header
  suite missed — 32-bit count*size wrap, an oversized single element, and
  unterminated name/unit fields — which double as fuzz seeds.
- Committed seed corpora: C seeds generated from the shared synthetic-buffer
  builder (tests/fuzz/gen_corpus.c), Rust seeds from the golden logs plus hostile
  lines. tests/fuzz/README.md documents running both harnesses locally.
- Nightly .github/workflows/fuzz.yml (schedule + workflow_dispatch) — the repo's
  first scheduled trigger; installs clang/nightly and runs both targets bounded.
- Doc truth-up: stale HWI_BUILD_FUZZ -> SW_BUILD_FUZZ (C_CODING_STANDARDS),
  SECURITY.md control row flipped from "fuzzing planned" to done, plus CHANGELOG
  and CONTRIBUTING entries.

Acceptance (mutation self-test): deleting the region-bounds guard in sw_parse.c
made ASan catch a heap-buffer-overflow read in sw_read_u32 (via sw_parse_buffer's
entry loop) on the wrap_count_size seed within seconds; reverted before commit.
Verified locally: both harnesses fuzz clean (C 200k+, Rust 2.4M / 8.5M runs), full
ctest green under ASan, and cargo test --workspace + fmt + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4

* fix: PR review — precise README guard ref, gen_corpus write-error checks, pin cargo-fuzz

Copilot review (PR #37):
- tests/fuzz/README.md: the mutation self-test named a guard that does not exist
  verbatim (`sensor_end / entry_end > len`); quote the real check
  `if (sensor_end > len || entry_end > len)` so the repro is not misleading.
- tests/fuzz/gen_corpus.c: write_file now fails fast on a short fwrite or a
  deferred flush error at fclose, so a truncated seed can never be committed
  while still printing "wrote ...". Seeds regenerate byte-identical.
- .github/workflows/fuzz.yml: pin `cargo install cargo-fuzz --version 0.13.2`
  (like ci.yml's bindgen-cli pin) so the nightly lane is reproducible and a
  cargo-fuzz release cannot break it unbidden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4

* fix: review round 2 (claude-reviewer) — corpus-hygiene consistency + crash-artifact ignores

Addresses the three still-open [low] docs/dev-hygiene findings from the shared
claude-reviewer round-2 pass on fb3cec4 (all non-blocking):

- .gitignore: ignore libFuzzer reproducers (crash-*/leak-*/timeout-*/oom-*) that
  the C harness — and the documented mutation self-test — write to the CWD / repo
  root.
- tests/fuzz/README.md + fuzz.yml: the Rust `cargo fuzz run` commands now fuzz into
  a scratch dir with the committed seeds passed as read-only input, so
  fuzz/corpus/<target> stays curated (mirrors the C target's existing pattern). A
  40 s run previously added ~2,281 untracked files under the committed corpus.
  Verified locally: committed seeds unchanged, all discovered inputs land in the
  scratch dir.

Finding 1 (the PR-body note implying fuzz.yml could be workflow_dispatch-triggered
pre-merge — GitHub only dispatches workflows on the default branch) is corrected in
the PR description; the lane's first GitHub run is the post-merge shakeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4

* fix: review round 3 (codex) — fixup oracle, fuzz-only link, accurate Rust sanitizer claim

Addresses the three P2 findings from the codex-reviewer rollups (fb3cec4 / 1084174),
each confirmed against the code:

1. fixup_python_tokens fuzz target had no semantic oracle — it caught crashes but
   not the string-boundary corruption it exists to test (a fixup that rewrites an
   in-string token passed the fuzzer while failing the unit test). The wrapper now
   asserts two invariants: already-valid JSON must pass through unchanged (None),
   and the rewrite is idempotent. Verified: 3.8M runs clean on the correct code,
   and the disposable in-string mutation now trips the oracle on the token_in_string
   seed within the first replay.
2. SW_BUILD_FUZZ linked sensorwatch_static but did not guarantee it was built:
   `-DSW_BUILD_FUZZ=ON -DSW_BUILD_STATIC=OFF -DSW_BUILD_TESTS=OFF` configured then
   failed to link (`cannot find -lsensorwatch_static`). Added SW_BUILD_FUZZ to the
   static-target condition; the fuzz-only configuration now builds and links.
3. Scoped the ASan+UBSan claim to the C harness. cargo-fuzz 0.13.2 supports only
   address/leak/memory/thread/none — not undefined — so the Rust targets run under
   ASan (plus Rust's debug assertions + overflow checks), not UBSan. Corrected
   fuzz.yml, CHANGELOG, CONTRIBUTING, and the README Rust section; the C target's
   ASan+UBSan wording is unchanged (accurate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4

* fix: review round 4 (copilot) — gen_corpus NULL-buffer hard-fail + README grammar

Two Copilot comments on 6905d80:
- tests/fuzz/gen_corpus.c: write_file now rejects a NULL buffer (a failed
  sw_test_build_buffer) before creating the file, so a build failure can't leave a
  truncated/empty seed behind with a bogus byte count; `len` is also initialized so
  it is never read indeterminate at the call site. Seeds regenerate byte-identical.
- tests/fuzz/README.md: "Both run any crash..." -> "Both treat any crash... as a
  bug" (the harnesses treat such findings as bugs; they don't run them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4

* fix: review round 5 (codex + copilot) — metamorphic fixup oracle + gen_corpus NULL-safety

codex (changes requested) on 7832b81:
- [P2] The fixup fuzz oracle missed the production shape where the input needs
  fixup AND carries in-string tokens (a bare token defeats the valid-JSON check;
  rewriting the in-string token too still leaves an idempotent result). Added a
  metamorphic invariant: embed the fuzzer bytes in a JSON string field, append a
  bare NaN to force the rewrite, then parse the fixed output and assert the string
  field decodes back byte-for-byte. Verified: 1.9M runs clean on the correct code,
  and the in-string mutation now trips it on the seed replay ("fixup corrupted a
  JSON string field").
- [P3] Truth-up: the fuzz.rs module doc no longer says the wrappers discard their
  results (fixup now asserts), and the PR description's ASan+UBSan claim is scoped
  to the C target (Rust runs under ASan).

copilot (x4) on gen_corpus.c: blocks patched/memset the buffer before write_file's
NULL check. Added a build_or_die() helper used by every block, so a failed
sw_test_build_buffer exits cleanly before any deref. gen_corpus compiles under
-Wall -Wextra -Wpedantic -Werror and the seven seeds regenerate byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
L
lcj-claude-coder committed
0019eb6140733747bdb8cdc90b4414c36b4a63c4
Parent: 8a53826
Committed by GitHub <noreply@github.com> on 7/15/2026, 1:09:08 AM