257 lines
13 KiB
Rust
257 lines
13 KiB
Rust
//! Hostile input: the `panic_hunt` pattern of the Craft suite.
|
|
//!
|
|
//! Every line goes through [`Server::handle_line`], which must answer whenever
|
|
//! an answer is due and must never panic. The engine fixtures
|
|
//! ([`notescraft_engine::adversarial_params`]) go to every tool, and `serve` is
|
|
//! driven over real pipes, including broken ones.
|
|
|
|
mod common;
|
|
|
|
use common::{ask, registry, seeded_vault};
|
|
use notescraft_engine::{Session, adversarial_params};
|
|
use notescraft_mcp::{MAX_LINE_BYTES, Server, ServerOptions, serve};
|
|
use serde_json::{Value, json};
|
|
|
|
/// The tool names `tools/list` serves, so the fuzz hits every one of them.
|
|
fn tool_names(server: &mut Server) -> Vec<String> {
|
|
let reply = ask(server, &json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).to_string());
|
|
reply
|
|
.get("result")
|
|
.and_then(|result| result.get("tools"))
|
|
.and_then(Value::as_array)
|
|
.map(|tools| tools.iter().filter_map(|tool| tool.get("name")).filter_map(Value::as_str).map(str::to_owned).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// A `tools/call` line for `name` with `arguments`.
|
|
fn call_line(name: &str, arguments: &Value) -> String {
|
|
json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": arguments } }).to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn every_tool_survives_the_engine_adversarial_params() {
|
|
let (_vault, mut server) = common::writable_server("hostile-engine");
|
|
let names = tool_names(&mut server);
|
|
assert!(names.len() >= 25, "the fuzz must reach every tool, got {}", names.len());
|
|
let fixtures = adversarial_params();
|
|
for name in &names {
|
|
for params in &fixtures {
|
|
let line = call_line(name, params);
|
|
let reply = server.handle_line(&line).unwrap_or_else(|| panic!("{name} with {params} got no reply"));
|
|
let parsed: Value = serde_json::from_str(&reply).unwrap_or_else(|error| panic!("{name}: the reply is JSON ({error}): {reply}"));
|
|
match parsed.get("result") {
|
|
Some(result) => {
|
|
let is_error = result.get("isError").and_then(Value::as_bool);
|
|
assert!(is_error.is_some(), "{name} always answers isError: {reply}");
|
|
}
|
|
// Params that are not an object are refused at the protocol
|
|
// level, which is also an answer.
|
|
None => assert_eq!(parsed.pointer("/error/code").and_then(Value::as_i64), Some(-32602), "{name} with {params}: {reply}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn random_bytes_and_hostile_lines_never_panic_and_answer_when_due() {
|
|
let (_vault, mut server) = common::writable_server("hostile-lines");
|
|
let mut rng = 0x2026_1013_u64;
|
|
let mut next = move || {
|
|
rng ^= rng << 13;
|
|
rng ^= rng >> 7;
|
|
rng ^= rng << 17;
|
|
rng
|
|
};
|
|
let mut lines: Vec<Vec<u8>> = vec![
|
|
vec![],
|
|
b"\n".to_vec(),
|
|
b"null".to_vec(),
|
|
b"[]".to_vec(),
|
|
b"[{}]".to_vec(),
|
|
b"[[[[[[[[[[]]]]]]]]]]".to_vec(),
|
|
b"{\"jsonrpc\":\"2.0\",\"id\":{},\"method\":\"ping\"}".to_vec(),
|
|
b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"\"}".to_vec(),
|
|
b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"note.read\",\"arguments\":[]}}".to_vec(),
|
|
b"{\"jsonrpc\":\"2.0\",\"id\":18446744073709551616,\"method\":\"ping\"}".to_vec(),
|
|
b"{\"jsonrpc\":\"2.0\",\"id\":-9223372036854775809,\"method\":\"ping\"}".to_vec(),
|
|
format!("{{\"jsonrpc\":\"2.0\",\"id\":\"{}\",\"method\":\"ping\"}}", "s".repeat(4096)).into_bytes(),
|
|
];
|
|
for _ in 0..64 {
|
|
let len = (next() % 512) as usize + 1;
|
|
let bytes: Vec<u8> = (0..len).map(|_| u8::try_from(next() % 256).unwrap_or(0)).collect();
|
|
lines.push(bytes);
|
|
}
|
|
for bytes in &lines {
|
|
let line = String::from_utf8_lossy(bytes);
|
|
if let Some(reply) = server.handle_line(&line) {
|
|
assert!(serde_json::from_str::<Value>(&reply).is_ok(), "the reply of {line:?} is JSON: {reply}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_nested_ten_thousand_levels_deep_is_answered_not_crashed() {
|
|
let (_vault, mut server) = common::writable_server("hostile-deep");
|
|
let depth = 10_000;
|
|
let mut line = String::with_capacity(depth * 2 + 64);
|
|
line.push_str(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"note_read","arguments":"#);
|
|
line.push_str(&"[".repeat(depth));
|
|
line.push_str(&"]".repeat(depth));
|
|
line.push_str("}}");
|
|
let reply = server.handle_line(&line).expect("an answer is due");
|
|
let parsed: Value = serde_json::from_str(&reply).expect("the reply is JSON");
|
|
assert_eq!(parsed.get("error").and_then(|error| error.get("code")).and_then(Value::as_i64), Some(-32700), "{reply}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_line_over_eight_mebibytes_is_refused() {
|
|
let (_vault, mut server) = common::writable_server("hostile-huge");
|
|
let line = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"ping","pad":"{}"}}"#, "x".repeat(MAX_LINE_BYTES));
|
|
let reply = server.handle_line(&line).expect("an answer is due");
|
|
let parsed: Value = serde_json::from_str(&reply).expect("the reply is JSON");
|
|
assert_eq!(parsed.pointer("/error/code").and_then(Value::as_i64), Some(-32600), "{reply}");
|
|
let just_under = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"ping","pad":"{}"}}"#, "x".repeat(MAX_LINE_BYTES - 64));
|
|
let reply = server.handle_line(&just_under).expect("an answer is due");
|
|
assert!(reply.contains(r#""result""#), "a line at the cap is served: {} bytes", reply.len());
|
|
}
|
|
|
|
#[test]
|
|
fn a_batch_of_hostile_entries_answers_per_entry_without_panicking() {
|
|
let (_vault, mut server) = common::writable_server("hostile-batch");
|
|
let line = json!([
|
|
{ "jsonrpc": "2.0", "id": "a", "method": "ping" },
|
|
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "note_read", "arguments": { "path": ["nope"] } } },
|
|
{ "jsonrpc": "2.0", "id": 3, "method": "resources/read", "params": { "uri": 7 } },
|
|
{ "jsonrpc": "2.0", "method": "notifications/initialized" },
|
|
{ "jsonrpc": "2.0", "id": 5, "method": "unknown/method" },
|
|
])
|
|
.to_string();
|
|
let reply = server.handle_line(&line).expect("an answer is due");
|
|
let parsed: Value = serde_json::from_str(&reply).expect("the reply is JSON");
|
|
let answers = parsed.as_array().expect("a batch answer");
|
|
assert_eq!(answers.len(), 4, "the notification is not answered: {answers:?}");
|
|
assert_eq!(answers.first().and_then(|a| a.get("id")).and_then(Value::as_str), Some("a"));
|
|
assert_eq!(answers.get(1).and_then(|a| a.pointer("/result/isError")).and_then(Value::as_bool), Some(true));
|
|
assert_eq!(answers.get(2).and_then(|a| a.pointer("/error/code")).and_then(Value::as_i64), Some(-32602));
|
|
assert_eq!(answers.get(3).and_then(|a| a.pointer("/error/code")).and_then(Value::as_i64), Some(-32601));
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_types_for_every_field_are_refused_per_field() {
|
|
let (_vault, mut server) = common::writable_server("hostile-types");
|
|
let cases = [
|
|
("initialize", json!({ "protocolVersion": 7 })),
|
|
("initialize", json!({ "protocolVersion": [] })),
|
|
("tools/list", json!({ "cursor": 3 })),
|
|
("tools/call", json!({ "name": null })),
|
|
("tools/call", json!({ "name": "read_note", "arguments": "path=Alpha.md" })),
|
|
("tools/call", json!({ "name": "read_note", "arguments": [1, 2] })),
|
|
("resources/list", json!({ "cursor": true })),
|
|
("resources/read", json!({ "uri": 4 })),
|
|
("resources/templates/list", json!([])),
|
|
];
|
|
for (method, params) in cases {
|
|
let line = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
|
let reply = server.handle_line(&line).expect("an answer is due");
|
|
let parsed: Value = serde_json::from_str(&reply).expect("the reply is JSON");
|
|
assert_eq!(parsed.pointer("/error/code").and_then(Value::as_i64), Some(-32602), "{method} with {params}: {reply}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn serve_answers_every_line_and_stops_at_end_of_input() {
|
|
let vault = seeded_vault("hostile-serve");
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
let server = Server::new(session, registry(), ServerOptions { write: true, allow_delete: false, via_control: false });
|
|
let input = [
|
|
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#,
|
|
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
|
|
"",
|
|
"not json",
|
|
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_note","arguments":{"path":"Alpha.md"}}}"#,
|
|
]
|
|
.join("\n");
|
|
let mut output: Vec<u8> = Vec::new();
|
|
serve(server, input.as_bytes(), &mut output).expect("the loop ends at end of input");
|
|
let text = String::from_utf8(output).expect("the writer got UTF-8");
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
assert_eq!(lines.len(), 3, "one answer per line that asked for one: {lines:?}");
|
|
let answers: Vec<Value> = lines.iter().filter_map(|line| serde_json::from_str::<Value>(line).ok()).collect();
|
|
let ids: Vec<i64> = answers.iter().filter_map(|value| value.get("id").and_then(Value::as_i64)).collect();
|
|
assert_eq!(ids, vec![1, 2], "the unparsable line has no id to echo");
|
|
assert_eq!(answers.get(1).and_then(|value| value.pointer("/error/code")).and_then(Value::as_i64), Some(-32700));
|
|
}
|
|
|
|
#[test]
|
|
fn serve_drops_a_line_over_the_cap_without_buffering_it() {
|
|
let vault = seeded_vault("hostile-cap");
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
let server = Server::new(session, registry(), ServerOptions::default());
|
|
let mut input = vec![b'x'; MAX_LINE_BYTES + 4096];
|
|
input.push(b'\n');
|
|
input.extend_from_slice(br#"{"jsonrpc":"2.0","id":7,"method":"ping"}"#);
|
|
input.push(b'\n');
|
|
let mut output: Vec<u8> = Vec::new();
|
|
serve(server, input.as_slice(), &mut output).expect("the loop answers the over-long line and goes on");
|
|
let text = String::from_utf8(output).expect("UTF-8 out");
|
|
let lines: Vec<Value> = text.lines().filter_map(|line| serde_json::from_str::<Value>(line).ok()).collect();
|
|
assert_eq!(lines.len(), 2, "the over-long line and the ping: {lines:?}");
|
|
assert_eq!(lines.first().and_then(|value| value.pointer("/error/code")).and_then(Value::as_i64), Some(-32600));
|
|
assert_eq!(lines.get(1).and_then(|value| value.get("id")).and_then(Value::as_i64), Some(7), "the reader is back on a line boundary");
|
|
}
|
|
|
|
#[test]
|
|
fn serve_survives_a_broken_pipe_and_invalid_utf8() {
|
|
let vault = seeded_vault("hostile-pipe");
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
let server = Server::new(session, registry(), ServerOptions::default());
|
|
|
|
let mut input: Vec<u8> = Vec::new();
|
|
input.extend_from_slice(b"\xff\xfe not utf-8\n");
|
|
input.extend_from_slice(br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#);
|
|
input.push(b'\n');
|
|
let mut output: Vec<u8> = Vec::new();
|
|
serve(server, input.as_slice(), &mut output).expect("the loop keeps going after invalid UTF-8");
|
|
let text = String::from_utf8(output).expect("UTF-8 out");
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
assert_eq!(lines.len(), 2);
|
|
let first: Value = serde_json::from_str(lines.first().copied().unwrap_or_default()).expect("JSON");
|
|
assert_eq!(first.pointer("/error/code").and_then(Value::as_i64), Some(-32700));
|
|
let second: Value = serde_json::from_str(lines.get(1).copied().unwrap_or_default()).expect("JSON");
|
|
assert_eq!(second.get("id").and_then(Value::as_i64), Some(1), "the loop kept going after the bad line: {second}");
|
|
|
|
let vault = seeded_vault("hostile-pipe-2");
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
let server = Server::new(session, registry(), ServerOptions::default());
|
|
let lines = [r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#];
|
|
let input = lines.join("\n");
|
|
let error = serve(server, input.as_bytes(), FailingWriter::new(1)).expect_err("a broken pipe is an error, not a panic");
|
|
assert_eq!(error.to_string(), "mcp transport: the pipe is gone", "{error}");
|
|
}
|
|
|
|
/// A writer that stops accepting bytes after `writable` lines.
|
|
struct FailingWriter {
|
|
writable: usize,
|
|
written: usize,
|
|
}
|
|
|
|
impl FailingWriter {
|
|
fn new(writable: usize) -> Self {
|
|
Self { writable, written: 0 }
|
|
}
|
|
}
|
|
|
|
impl std::io::Write for FailingWriter {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
if self.written >= self.writable {
|
|
return Err(std::io::Error::other("the pipe is gone"));
|
|
}
|
|
self.written += 1;
|
|
Ok(buf.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|