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:
1 parent
84c4b48407
commit
e6491e31c7
56 files changed
+1098
-12
No files matched your search
+38
-7
@@ -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.
|
||||||
Generated
+202
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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/`).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"openapi":"3.0.0","info":{"title":"t","version":"1.0.0"},"paths":{"/x":{"get":{"operationId":"x_get","responses":{}}}}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: t
|
||||||
|
version: 1.0.0
|
||||||
|
paths:
|
||||||
|
/x:
|
||||||
|
get:
|
||||||
|
operationId: x_get
|
||||||
|
responses: {}
|
||||||
@@ -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}}}}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"paths":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"info":{"title":"t"}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
42
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"a string"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
}{
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
not a document
|
||||||
Whitespace-only changes.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
{"openapi":3.0}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
a: 1
|
||||||
|
a: 2
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
base: &b
|
||||||
|
x: 1
|
||||||
|
child:
|
||||||
|
<<: *b
|
||||||
|
y: 2
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
maximum: .inf
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
minimum: -.inf
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
value: .nan
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
~: v
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
? [1, 2]
|
||||||
|
: v
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
responses:
|
||||||
|
200: {}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
a: yes
|
||||||
|
b: no
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a: !!unknown b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a: !!str 200
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
paths:
|
||||||
|
/x:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
200: {}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"components":{"schemas":{"A":{"$ref":"#/components/schemas/A"}}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"components":{"schemas":{"A":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/B"}}},"B":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/A"}}}}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"components":{"schemas":{"A":{"$ref":"#/components/schemas/Missing"}}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"components":{"schemas":{"A":{"$ref":"https://other/file.json"}}}}
|
||||||
@@ -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"}}}}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"required": "n"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"not an object"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type":"object","properties":{"q":{"type":"string"}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type":"object","properties":{"q":{"type":"string"}},"additionalProperties":true}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type":"object","properties":{"level":{"enum":["low","high"]},"n":{"minimum":1},"s":{"maxLength":3}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"ok_tool","inputSchema":{"type":"object","properties":{"q":{"type":"string"}}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"ns/ok_tool","inputSchema":{"type":"object"}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"has space","inputSchema":{"type":"object"}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","inputSchema":{"type":"object"}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"ok_tool"}
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
Reference in new issue
Block a user