Files
2026-10-15 02:00:16 -03:00

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(())
}
}