Files
Marcos Paulo a8ca62b924 feat(engine): command registry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:36:44 -03:00

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