//! `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, } 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 { 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 = 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(_)))); }