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
+1098 -12

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 survey, CI tiers) — this plan stays focused on what alkhttp fuzzes and
in what order. in what order.
**Status:** targets 1–2 implemented and verified this session **Status:** targets 1–3 implemented and verified (1–2 on 2026-09-29,
(2026-09-29); targets 3–4 are planned and can be handed to subsequent wave 1; 3 on 2026-09-29, wave 2); target 4 is planned and can be
sessions. handed to subsequent sessions.
Progress log: Progress log:
@@ -38,6 +38,10 @@ Progress log:
round-trip was wrong for data ending in `\r` (un-re-encodable under round-trip was wrong for data ending in `\r` (un-re-encodable under
WHATWG CR-stripping — the serde_json key-order class; the harness WHATWG CR-stripping — the serde_json key-order class; the harness
skips that documented-lossy shape). 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 | | 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 | | 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) | | 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) ### 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 `#[cfg(fuzzing)]` re-export and a current-thread runtime with a
mock sink (the alkcall `envelope_frame` Cursor pattern). 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` - **Drives:** raw bytes (and `json.dict`) → `serde_json`/`yaml_serde`
→ `OpenAPISpec::from_str` (JSON-first, YAML-fallback, ADR-051) and → `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 `additionalProperties: false`; registry-compile shape parity with
alkcall's `spec_parse` invariants (bounded compile, always alkcall's `spec_parse` invariants (bounded compile, always
`Result`). `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 - **Note:** yaml_serde (ADR-051: YAML 1.2 + merge keys) is the
deepest third-party parse we own — the campaign should also encode deepest third-party parse we own — the campaign should also encode
the already-documented boundaries (`apply_merge` error path, the already-documented boundaries (`apply_merge` error path,
duplicate-key rejection with line/column) as corpus expectations. duplicate-key rejection with line/column) as corpus expectations.
42 committed seeds cover the menu below.
### Target 4 — stateful wave (planned) ### 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 - Toolchain notes live in `fuzz/README.md`; nightly stays confined to
`fuzz/`. `fuzz/`.
Wave-2 (`spec_import`) campaign results: filled in below after the
smoke run completes.
## 6. Sequencing ## 6. Sequencing
1. ✅ **Done (2026-09-29, commit 5971c0a)** — infra (workspace 1. ✅ **Done (2026-09-29, commit 5971c0a)** — infra (workspace
exclude, toolchain pin, runner, seed generator, README) + targets exclude, toolchain pin, runner, seed generator, README) + targets
1–2 + corpora + corpus replay + AGENTS.md gate + smoke campaigns 1–2 + corpora + corpus replay + AGENTS.md gate + smoke campaigns
(clean, see §5). Includes the FWD-20 SSE dribble quadratic fix. (clean, see §5). Includes the FWD-20 SSE dribble quadratic fix.
2. **Next session(s)** — target 3 (`spec_import`) + campaigns. 2. ✅ **Done (2026-09-29)** — target 3 (`spec_import`): harness +
3. **Then** — target 4 stateful wave + longer campaigns; revisit 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. corpus merges if any campaign yields inputs worth curating.
Status updates land in §3's table and §6's list as work completes. Status updates land in §3's table and §6's list as work completes.
+202
View File
@@ -66,6 +66,7 @@ dependencies = [
"reqwest", "reqwest",
"reqwest-middleware", "reqwest-middleware",
"reqwest-retry", "reqwest-retry",
"rmcp",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
@@ -100,6 +101,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.104" version = "1.0.104"
@@ -323,6 +333,18 @@ dependencies = [
"rand_core 0.10.1", "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]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.58" version = "0.1.58"
@@ -419,6 +441,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "email_address" name = "email_address"
version = "0.2.9" version = "0.2.9"
@@ -780,6 +808,30 @@ dependencies = [
"tracing", "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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.3.0" version = "2.3.0"
@@ -1221,6 +1273,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1578,6 +1636,36 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -1698,6 +1786,32 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -1763,6 +1877,17 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.151" version = "1.0.151"
@@ -1854,6 +1979,19 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -1980,6 +2118,17 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tokio-tungstenite" name = "tokio-tungstenite"
version = "0.29.0" version = "0.29.0"
@@ -2346,12 +2495,65 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
+7
View File
@@ -28,4 +28,11 @@ test = false
doc = false doc = false
bench = false bench = false
[[bin]]
name = "spec_import"
path = "fuzz_targets/spec_import.rs"
test = false
doc = false
bench = false
[workspace] [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 | | `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`) | | `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 ## 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). toolchain file (rustup resolves per directory).
Run campaigns before releases and after touching `src/adapters/forward.rs` 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 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__": if __name__ == "__main__":
n_sse = sse_parser_seeds() n_sse = sse_parser_seeds()
n_ws = ws_chunk_framing_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"
)
+55 -1
View File
@@ -20,4 +20,58 @@
"INTERNAL" "INTERNAL"
"TIMEOUT" "TIMEOUT"
"FORBIDDEN" "FORBIDDEN"
"CONNECTION_CLOSED" "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 publish = false
edition = "2021" 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] [dependencies]
alkhttp = { path = "../.." } alkhttp = { path = "../.." }
alkcall = "0.8.1" alkcall = "0.8.1"
+1
View File
@@ -4,5 +4,6 @@
//! against every committed corpus entry (the quinn CI pattern) without //! against every committed corpus entry (the quinn CI pattern) without
//! a nightly toolchain. //! a nightly toolchain.
pub mod spec_import;
pub mod sse_parser; pub mod sse_parser;
pub mod ws_chunk_framing; 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]); fuzz_ws_chunk_framing(&h(3, 10)[..n]);
} }
// Over-length declarations: > MAX_CHUNK_LEN, u32::MAX. // 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"); over.extend_from_slice(b"partial-payload");
fuzz_ws_chunk_framing(&over); fuzz_ws_chunk_framing(&over);
fuzz_ws_chunk_framing(&h(4, u32::MAX)); 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 // cap frames are valid but expensive; the fuzzer explores the
// near-cap shapes and the cap partition is asserted exactly in // near-cap shapes and the cap partition is asserted exactly in
// cap_walk). // 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) 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)] #[cfg(test)]
mod tests; mod tests;
+35
View File
@@ -144,6 +144,41 @@ fn harden_closed_by_default(input_schema: &Value) -> Value {
hardened 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"))] #[cfg(all(test, feature = "client"))]
mod tests { mod tests {
use super::*; 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"))] #[cfg(all(test, feature = "client", feature = "openapi"))]
mod tests { mod tests {
use super::*; use super::*;