test(M9): fuzz the JSON deserialization trust boundary — cargo-fuzz harness - #227
Conversation
…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
🌙 Merge note — ready-to-apply README resolution (this PR conflicts with #223 at merge time)This PR is clean against 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 **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 |
…euler-ckhnet # Conflicts: # README.md
🌙 Conflict resolved on-branch — this PR is mergeable againThe What was resolvedOne hunk,
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
Real use case — why unblocking this matters
Claude session: https://claude.ai/code/session_01SMe7KK1CpE73cvgAVDpwLV Generated by Claude Code |
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 andocpi-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):
fuzz/cargo-fuzzmember, excluded from the default workspace — nightly-only libFuzzer runtime, its own[workspace]table +exclude = ["fuzz"]in the rootCargo.toml— socargo build/test --workspaceand the stable-D warningsCI gates are unaffected, and no shipping crate gains a dependency.envelope(OcpiResponse),versions(Vec<Version>— the/versionscatalogue the 3.0-recognition fix M9: OCPI 3.0 forward-scaffold — recogniseVersionNumber::V3_0(recognition-only, no type surface), mirroring the resolved 2.0 slice (#182) #219 hardened), and thelocation/session/cdr/tariff/tokencomposites. Each callsserde_json::from_slice::<T>(data)and asserts the invariant — never panic;Ok(_)or a cleanErr(_)only.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.mddocuments run instructions + the invariant; a README M9 note records the slice.Real use case — why this PR is needed
ocpi-rsis 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 malformedLocation/Session/Cdrthat 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. Theversionstarget is pointed: a partner's/versionscatalogue is the very first thing negotiation parses, so a crash there breaks the handshake before a mutual version can even be chosen.Spec reference
v1.3.0+).CLAUDE.md;#[forbid(unsafe_code)]).crates/ocpi-server/src/lib.rs(receiver routers) andcrates/ocpi-client/src/lib.rs(response parsing) — the deserialize entry points these targets drive.specs/ocpi/2.2.1/*.asciidocexample payloads (via the existing module unit-test fixtures).Test plan
cargo fmt --all -- --check— cleancargo 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 CIcargo +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
unsafe.libfuzzer-sysis a dependency of the excludedfuzz/member only.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 forTariff/Cdrversion-forks (v2_2/v2_3_0) and the PaymentsTerminalobject.cargo install cargo-fuzz(seefuzz/README.md).🤖 Generated with Claude Code
Claude session: https://claude.ai/code/session_01G6JopqD56JsuTLsf4HLfuT
Generated by Claude Code