Skip to content

test(M9): fuzz the JSON deserialization trust boundary — cargo-fuzz harness - #227

Merged
duyhuynh-vn merged 2 commits into
mainfrom
claude/compassionate-euler-ckhnet
Jul 25, 2026
Merged

duyhuynh-vn merged 2 commits into
mainfrom
claude/compassionate-euler-ckhnet

Conversation

@duyhuynh-vn

Copy link
Copy Markdown
Contributor

Closes #224

Summary

Stands up the first fuzz harness (the M9 "fuzzing" roadmap item) against the SDK's single most important trust boundary: deserializing untrusted OCPI JSON off the wire. ocpi-server's receiver routers and ocpi-client's response parsing both turn JSON authored by a remote party into typed objects; the charter's core promise is that the unsupported case is rejected explicitly, never a panic, with #[forbid(unsafe_code)] in the type layer. A fuzzer moves that from asserted to continuously verified.

What changed (+271 LOC, no public type changes):

  • New fuzz/ cargo-fuzz member, excluded from the default workspace — nightly-only libFuzzer runtime, its own [workspace] table + exclude = ["fuzz"] in the root Cargo.toml — so cargo build/test --workspace and the stable -D warnings CI gates are unaffected, and no shipping crate gains a dependency.
  • 7 targets, one per high-value deserialize entry point: envelope (OcpiResponse), versions (Vec<Version> — the /versions catalogue the 3.0-recognition fix M9: OCPI 3.0 forward-scaffold — recognise VersionNumber::V3_0 (recognition-only, no type surface), mirroring the resolved 2.0 slice (#182) #219 hardened), and the location / session / cdr / tariff / token composites. Each calls serde_json::from_slice::<T>(data) and asserts the invariant — never panic; Ok(_) or a clean Err(_) only.
  • Seed corpora (fuzz/corpus/<target>/*.json) transcribed from the vendored spec-example payloads already carried in the module unit tests, so the fuzzer starts from valid, structurally-rich inputs. Only human seeds are committed; libFuzzer's coverage discoveries are .gitignored.
  • fuzz/README.md documents run instructions + the invariant; a README M9 note records the slice.

Real use case — why this PR is needed

ocpi-rs is consumed by evlinked/charge-hub (a hosted OCPI roaming Hub) and evlinked/stationlink. A Hub deserializes JSON authored by every partner it roams with — CPOs and eMSPs it does not control — so a single malformed Location/Session/Cdr that panics the deserializer is a remote denial-of-service on the hub, not a cosmetic bug. Hand-written round-trip tests only ever exercise the payloads a human thought to write; they will not construct an integer overflow in a numeric field, pathological nesting, invalid UTF-8, or a truncated body. Fuzzing the deserialize boundary is how the SDK earns the "a Hub can trust it at the version/trust boundary" guarantee against exactly those inputs — directly backing the charter's "reject explicitly, never mis-handle" promise on the one surface an attacker actually controls. The versions target is pointed: a partner's /versions catalogue is the very first thing negotiation parses, so a crash there breaks the handshake before a mutual version can even be chosen.

Spec reference

  • Milestone M9Conformance, fuzzing, docs, 3.0 forward-scaffold (v1.3.0+).
  • Charter trust-boundary promise (CLAUDE.md; #[forbid(unsafe_code)]).
  • crates/ocpi-server/src/lib.rs (receiver routers) and crates/ocpi-client/src/lib.rs (response parsing) — the deserialize entry points these targets drive.
  • Seed corpora sourced from specs/ocpi/2.2.1/*.asciidoc example payloads (via the existing module unit-test fixtures).

Test plan

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — clean (fuzz member excluded; only the 4 shipping crates checked)
  • cargo test --workspace --all-features — green (workspace unperturbed by the excluded member)
  • cargo deny check — not run locally (not installed in this environment); runs in CI
  • cargo-fuzz smoke run — all 7 targets build under cargo +nightly fuzz build; each ran 50,000 iterations over its seed corpus with zero crashes (fuzz/artifacts/ empty). The invariant holds on every entry point.

Notes

  • No public type changes; no new runtime dependencies in the shipping crates; no unsafe. libfuzzer-sys is a dependency of the excluded fuzz/ member only.
  • Scope: envelope + Vec<Version> + the five most-exposed 2.2.1 composites, per M9: Fuzz the JSON deserialization trust boundary — no panics on malformed OCPI payloads #224's "keep it reviewable" guard. Natural follow-ups: a time-boxed non-blocking CI fuzz job, and targets for Tariff/Cdr version-forks (v2_2 / v2_3_0) and the Payments Terminal object.
  • The fuzz member is deliberately kept out of the default build; running it requires a nightly toolchain + cargo install cargo-fuzz (see fuzz/README.md).

🤖 Generated with Claude Code

Claude session: https://claude.ai/code/session_01G6JopqD56JsuTLsf4HLfuT


Generated by Claude Code

…arness (closes #224)

Stand up the first fuzz harness against the SDK's single most important trust
boundary: deserializing untrusted OCPI JSON off the wire. `ocpi-server`'s
receiver routers and `ocpi-client`'s response parsing both turn remote-authored
JSON into typed objects; the charter's promise is that no input — however
malformed, truncated, deeply nested, non-UTF-8, or adversarial — may ever
panic, overflow, or hang the deserializer. This makes that continuously
verified rather than merely asserted.

- New `fuzz/` cargo-fuzz member, EXCLUDED from the default workspace (nightly-
  only libFuzzer runtime; own `[workspace]` table + root `exclude = ["fuzz"]`),
  so `cargo build/test --workspace` and the stable `-D warnings` gates are
  untouched and no shipping crate gains a dependency.
- One target per high-value deserialize entry point: `envelope`
  (`OcpiResponse`), `versions` (`Vec<Version>` — the /versions catalogue #219
  hardened), `location`, `session`, `cdr`, `tariff`, `token`. Each asserts the
  invariant: never panic; `Ok(_)` or a clean `Err(_)` only.
- Seed corpora transcribed from the vendored spec-example payloads already used
  as module unit-test fixtures. Only human seeds are committed; libFuzzer
  discoveries are gitignored.
- `fuzz/README.md` documents run instructions + the invariant; README M9 note.

No public type changes, no new runtime deps in shipping crates, no unsafe.
Verified locally: all 7 targets build under nightly cargo-fuzz and each ran
50k iterations over its seeds with zero crashes; `cargo fmt --check`, `cargo
clippy --workspace --all-targets --all-features -D warnings`, and `cargo test
--workspace --all-features` stay green.

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

Copy link
Copy Markdown
Contributor Author

🌙 Merge note — ready-to-apply README resolution (this PR conflicts with #223 at merge time)

This PR is clean against main today, but it collides with #223 because both insert a new paragraph at the same README anchor (immediately before ## How this repo is built, after the "Credentials registration handshake … 2.3.0" paragraph). Whichever of {#223, #227} merges second hits a README.md content conflict — flagged in the #240 goal review. Under the recommended landing order (#223#229#226#227#231), this PR lands second, so the resolution below is for this one.

Resolution: keep both paragraphs (no content is dropped)

The two paragraphs describe different, non-overlapping M9 slices — #223's 3.0 version-layer recognition and #227's fuzz harness — so the conflict resolves by keeping both, #223's first (it lands first). I reproduced the conflict locally (trial merge of origin/main#223#227) and verified this resolution completes the merge with zero leftover markers and no other conflicting files. Replace the <<<<<<< / ======= / >>>>>>> block with:

**M9 — the OCPI 3.0 forward-scaffold's first slice now lands: recognition-only** ([#219](https://github.com/evlinked/ocpi-rs/issues/219)). Following the resolved 2.0 back-coverage precedent ([#182](https://github.com/evlinked/ocpi-rs/issues/182)) and the crate's own 3.0 contract ([`specs/ocpi/3.0/README.md`](specs/ocpi/3.0/README.md) — *"defer logic, not schema"*), the version layer now **recognises** OCPI `3.0`: `VersionNumber::V3_0` parses/serialises `"3.0"`, `Display`/`as_str` round-trip, and orders **highest** (`V2_3_0 < V3_0`). Crucially this is recognition, **not support** — because no `supported` set the crate ships includes `V3_0`, version negotiation can never *select* 3.0: a partner advertising **only** `3.0` degrades to *no common version* (→ an explicit `UnsupportedVersion` `status_code`), while a partner advertising `3.0` **plus** a mutual version still negotiates the highest *mutual* one, so a single forward-looking `3.0` entry in a `/versions` catalogue never breaks an otherwise-working handshake (the top-of-range mirror of the `V2_0` degrade path, fenced by `negotiate_disjoint_returns_none_for_3_0_only_partner` + `negotiate_ignores_forward_3_0_and_picks_highest_mutual` in `crates/ocpi-client/src/lib.rs`). Deliberately **no `v3_0` type module**, no `ModuleID`/endpoint change, and — exactly as with 2.0 — **no 3.0 support-matrix column**; the 3.0 implementation logic stays `blocked-upstream` until the spec is public.

M9 (**Conformance, fuzzing, docs, 3.0 forward-scaffold**) now has its first coverage slice. The **fuzzing** roadmap item lands as the `fuzz/` [`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz) member ([#224](https://github.com/evlinked/ocpi-rs/issues/224)): libFuzzer harnesses against the SDK's single most important trust boundary — deserializing untrusted OCPI JSON off the wire — with one target per high-value entry point (`OcpiResponse` envelope, the `/versions` catalogue `Vec<Version>`, and the `Location`/`Session`/`Cdr`/`Tariff`/`Token` composites), each seeded from the vendored spec-example payloads and asserting the charter's invariant: *no input, however malformed or adversarial, may ever `panic!`, overflow, or hang — only `Ok(_)` or a clean `Err(_)`* (a panic on a hostile payload is a remote DoS on a Hub that deserializes JSON from every partner it roams with). The member is **excluded** from the default workspace (nightly-only libFuzzer runtime; carries its own `[workspace]` table), so `cargo build/test --workspace` and the stable `-D warnings` CI gates are untouched and no shipping crate gains a dependency. Run instructions and the invariant live in [`fuzz/README.md`](fuzz/README.md). No support-matrix column changes — M9 is cross-cutting hardening, not a per-version module.

If instead #227 lands first, the mirror is identical — put #227's fuzzing paragraph before #223's recognition paragraph; the resolution is still "keep both."

Left as a merge-time note rather than pushed to the branch: pre-resolving would require force-updating another author's PR branch, which the nightly dev does not do. This is a doc-only change; no source, tests, or CI behaviour is affected.

Claude session: https://claude.ai/code/session_01PHXLhTFdwQcLa3Q8SDDDJq


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

🌙 Conflict resolved on-branch — this PR is mergeable again

The README.md conflict flagged in the merge note above is now resolved in place: origin/main (73c9701) is merged into claude/compassionate-euler-ckhnet via an ordinary merge commit (a0abe21, no force-push, no history rewrite), so GitHub reports the PR mergeable (dirty → blocked, i.e. only awaiting the owner's review — the nightly bot never self-merges).

What was resolved

One hunk, README.md, exactly as pre-scoped in the merge note — both non-overlapping M9 paragraphs are kept:

  1. 3.0 recognition-only (feat(M9): recognise OCPI 3.0 at the version layer — recognition-only forward-scaffold #223, already on main) — first, since it landed first;
  2. fuzzing / fuzz/ cargo-fuzz harness (this PR) — second.

Its opening was adjusted from "now has its first coverage slice" to "now also has its fuzzing slice" so the two paragraphs read as a sequence rather than each claiming to be M9's first. Zero conflict markers remain; no source, test, or CI logic touched.

Verified locally on the merged tree

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — clean (exit 0; fuzz/ excluded from the workspace gate)
  • cargo test --workspace --all-features578 passed / 0 failed
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features — clean

Real use case — why unblocking this matters

ocpi-rs is consumed by evlinked/charge-hub (a hosted OCPI roaming Hub) and evlinked/stationlink. A Hub deserializes JSON authored by every partner it roams with, so the un-panickable deserialize boundary this fuzz harness guards is the difference between "reject one malformed payload" and "a single hostile POST takes the Hub down." While this PR sat dirty it could not land, leaving that trust-boundary guarantee claimed in the roadmap rather than enforced in the build — the merge conflict, not the code, was the only thing blocking it. It is now clean against current main and ready for the owner to merge (individually, or via the drain #243 which carries the same resolution).

Claude session: https://claude.ai/code/session_01SMe7KK1CpE73cvgAVDpwLV


Generated by Claude Code

@duyhuynh-vn
duyhuynh-vn merged commit cd23ba8 into main Jul 25, 2026
13 of 14 checks passed
@duyhuynh-vn
duyhuynh-vn deleted the claude/compassionate-euler-ckhnet branch July 25, 2026 05:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants