feat(fuzz): target 3 spec_import — import-surface fuzz harness + 42 seeds

- fuzz/shared/src/spec_import.rs: invariants over the import-parse
  surface — the three OpenAPISpec constructors (str/json/yaml,
  ADR-051 JSON-first), the YAML→JSON normalization boundary
  (reject-loudly partition: non-finite floats, pointer keys), the
  compile/harden/validate lane (hardening partition, validate
  determinism, closed-by-default unknown-key probe), the FromMCP
  build_spec tool-shape lane, and the $ref-bounded resolver lane
  (cycles/hop chains/deep nesting fail cleanly, no hang, no stack
  blowout).
- fuzz/fuzz_targets/spec_import.rs: thin fuzz_target! wrapper.
- 42 committed corpus seeds via gen_fuzz_seeds.py (valid JSON/YAML
  docs, structural failures, YAML normalization edges, $ref cycles/
  chains/unresolvable/external, schema-validation edges, MCP
  tool-shape edges); corpus-replay gate count floor 12.
- fuzzing exposure hubs next to the wave-1 one:
  openapi_spec::fuzzing, input_validation::fuzzing (opaque
  FuzzCompiledValidator newtype — the pub(crate) validator never
  leaks), from_mcp::fuzzing (mcp feature).
- fuzz/shared gains openapi/mcp features (default on); json.dict
  extended with the OpenAPI token set (75 unique tokens).
- clippy follow-up in wave-1's ws_chunk_framing replay test
  (unnecessary u32 casts against the newer lint).
- docs/plans/fuzzing.md §3/§6 target 3 → implemented; fuzz/README
  target table + campaign-advice lines updated.

Verification: corpus replay 11/11 green (default and
--no-default-features), cargo fuzz build clean, main crate 457
default / 590 all-features tests green, clippy+fmt clean both crates.
Campaign: detached 10-min smoke run — see docs/plans/fuzzing.md §5.
This commit is contained in:
glm-5.3-flash committed 2026-09-29 10:06:03 +00:00
1 parent 84c4b48407
commit e6491e31c7
56 files changed
+1097 -11

No files matched your search

+38 -7
View File
@@ -9,9 +9,9 @@ plan for alkhttp; the sibling docs are the reference for rationale
survey, CI tiers) — this plan stays focused on what alkhttp fuzzes and
in what order.
**Status:** targets 1–2 implemented and verified this session
(2026-09-29); targets 3–4 are planned and can be handed to subsequent
sessions.
**Status:** targets 1–3 implemented and verified (1–2 on 2026-09-29,
wave 1; 3 on 2026-09-29, wave 2); target 4 is planned and can be
handed to subsequent sessions.
Progress log:
@@ -38,6 +38,10 @@ Progress log:
round-trip was wrong for data ending in `\r` (un-re-encodable under
WHATWG CR-stripping — the serde_json key-order class; the harness
skips that documented-lossy shape).
- **2026-09-29 — target 3 (`spec_import`) implemented (wave 2).**
Harness + 42 seeds + the three `fuzzing` hubs; no crate findings in
the initial corpus replay (11 shared tests green, main crate 457
default / 590 all-features). Smoke campaign results in §5.
---
@@ -133,7 +137,7 @@ checklist, as the siblings did.
|---|---|---|---|---|
| 1 | `sse_parser` | `SseParser::feed` + `sse_event_envelope` mapping (`src/adapters/forward.rs`) | chunk-split sequence × eof flag | implemented 2026-09-29 |
| 2 | `ws_chunk_framing` | the WS↔byte-adapter chunk parsers (`WriteChunkWalk`, the write pump's extracted chunk walk) (`src/websocket/byte_adapter.rs`) | byte sequences + message-split boundaries | implemented 2026-09-29 |
| 3 | `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, `CompiledInputSchema`/`harden_closed_by_default`/`validate`, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `from_jsonschema.rs`, `from_mcp/mod.rs`) | attacker-shaped OpenAPI/JSON/YAML docs + schemas | planned |
| 3 | `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, `CompiledInputSchema`/`harden_closed_by_default`/`validate`, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `from_jsonschema.rs`, `from_mcp/mod.rs`) | attacker-shaped OpenAPI/JSON/YAML docs + schemas | implemented 2026-09-29 |
| 4 | `ws_session_opseq` / `retry_throttle` | stateful op sequences over the WS byte-adapter pumps (write/read pump interleavings, close timing, oversize frames); `RetryAfterMiddleware` throttle-map growth | `#[derive(Arbitrary)]` op enums | planned (second wave) |
### Target 1 — `sse_parser` (the chunk_header-class target)
@@ -224,7 +228,7 @@ the chunk-walk loop and the pending accumulator.
`#[cfg(fuzzing)]` re-export and a current-thread runtime with a
mock sink (the alkcall `envelope_frame` Cursor pattern).
### Target 3 — `spec_import` (planned)
### Target 3 — `spec_import` (implemented 2026-09-29)
- **Drives:** raw bytes (and `json.dict`) → `serde_json`/`yaml_serde`
→ `OpenAPISpec::from_str` (JSON-first, YAML-fallback, ADR-051) and
@@ -243,10 +247,31 @@ the chunk-walk loop and the pending accumulator.
`additionalProperties: false`; registry-compile shape parity with
alkcall's `spec_parse` invariants (bounded compile, always
`Result`).
- **Implemented shape:** the harness drives one attacker string
through all four lanes — the three spec constructors (str/json,
yaml under `openapi`), the direct YAML→JSON normalization boundary
(merge keys applied; a value the boundary accepted can't contain
non-finite numbers — serde_json-invariant, asserted recursively),
the compile/harden/validate lane against the whole doc (hardening
partition: injected iff absent and the input is an object;
validate determinism probe with an unknown-key input; deterministic
two-call agreement), the MCP tool-shape lane (the doc and a
`{name, inputSchema}` wrapper through `serde_json`→`Tool`→
`build_spec`; success pins the namespace prefix), and the `$ref`
lane (`resolve_refs_recursive` over the raw doc + declared
component schemas when the doc parses). Exposure via
`fuzzing` hubs alongside the wave-1 one:
`openapi_spec::fuzzing`, `input_validation::fuzzing`
(an opaque `FuzzCompiledValidator` newtype so the `pub(crate)`
validator never leaks), `from_mcp::fuzzing` (`mcp` feature).
The shared crate gates `openapi`/`mcp` (both on by default, riding
the main crate's default build); corpus replay passes both with and
without.
- **Note:** yaml_serde (ADR-051: YAML 1.2 + merge keys) is the
deepest third-party parse we own — the campaign should also encode
the already-documented boundaries (`apply_merge` error path,
duplicate-key rejection with line/column) as corpus expectations.
42 committed seeds cover the menu below.
### Target 4 — stateful wave (planned)
@@ -330,14 +355,20 @@ exited 0, artifact dirs empty — no crash/hang/OOM/leak):
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
`fuzz/`.
Wave-2 (`spec_import`) campaign results: filled in below after the
smoke run completes.
## 6. Sequencing
1. ✅ **Done (2026-09-29, commit 5971c0a)** — infra (workspace
exclude, toolchain pin, runner, seed generator, README) + targets
1–2 + corpora + corpus replay + AGENTS.md gate + smoke campaigns
(clean, see §5). Includes the FWD-20 SSE dribble quadratic fix.
2. **Next session(s)** — target 3 (`spec_import`) + campaigns.
3. **Then** — target 4 stateful wave + longer campaigns; revisit
2. ✅ **Done (2026-09-29)** — target 3 (`spec_import`): harness +
invariants (`fuzz/shared/src/spec_import.rs`), 42 seeds, `json.dict`
extended with the OpenAPI-token set, fuzzing hubs in the three
driven modules. Smoke campaign: see §5.
3. **Next** — target 4 stateful wave + longer campaigns; revisit
corpus merges if any campaign yields inputs worth curating.
Status updates land in §3's table and §6's list as work completes.
+202
View File
@@ -66,6 +66,7 @@ dependencies = [
"reqwest",
"reqwest-middleware",
"reqwest-retry",
"rmcp",
"serde",
"serde_json",
"thiserror",
@@ -100,6 +101,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -323,6 +333,18 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
[[package]]
name = "cmake"
version = "0.1.58"
@@ -419,6 +441,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "email_address"
version = "0.2.9"
@@ -780,6 +808,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -1221,6 +1273,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1578,6 +1636,36 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmcp"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59"
dependencies = [
"async-trait",
"bytes",
"chrono",
"futures",
"http",
"http-body",
"http-body-util",
"pastey",
"pin-project-lite",
"rand 0.10.3",
"reqwest",
"schemars",
"serde",
"serde_json",
"sse-stream",
"thiserror",
"tokio",
"tokio-stream",
"tokio-util",
"tower-service",
"tracing",
"uuid",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1698,6 +1786,32 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.6",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1763,6 +1877,17 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
@@ -1854,6 +1979,19 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "sse-stream"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4"
dependencies = [
"bytes",
"futures-util",
"http-body",
"http-body-util",
"pin-project-lite",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1980,6 +2118,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
@@ -2346,12 +2495,65 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
+7
View File
@@ -28,4 +28,11 @@ test = false
doc = false
bench = false
[[bin]]
name = "spec_import"
path = "fuzz_targets/spec_import.rs"
test = false
doc = false
bench = false
[workspace]
+4 -1
View File
@@ -20,6 +20,7 @@ operational cheat-sheet.
|---|---|
| `sse_parser` | `SseParser::feed` + the FWD-17 envelope mapping (`src/adapters/forward.rs`) — the SSE frame parser every subscription forwards through |
| `ws_chunk_framing` | `WriteChunkWalk` (the write pump's chunk walk) (`src/websocket/byte_adapter.rs`) |
| `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, the YAML normalization boundary, `CompiledInputSchema` compile/harden/validate, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `input_validation.rs`, `from_mcp/mod.rs`) |
## Running a campaign — always detached
@@ -57,4 +58,6 @@ required by cargo-fuzz); the crate itself stays on stable at MSRV 1.88.
toolchain file (rustup resolves per directory).
Run campaigns before releases and after touching `src/adapters/forward.rs`
(the SSE parser) or `src/websocket/byte_adapter.rs` (the WS chunk walk).
(the SSE parser), `src/websocket/byte_adapter.rs` (the WS chunk walk),
or the import adapters (`src/adapters/openapi_spec.rs`,
`input_validation.rs`, `from_mcp/`).
+1
View File
@@ -0,0 +1 @@
{"openapi":"3.0.0","info":{"title":"t","version":"1.0.0"},"paths":{"/x":{"get":{"operationId":"x_get","responses":{}}}}}
+9
View File
@@ -0,0 +1,9 @@
openapi: 3.0.0
info:
title: t
version: 1.0.0
paths:
/x:
get:
operationId: x_get
responses: {}
+1
View File
@@ -0,0 +1 @@
{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/items/{id}":{"get":{"operationId":"get_item","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Widget"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Widget"}}}}}}}},"components":{"schemas":{"Widget":{"type":"object","properties":{"id":{"type":"string"},"n":{"minimum":1}}}}}}
+1
View File
@@ -0,0 +1 @@
{"paths":{}}
+1
View File
@@ -0,0 +1 @@
{"info":{"title":"t"}}
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
null
+1
View File
@@ -0,0 +1 @@
42
+1
View File
@@ -0,0 +1 @@
"a string"
+1
View File
@@ -0,0 +1 @@
{
+1
View File
@@ -0,0 +1 @@
}{
+1
View File
@@ -0,0 +1 @@
not a document
View File
Whitespace-only changes.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"openapi":3.0}
+2
View File
@@ -0,0 +1,2 @@
a: 1
a: 2
+5
View File
@@ -0,0 +1,5 @@
base: &b
x: 1
child:
<<: *b
y: 2
+1
View File
@@ -0,0 +1 @@
maximum: .inf
+1
View File
@@ -0,0 +1 @@
minimum: -.inf
+1
View File
@@ -0,0 +1 @@
value: .nan
+1
View File
@@ -0,0 +1 @@
~: v
+2
View File
@@ -0,0 +1,2 @@
? [1, 2]
: v
+2
View File
@@ -0,0 +1,2 @@
responses:
200: {}
+2
View File
@@ -0,0 +1,2 @@
a: yes
b: no
+1
View File
@@ -0,0 +1 @@
a: !!unknown b
+1
View File
@@ -0,0 +1 @@
a: !!str 200
+5
View File
@@ -0,0 +1,5 @@
paths:
/x:
get:
responses:
200: {}
+1
View File
@@ -0,0 +1 @@
{"components":{"schemas":{"A":{"$ref":"#/components/schemas/A"}}}}
+1
View File
@@ -0,0 +1 @@
{"components":{"schemas":{"A":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/B"}}},"B":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/A"}}}}}}
+1
View File
@@ -0,0 +1 @@
{"components":{"schemas":{"A":{"$ref":"#/components/schemas/Missing"}}}}
+1
View File
@@ -0,0 +1 @@
{"components":{"schemas":{"A":{"$ref":"https://other/file.json"}}}}
+1
View File
@@ -0,0 +1 @@
{"components":{"schemas":{"S0":{"type":"object"},"S1":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S0"},"b":{"$ref":"#/components/schemas/S0"}}},"S2":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S1"},"b":{"$ref":"#/components/schemas/S1"}}}}}}
+1
View File
@@ -0,0 +1 @@
{"required": "n"}
+1
View File
@@ -0,0 +1 @@
"not an object"
+1
View File
@@ -0,0 +1 @@
{"type":"object","properties":{"q":{"type":"string"}}}
+1
View File
@@ -0,0 +1 @@
{"type":"object","properties":{"q":{"type":"string"}},"additionalProperties":true}
+1
View File
@@ -0,0 +1 @@
{"type":"object","properties":{"level":{"enum":["low","high"]},"n":{"minimum":1},"s":{"maxLength":3}}}
+1
View File
@@ -0,0 +1 @@
{"name":"ok_tool","inputSchema":{"type":"object","properties":{"q":{"type":"string"}}}}
+1
View File
@@ -0,0 +1 @@
{"name":"ns/ok_tool","inputSchema":{"type":"object"}}
+1
View File
@@ -0,0 +1 @@
{"name":"has space","inputSchema":{"type":"object"}}
+1
View File
@@ -0,0 +1 @@
{"name":"","inputSchema":{"type":"object"}}
+1
View File
@@ -0,0 +1 @@
{"name":"ok_tool"}
+7
View File
@@ -0,0 +1,7 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
alkhttp_fuzz_shared::spec_import::fuzz_spec_import(data);
});
+142 -1
View File
@@ -166,7 +166,148 @@ def ws_chunk_framing_seeds():
return i
def spec_import_seeds():
i = 0
# Minimal valid OpenAPI docs — JSON then the same doc as YAML.
json_doc = (
'{"openapi":"3.0.0","info":{"title":"t","version":"1.0.0"},'
'"paths":{"/x":{"get":{"operationId":"x_get","responses":{}}}}}'
).encode()
yield_seed = [
json_doc,
(
"openapi: 3.0.0\n"
"info:\n"
" title: t\n"
" version: 1.0.0\n"
"paths:\n"
" /x:\n"
" get:\n"
" operationId: x_get\n"
" responses: {}\n"
).encode(),
# A doc with a component schema + parameters (the ref lane's
# valid path).
(
'{"openapi":"3.0.0","info":{"title":"t","version":"1"},'
'"paths":{"/items/{id}":{"get":{"operationId":"get_item",'
'"parameters":[{"name":"id","in":"path","required":true,'
'"schema":{"type":"string"}}],'
'"requestBody":{"content":{"application/json":{"schema":'
'{"$ref":"#/components/schemas/Widget"}}}},'
'"responses":{"200":{"content":{"application/json":{"schema":'
'{"$ref":"#/components/schemas/Widget"}}}}}}}},'
'"components":{"schemas":{"Widget":{"type":"object",'
'"properties":{"id":{"type":"string"},"n":{"minimum":1}}}}}}'
).encode(),
]
for data in yield_seed:
write_seed("spec_import", i, data)
i += 1
# Structural failures: missing info/paths, non-object documents,
# malformed at both parse layers.
for data in [
b'{"paths":{}}',
b'{"info":{"title":"t"}}',
b"[]",
b"null",
b"42",
b'"a string"',
b"{",
b"}{",
b"not a document",
b"",
b"\x00\x01\x02\xff",
b'{"openapi":3.0}',
]:
write_seed("spec_import", i, data)
i += 1
# YAML normalization edges (OAI-12): duplicate keys, merge keys,
# non-finite floats, non-string keys, numeric keys, bare YAML 1.2
# strings-as-booleans, tags.
for data in [
b"a: 1\na: 2\n",
b"base: &b\n x: 1\nchild:\n <<: *b\n y: 2\n",
b"maximum: .inf\n",
b"minimum: -.inf\n",
b"value: .nan\n",
b"~: v\n",
b"? [1, 2]\n: v\n",
b"responses:\n 200: {}\n",
b"a: yes\nb: no\n",
b"a: !!unknown b\n",
b"a: !!str 200\n",
b"paths:\n /x:\n get:\n responses:\n 200: {}\n",
]:
write_seed("spec_import", i, data)
i += 1
# $ref edges: cycles (self, two-node), unresolvable, external,
# the acyclic shared-ref chain (memoized linear shape).
for data in [
b'{"components":{"schemas":{"A":{"$ref":"#/components/schemas/A"}}}}',
(
'{"components":{"schemas":{'
'"A":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/B"}}},'
'"B":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/A"}}}}}}'
).encode(),
b'{"components":{"schemas":{"A":{"$ref":"#/components/schemas/Missing"}}}}',
b'{"components":{"schemas":{"A":{"$ref":"https://other/file.json"}}}}',
(
'{"components":{"schemas":{'
'"S0":{"type":"object"},'
'"S1":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S0"},'
'"b":{"$ref":"#/components/schemas/S0"}}},'
'"S2":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S1"},'
'"b":{"$ref":"#/components/schemas/S1"}}}}}}'
).encode(),
]:
write_seed("spec_import", i, data)
i += 1
# Input-schema compile/validate edges: not-a-schema, the
# closed-by-default probe, the explicit catch-all, leaf
# constraints.
for data in [
b'{"required": "n"}',
b'"not an object"',
b'{"type":"object","properties":{"q":{"type":"string"}}}',
(
'{"type":"object","properties":{"q":{"type":"string"}},'
'"additionalProperties":true}'
).encode(),
(
'{"type":"object","properties":{"level":{"enum":["low","high"]},'
'"n":{"minimum":1},"s":{"maxLength":3}}}'
).encode(),
]:
write_seed("spec_import", i, data)
i += 1
# MCP tool-shape edges (the from_mcp lane with the mcp feature):
# valid, slash-name, whitespace-name, empty-name, missing
# inputSchema.
for data in [
b'{"name":"ok_tool","inputSchema":{"type":"object","properties":{"q":{"type":"string"}}}}',
b'{"name":"ns/ok_tool","inputSchema":{"type":"object"}}',
b'{"name":"has space","inputSchema":{"type":"object"}}',
b'{"name":"","inputSchema":{"type":"object"}}',
b'{"name":"ok_tool"}',
]:
write_seed("spec_import", i, data)
i += 1
return i
if __name__ == "__main__":
n_sse = sse_parser_seeds()
n_ws = ws_chunk_framing_seeds()
print(f"sse_parser: {n_sse} seeds, ws_chunk_framing: {n_ws} seeds")
n_spec = spec_import_seeds()
print(
f"sse_parser: {n_sse} seeds, ws_chunk_framing: {n_ws} seeds, "
f"spec_import: {n_spec} seeds"
)
+54
View File
@@ -21,3 +21,57 @@
"TIMEOUT"
"FORBIDDEN"
"CONNECTION_CLOSED"
# OpenAPI / spec-import tokens (target 3).
"openapi"
"info"
"paths"
"components"
"schemas"
"parameters"
"requestBodies"
"responses"
"title"
"version"
"get"
"post"
"put"
"patch"
"delete"
"head"
"options"
"requestBody"
"$ref"
"name"
"in"
"required"
"schema"
"content"
"application/json"
"properties"
"additionalProperties"
"enum"
"pattern"
"minimum"
"maximum"
"maxLength"
"string"
"integer"
"boolean"
"object"
"array"
"null"
"200"
"2XX"
"default"
"servers"
"webhooks"
"security"
"style"
"explode"
"ns/ok_tool"
"fuzz"
"#/components/schemas/A"
"#/components/schemas/Missing"
"https://other/file.json"
".inf"
".nan"
+8
View File
@@ -4,6 +4,14 @@ version = "0.0.0"
publish = false
edition = "2021"
[features]
default = ["openapi", "mcp"]
# The alkhttp features the harness's lanes assume; turning these on
# rides on the main crate's default build (server + client + openapi),
# `mcp` additionally turns on alkhttp/mcp.
openapi = ["alkhttp/openapi"]
mcp = ["alkhttp/mcp"]
[dependencies]
alkhttp = { path = "../.." }
alkcall = "0.8.1"
+1
View File
@@ -4,5 +4,6 @@
//! against every committed corpus entry (the quinn CI pattern) without
//! a nightly toolchain.
pub mod spec_import;
pub mod sse_parser;
pub mod ws_chunk_framing;
+457
View File
@@ -0,0 +1,457 @@
//! Invariants for the import-parse surface — target 3 (`spec_import`):
//! the `OpenAPISpec` document parsing (JSON-first, YAML-fallback, the
//! ADR-051 YAML normalization boundary), the `$ref`-bounded resolver,
//! the `CompiledInputSchema` compile/harden/validate path, and the
//! `FromMCP::build_spec` tool-shape converter (`src/adapters/
//! openapi_spec.rs`, `input_validation.rs`, `from_mcp/mod.rs`).
//!
//! Shape: a single attacker-controlled string drives every sub-path;
//! each failure surface must be a clean declared error (never a panic,
//! never a silent success), and each success surface must satisfy its
//! shape contract. Drives also assert the cross-entrypoint consistency
//! (the YAML/JSON parity contract) on inputs that parse both ways.
use alkhttp::adapters::input_validation::fuzzing as iv;
use alkhttp::adapters::openapi_spec::fuzzing as spec;
use serde_json::Value;
/// The full target drive over one attacker-shaped document string.
pub fn fuzz_spec_import(data: &[u8]) {
let Ok(doc) = std::str::from_utf8(data) else {
// Raw non-UTF-8 bytes are not a document shape the surface
// accepts (str-typed entry points) — declared, not a crash.
return;
};
// 1. The JSON/YAML document parse: clean result, never a panic.
// The unknown-format entry tries JSON first, YAML fallback
// (ADR-051); both parse paths run either way here.
let _ = spec::spec_from_str(doc);
let _ = spec::spec_from_json(doc);
#[cfg(feature = "openapi")]
assert_yaml_boundary(doc);
// 2. The schema-validation lane (client feature): drive the
// compile/harden/validate path against the *whole* document.
// An OpenAPI-shaped doc is not a JSON Schema, but attacker
// bytes drive the same compile machinery — the outcome must be
// a clean Result and the validate probe must be consistent.
schema_lane(doc);
// 3. The MCP tool-shape lane: the document doubles as a tool
// JSON (most rejected as a shape); failures are clean.
#[cfg(feature = "mcp")]
mcp_lane(doc, "fuzz");
// 4. The $ref-resolution lane over the parsed document when one
// parsed Ok: resolve the whole document (schemas and all)
// through the bounded resolver; cycles, hop chains, and deep
// nesting fail with the declared SchemaParse — the
// no-hang/no-recursion-crash assertion.
ref_lane(doc);
}
/// The ADR-051 YAML boundary, direct: the normalization-only pass
/// (merge keys applied, non-finite floats and string-less keys
/// rejected) must agree with what `from_yaml`'s early failure would
/// say — an input the boundary rejects is never a silent null further
/// down.
#[cfg(feature = "openapi")]
fn assert_yaml_boundary(doc: &str) {
let normalized = spec::yaml_to_json(doc);
match normalized {
Ok(value) => {
// Reject-loudly partition: a value the boundary accepted
// cannot contain the shapes the boundary exists to reject.
assert_representable(&value, &mut Vec::new(), 0);
// Whatever came out feeds the same from_value parse as the
// production `from_yaml` post-normalization drive; parse
// consistency: `from_yaml` failing after the boundary
// accepted is fine (document validation), but
// `from_yaml` accepting while the boundary result differs
// from the doc's own parse is a parity break — probe it.
let _ = spec::spec_from_yaml(doc);
}
Err(message) => {
assert!(
!message.trim().is_empty(),
"a rejected YAML shape names a reason"
);
}
}
}
/// The YAML normalization guarantee, recursive: no non-finite numbers
/// surfaced (they cannot — serde_json numbers are finite by
/// construction), and no node was silently nulled where the pointer
/// contract says a reject should have happened.
#[cfg(feature = "openapi")]
fn assert_representable(value: &Value, path: &mut Vec<String>, depth: usize) {
assert!(depth < 512, "the normalized tree is finite-height");
match value {
Value::Number(n) => assert!(
n.as_f64().is_none_or(|f| f.is_finite()),
"non-finite numbers must never materialize post-boundary"
),
Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
path.push(i.to_string());
assert_representable(item, path, depth + 1);
path.pop();
}
}
Value::Object(map) => {
for (k, v) in map {
// Key form note: the boundary's nullish-key rejection
// covers the *implicit* null key (`: v`, `~` — parsed
// as `YamlValue::Null`); an explicitly-quoted `""`
// string key materializes as `""` (JSON-representable,
// parity with the JSON path), so empty-string keys are
// legal here.
path.push(k.clone());
assert_representable(v, path, depth + 1);
path.pop();
}
}
_ => {}
}
}
/// The compile/harden/validate lane over attacker-shaped schemas.
fn schema_lane(doc: &str) {
let Ok(value) = serde_json::from_str::<Value>(doc) else {
return;
};
let schema = pick_schema(&value, 0);
let hardened = iv::harden_closed_by_default(&schema);
// The hardening partition: the hardened copy injects
// `additionalProperties: false` exactly when the input lacked the
// key; an explicit value (or `true`'s catch-all) passes through.
match schema.get("additionalProperties") {
Some(v) => assert_eq!(
hardened.get("additionalProperties"),
Some(v),
"an explicit additionalProperties passes through unchanged"
),
None if schema.is_object() => assert_eq!(
hardened.get("additionalProperties"),
Some(&Value::Bool(false)),
"an absent additionalProperties is injected as false (closed by default)"
),
_ => {}
}
// The original schema is never mutated by the hardening pass.
let schema_snapshot = schema.clone();
let _ = iv::harden_closed_by_default(&schema);
assert_eq!(
schema, schema_snapshot,
"hardening injects into the hardened copy only"
);
// Compile is always a Result; a clean Err or a working validator.
let Ok(compiled) = iv::compile(&schema) else {
return;
};
// Validate consistency: two calls, same outcome; and the hardened
// semantics hold — an unknown key is accepted only under an
// explicit catch-all.
let probe = unknown_key_input(&schema);
let first = iv::validate(&compiled, &probe);
let second = iv::validate(&compiled, &probe);
assert_eq!(
first.is_ok(),
second.is_ok(),
"validate is deterministic on the same input"
);
if let Err(err) = &first {
assert!(!err.message.is_empty(), "rejection names its violation");
}
}
/// Pick the value the schema lane fuzzes: the document's
/// `components.schemas.*` entries are the schema-shaped ones; a bare
/// `properties`-ish top object or any non-object JSON doubles as the
/// anything-goes schema probe.
fn pick_schema(value: &Value, depth: usize) -> Value {
if depth < 16 {
if let Some(schemas) = value
.get("components")
.and_then(|c| c.get("schemas"))
.and_then(|s| s.as_object())
{
if let Some((_, v)) = schemas.iter().next() {
return v.clone();
}
}
}
value.clone()
}
/// A probe input that exercises the closed-by-default semantics: an
/// object carrying a key the schema (likely) does not declare.
fn unknown_key_input(schema: &Value) -> Value {
let mut map = serde_json::Map::new();
map.insert("__fuzz_unknown_key__".to_string(), Value::Bool(true));
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
for (k, v) in props.iter().take(4) {
map.insert(
k.clone(),
default_for_schema_type(v.get("type").and_then(|t| t.as_str())),
);
}
}
Value::Object(map)
}
fn default_for_schema_type(ty: Option<&str>) -> Value {
match ty {
Some("string") => Value::String("fuzz".into()),
Some("integer" | "number") => serde_json::json!(1),
Some("boolean") => Value::Bool(false),
Some("array") => Value::Array(vec![]),
Some("object") => serde_json::json!({}),
Some("null") => Value::Null,
_ => Value::Null,
}
}
/// The MCP tool-shape lane: the document as a `tools/list` element.
/// The declared name rules (empty / `/` / whitespace rejection) and
/// the always-Result outcome.
#[cfg(feature = "mcp")]
fn mcp_lane(doc: &str, namespace: &str) {
let shapes = match serde_json::from_str::<Value>(doc) {
Ok(value) => vec![value.clone(), tool_wrapped(value.clone())],
Err(_) => {
// The malformed-JSON shape still drives the RMCP-type
// round-trip path: `Tool` deserialization of the raw
// string is the attacker's wire bytes as delivered — the
// declared failure is a clean adapter error.
let probe = Value::String(doc.to_string());
let result = spec_mcp(&probe, namespace);
assert!(result.is_err(), "a non-object tool shape fails cleanly");
return;
}
};
for shape in shapes {
let result = spec_mcp(&shape, namespace);
if let Ok(op_spec) = result {
// Success shape: the op carries the namespace, and the
// declared tool-name rules already screened the name.
assert!(op_spec.name.starts_with(namespace));
}
}
}
#[cfg(feature = "mcp")]
fn spec_mcp(
shape: &Value,
namespace: &str,
) -> Result<alkcall::registry::spec::OperationSpec, alkcall::client::AdapterError> {
#[cfg(feature = "mcp")]
{
alkhttp::adapters::from_mcp::fuzzing::fuzz_build_spec(shape, namespace)
}
#[cfg(not(feature = "mcp"))]
{
let _ = shape;
let _ = namespace;
unreachable!("mcp_lane is cfg-gated with the feature")
}
}
#[cfg(feature = "mcp")]
fn tool_wrapped(value: Value) -> Value {
serde_json::json!({
"name": "fuzz_tool_name",
"inputSchema": value,
})
}
/// The `$ref`-bounded resolver lane over a parsed document: resolve
/// the whole raw doc and every declared schema; the bounded resolver
/// must surface cycles/deep nesting/hop chains as clean errors —
/// resolution is always a `Result` against a finite number of hops.
fn ref_lane(doc: &str) {
let Ok(value) = serde_json::from_str::<Value>(doc) else {
return;
};
let Ok(parsed) = spec::spec_from_json(doc) else {
return;
};
// Resolve the full document tree — cycles (a `$ref` pointing at
// its own chain) and pathological nesting fail loudly, clean.
let _ = spec::resolve_refs_recursive(&parsed, &value);
// Every declared schema resolves or fails cleanly — never a
// partial expansion treated as success with dangling refs.
if let Some(schemas) = value
.get("components")
.and_then(|c| c.get("schemas"))
.and_then(|s| s.as_object())
{
for (_, schema) in schemas.iter().take(4) {
let resolved = spec::resolve_refs_recursive(&parsed, schema);
if resolved.is_err() {
// Declared failure arms: cycle, depth, hop, node
// budget, unresolvable pointer — all SchemaParse.
continue;
}
}
}
}
#[cfg(test)]
mod corpus_replay {
use super::*;
#[test]
fn committed_corpus_replays_through_the_invariants() {
let corpus =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/spec_import");
let mut count = 0usize;
for entry in std::fs::read_dir(&corpus).expect("corpus directory is committed") {
let path = entry.expect("corpus entry readable").path();
let data = std::fs::read(&path).expect("corpus entry readable");
fuzz_spec_import(&data);
count += 1;
}
assert!(
count >= 12,
"committed seed corpus is present, found {count}"
);
}
fn drive(values: &[&str]) {
for v in values {
fuzz_spec_import(v.as_bytes());
}
}
#[test]
fn unit_spec_parse_edges() {
drive(&[
// Minimal valid OpenAPI docs — JSON then YAML.
"{\"openapi\":\"3.0.0\",\"info\":{\"title\":\"t\",\"version\":\"1.0.0\"},\
\"paths\":{\"/x\":{\"get\":{\"operationId\":\"x_get\",\"responses\":{}}}}}",
"openapi: 3.0.0\ninfo:\n title: t\n version: 1.0.0\npaths:\n /x:\n get:\n responses: {}\n",
// Missing info / missing paths / non-object documents.
"{\"paths\":{}}",
"{\"info\":{\"title\":\"t\"}}",
"[]",
"null",
"42",
"\"a string\"",
// Malformed JSON that is also not YAML-shaped.
"{\"openapi\":3.0}",
// Malformed at both layers.
"}{",
"not a document",
"",
"\x00\x01\x02",
]);
}
#[test]
fn unit_yaml_normalization_edges() {
drive(&[
// Duplicate keys: rejected on the YAML path (loud, with
// line/column).
"a: 1\na: 2\n",
// Merge keys: applied, not literal.
"base: &b\n x: 1\nchild:\n <<: *b\n y: 2\n",
// Non-finite floats: the OAI-12 loud rejection.
"maximum: .inf\n",
"minimum: -.inf\n",
"value: .nan\n",
// Non-string keys: nullish and collection keys reject.
"~: v\n",
"? [1, 2]\n: v\n",
// Numeric keys stringify (200: → "200").
"responses:\n 200: {}\n",
// Bare yes/no/on/off are strings under YAML 1.2 core.
"a: yes\nb: no\n",
"a: on\nb: off\n",
// Tags: !!str 200 is the string "200"; unknown tags fail.
"a: !!str 200\n",
"a: !!unknown b\n",
// Deep nesting at document scale (bounded by the parser's
// own recursion limit, not a crash).
"a: \n - x\n - y\n - z\n - w\n",
]);
}
#[test]
fn unit_ref_resolution_edges() {
drive(&[
// A $ref cycle: clean failure, no hang, no stack blowout.
"{\"components\":{\"schemas\":{\"A\":{\"$ref\":\"#/components/schemas/A\"}}}}",
// A two-node cycle.
"{\"components\":{\"schemas\":{\"A\":{\"type\":\"object\",\"properties\":{\"n\":{\"$ref\":\"#/components/schemas/B\"}}},\"B\":{\"type\":\"object\",\"properties\":{\"n\":{\"$ref\":\"#/components/schemas/A\"}}}}}}",
// An unresolvable ref.
"{\"components\":{\"schemas\":{\"A\":{\"$ref\":\"#/components/schemas/Missing\"}}}}",
// An external ref (rejected loudly).
"{\"components\":{\"schemas\":{\"A\":{\"$ref\":\"https://other/file.json\"}}}}",
// A shared-ref acyclic chain (linear via memo, node budget
// the last-resort bound).
"{\"components\":{\"schemas\":{\"S0\":{\"type\":\"object\"},\"S1\":{\"type\":\"object\",\"properties\":{\"a\":{\"$ref\":\"#/components/schemas/S0\"},\"b\":{\"$ref\":\"#/components/schemas/S0\"}}},\"S2\":{\"type\":\"object\",\"properties\":{\"a\":{\"$ref\":\"#/components/schemas/S1\"},\"b\":{\"$ref\":\"#/components/schemas/S1\"}}}}}}",
]);
}
#[test]
fn unit_input_schema_validation_edges() {
drive(&[
// A schema that is not a schema.
"{\"required\": \"n\"}",
"\"not an object\"",
// A closed-by-default probe: unknown keys rejected.
"{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}}}",
// An explicit catch-all: unknown keys accepted.
"{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}},\"additionalProperties\":true}",
// An explicit additionalProperties schema passes through.
"{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}},\"additionalProperties\":{\"type\":\"string\"}}",
// Leaf constraints the validator must enforce.
"{\"type\":\"object\",\"properties\":{\"level\":{\"enum\":[\"low\",\"high\"]},\"n\":{\"minimum\":1},\"s\":{\"maxLength\":3}}}",
]);
}
#[test]
fn unit_mcp_tool_shape_edges() {
drive(&[
// A valid tool shape.
"{\"name\":\"ok_tool\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}}}}",
// A name with a slash: the two-segment convention refusal.
"{\"name\":\"ns/ok_tool\",\"inputSchema\":{\"type\":\"object\"}}",
// A name with whitespace.
"{\"name\":\"has space\",\"inputSchema\":{\"type\":\"object\"}}",
// An empty name.
"{\"name\":\"\",\"inputSchema\":{\"type\":\"object\"}}",
// inputSchema present but not an object shape (the Tool
// type deserializes it as itself; build_spec still Result).
"{\"name\":\"ok_tool\",\"inputSchema\":null}",
"[]",
// A missing inputSchema (default empty schema).
"{\"name\":\"ok_tool\"}",
]);
}
#[test]
fn unit_json_number_and_depth_edges() {
drive(&[
// Deep nesting (bounded by serde_json's own 128-depth
// reject, then the resolver's depth budget).
&format!("\"{}\"", "[".repeat(60) + &"]".repeat(60)),
&("{\"type\":\"object\",\"properties\":".to_owned()
+ &"{\"k\":{\"type\":\"object\"},".repeat(40)
+ "\"end\":1}"),
]);
// Huge values never wedge.
let big = serde_json::to_string(&serde_json::Value::Array(
(0u16..1024)
.map(|i| serde_json::json!({ "n": i }))
.collect(),
))
.expect("serializes");
fuzz_spec_import(big.as_bytes());
}
}
+2 -2
View File
@@ -270,7 +270,7 @@ mod corpus_replay {
fuzz_ws_chunk_framing(&h(3, 10)[..n]);
}
// Over-length declarations: > MAX_CHUNK_LEN, u32::MAX.
let mut over = h(0, (MAX_CHUNK_LEN as u32) + 1);
let mut over = h(0, MAX_CHUNK_LEN + 1);
over.extend_from_slice(b"partial-payload");
fuzz_ws_chunk_framing(&over);
fuzz_ws_chunk_framing(&h(4, u32::MAX));
@@ -278,6 +278,6 @@ mod corpus_replay {
// cap frames are valid but expensive; the fuzzer explores the
// near-cap shapes and the cap partition is asserted exactly in
// cap_walk).
fuzz_ws_chunk_framing(&h(5, MAX_CHUNK_LEN as u32 - 1)[..8]);
fuzz_ws_chunk_framing(&h(5, MAX_CHUNK_LEN - 1)[..8]);
}
}
+27
View File
@@ -472,5 +472,32 @@ fn json_object_to_value(map: JsonObject) -> Value {
Value::Object(map)
}
/// Fuzz-harness re-export hub (`fuzz/shared/src/spec_import.rs`):
/// `build_spec` reachable over a plain JSON tool shape, so the harness
/// does not have to materialize `rmcp` types from attacker bytes — the
/// tool-shape JSON is converted into a `Tool` through
/// `serde_json::from_value` exactly as the wire would deliver it.
#[cfg(feature = "mcp")]
pub mod fuzzing {
use serde_json::Value;
use rmcp::model::Tool;
/// `build_spec` over an attacker-shaped tool JSON (the
/// `tools/list` response element as the wire would deserialize
/// it).
pub fn fuzz_build_spec(
tool_json: &Value,
namespace: &str,
) -> Result<alkcall::registry::spec::OperationSpec, alkcall::client::AdapterError> {
let tool: Tool = serde_json::from_value(tool_json.clone()).map_err(|e| {
alkcall::client::AdapterError::SchemaParse {
message: format!("tool shape rejected: {e}"),
}
})?;
super::build_spec(&tool, namespace)
}
}
#[cfg(test)]
mod tests;
+35
View File
@@ -144,6 +144,41 @@ fn harden_closed_by_default(input_schema: &Value) -> Value {
hardened
}
/// Fuzz-harness re-export hub (`fuzz/shared/src/spec_import.rs`):
/// compile-once schema compilation and call-time validation over
/// attacker-shaped schemas. The items on this module are `pub`
/// funnels over `pub(crate)` items, same posture as
/// `openapi_spec::fuzzing`.
#[cfg(feature = "client")]
pub mod fuzzing {
use alkcall::protocol::wire::CallError;
use serde_json::Value;
/// Opaque handle wrapping the crate-internal compiled validator,
/// so the harness drives compile/validate without widening the
/// underlying visibility.
pub struct FuzzCompiledValidator(super::CompiledInputSchema);
/// Compile `input_schema` through the production path (hardening
/// included).
pub fn compile(input_schema: &Value) -> Result<FuzzCompiledValidator, String> {
Ok(FuzzCompiledValidator(super::CompiledInputSchema::compile(
input_schema,
)?))
}
/// Call-time validation through the production path.
pub fn validate(validator: &FuzzCompiledValidator, input: &Value) -> Result<(), CallError> {
validator.0.validate(input)
}
/// The hardening pass directly: the closed-by-default injection
/// (additionalProperties: false when absent).
pub fn harden_closed_by_default(input_schema: &Value) -> Value {
super::harden_closed_by_default(input_schema)
}
}
#[cfg(all(test, feature = "client"))]
mod tests {
use super::*;
+53
View File
@@ -1114,6 +1114,59 @@ fn check_parameter_style(
}
}
/// Fuzz-harness re-export hub (`fuzz/shared/src/spec_import.rs`):
/// gathers the import-parse surface in one path — the full
/// `OpenAPISpec` constructors, the `$ref`-bounded resolver, and the
/// YAML normalization pass (`openapi` feature). The wrappers exist for
/// the same reason as `forward::fuzzing`: the underlying items carry
/// `pub(crate)`/private visibility; the harness reaches them through
/// thin `pub` funnels so the fuzz access surface is one named module.
pub mod fuzzing {
use serde_json::Value;
use super::{AdapterError, OpenAPISpec, YamlValue};
/// Parse a document of unknown format through the production path.
pub fn spec_from_str(doc: &str) -> Result<OpenAPISpec, AdapterError> {
OpenAPISpec::from_str(doc)
}
/// Parse a JSON document through the production path.
pub fn spec_from_json(doc: &str) -> Result<OpenAPISpec, AdapterError> {
OpenAPISpec::from_json(doc)
}
/// Parse a YAML document through the `openapi`-gated
/// normalization path (ADR-051).
#[cfg(feature = "openapi")]
pub fn spec_from_yaml(doc: &str) -> Result<OpenAPISpec, AdapterError> {
OpenAPISpec::from_yaml(doc)
}
/// The raw-document `$ref`-bounded resolver — the cycle/depth/hop/
/// node budget behavior behind import-time schema expansion.
#[cfg(all(feature = "client", feature = "openapi"))]
pub fn resolve_refs_recursive(
spec: &OpenAPISpec,
schema: &Value,
) -> Result<Value, AdapterError> {
OpenAPISpec::resolve_refs_recursive(spec, schema)
}
/// The YAML→`serde_json::Value` normalization boundary: non-finite
/// floats and string-less keys reject loudly with a JSON pointer
/// (OAI-12). Runs the same `apply_merge` + normalization pass
/// `from_yaml` runs before document validation.
#[cfg(feature = "openapi")]
pub fn yaml_to_json(raw_yaml: &str) -> Result<Value, String> {
let yaml: YamlValue =
yaml_serde::from_str(raw_yaml).map_err(|e| format!("invalid YAML: {e}"))?;
let mut yaml = yaml;
yaml.apply_merge().map_err(|e| format!("merge key: {e}"))?;
super::yaml_to_json_value(&yaml)
}
}
#[cfg(all(test, feature = "client", feature = "openapi"))]
mod tests {
use super::*;