a8ca62b924
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
143 lines
5.6 KiB
Rust
143 lines
5.6 KiB
Rust
//! `panic_hunt`: every sample command against every adversarial params object.
|
|
//!
|
|
//! The point is not that each run returns a nice error, but that no input can
|
|
//! escape as a panic and the registry is still usable afterwards.
|
|
|
|
use notescraft_engine::{CommandResult, CommandSpec, Error, ParamKind, ParamSpec, Registry, adversarial_params};
|
|
use serde_json::Value;
|
|
|
|
/// Keys a sample body tolerates before refusing.
|
|
const MAX_KEYS: usize = 32;
|
|
|
|
/// Context of the fuzz: a call counter and a slot for whatever the body saw.
|
|
#[derive(Debug, Default)]
|
|
struct Ctx {
|
|
calls: usize,
|
|
last: Option<Value>,
|
|
}
|
|
|
|
fn sink(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
ctx.calls += 1;
|
|
ctx.last = Some(params.clone());
|
|
if params.as_object().is_some_and(|map| map.len() > MAX_KEYS) {
|
|
return Err(notescraft_engine::CommandError::new("too_many_keys", "too many keys"));
|
|
}
|
|
Ok(Value::Bool(true))
|
|
}
|
|
|
|
fn echo(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
sink(ctx, params)
|
|
}
|
|
|
|
fn pick_name(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
let name = params.get("name").cloned().unwrap_or(Value::Null);
|
|
sink(ctx, &name)
|
|
}
|
|
|
|
fn count_things(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
let n = params.get("count").and_then(Value::as_u64).unwrap_or(0);
|
|
sink(ctx, &Value::from(n))
|
|
}
|
|
|
|
fn deep_dive(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
sink(ctx, &Value::Array(vec![params.clone(), params.clone()]))
|
|
}
|
|
|
|
fn invert(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
if params.get("flag").and_then(Value::as_bool).unwrap_or(false) {
|
|
ctx.last = None;
|
|
}
|
|
sink(ctx, params)
|
|
}
|
|
|
|
fn expect_object(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
if params.is_object() { sink(ctx, params) } else { Err(notescraft_engine::CommandError::new("not_an_object", "expected an object")) }
|
|
}
|
|
|
|
fn expect_string(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
match params.as_str() {
|
|
Some(s) => sink(ctx, &Value::from(s.chars().count())),
|
|
None => Err(notescraft_engine::CommandError::new("not_a_string", "expected a string")),
|
|
}
|
|
}
|
|
|
|
fn gated(ctx: &mut Ctx, params: &Value) -> CommandResult {
|
|
sink(ctx, params)
|
|
}
|
|
|
|
fn gated_when(_ctx: &Ctx) -> bool {
|
|
false
|
|
}
|
|
|
|
fn panicking(_ctx: &mut Ctx, _params: &Value) -> CommandResult {
|
|
panic!("deliberate panic in a fuzz sample");
|
|
}
|
|
|
|
fn failing(_ctx: &mut Ctx, _params: &Value) -> CommandResult {
|
|
Err(notescraft_engine::CommandError::new("always", "always fails"))
|
|
}
|
|
|
|
/// A dozen commands with different shapes: no params, every `ParamKind`,
|
|
/// disabled, failing and panicking bodies.
|
|
fn sample_registry() -> Registry<Ctx> {
|
|
let mut reg = Registry::new();
|
|
let specs = vec![
|
|
CommandSpec::new("core.ping", "Ping", "", |_| true, sink),
|
|
CommandSpec::new("core.echo", "Echo", "", |_| true, echo),
|
|
CommandSpec::new("core.deep", "Deep", "", |_| true, deep_dive),
|
|
CommandSpec::new("core.gated", "Gated", "", gated_when, gated),
|
|
CommandSpec::new("core.fail", "Fail", "", |_| true, failing),
|
|
CommandSpec::new("core.panic", "Panic", "", |_| true, panicking),
|
|
CommandSpec::new("note.open", "Open", "", |_| true, sink),
|
|
CommandSpec::new("note.object", "Object", "", |_| true, expect_object).with_param(ParamSpec::required("name", ParamKind::String, "name")),
|
|
CommandSpec::new("note.string", "String", "", |_| true, expect_string)
|
|
.with_param(ParamSpec::required("name", ParamKind::String, "name"))
|
|
.with_param(ParamSpec::optional("any", ParamKind::Any, "anything")),
|
|
CommandSpec::new("note.pick", "Pick", "", |_| true, pick_name).with_param(ParamSpec::required("name", ParamKind::String, "name")),
|
|
CommandSpec::new("note.count", "Count", "", |_| true, count_things).with_param(ParamSpec::required("count", ParamKind::Integer, "how many")),
|
|
CommandSpec::new("note.flag", "Flag", "", |_| true, invert)
|
|
.with_param(ParamSpec::optional("flag", ParamKind::Bool, "invert"))
|
|
.with_param(ParamSpec::optional("ratio", ParamKind::Number, "a number"))
|
|
.with_param(ParamSpec::optional("tags", ParamKind::Array, "a list"))
|
|
.with_param(ParamSpec::optional("meta", ParamKind::Object, "an object")),
|
|
];
|
|
for spec in specs {
|
|
reg.register(spec).expect("sample registration");
|
|
}
|
|
reg
|
|
}
|
|
|
|
#[test]
|
|
fn every_command_survives_every_adversarial_params() {
|
|
let reg = sample_registry();
|
|
let fixtures = adversarial_params();
|
|
assert!(fixtures.len() >= 20);
|
|
assert_eq!(reg.len(), 12);
|
|
|
|
let previous = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(|_| {}));
|
|
let mut ctx = Ctx::default();
|
|
let mut accepted = 0usize;
|
|
for id in reg.list(&ctx).iter().map(|info| info.id.clone()) {
|
|
for params in &fixtures {
|
|
match reg.run(&id, &mut ctx, params) {
|
|
Ok(_) => accepted += 1,
|
|
Err(Error::Panicked(other)) => assert_eq!(other, id),
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
}
|
|
std::panic::set_hook(previous);
|
|
|
|
assert!(accepted > 0, "at least the param-less commands must accept some fixtures");
|
|
assert!(ctx.calls > 0);
|
|
|
|
// The registry is still usable and still answers by id.
|
|
let ids: Vec<String> = reg.list(&ctx).into_iter().map(|info| info.id).collect();
|
|
assert_eq!(ids.len(), 12);
|
|
assert_eq!(reg.run("core.ping", &mut ctx, &Value::Null), Ok(Value::Bool(true)));
|
|
assert!(matches!(reg.run("core.gated", &mut ctx, &Value::Null), Err(Error::Disabled(_))));
|
|
assert!(matches!(reg.run("core.fail", &mut ctx, &Value::Null), Err(Error::Command { .. })));
|
|
assert!(matches!(reg.run("missing.id", &mut ctx, &Value::Null), Err(Error::UnknownCommand(_))));
|
|
}
|