Fix panics on malformed params; add panic-fuzz harness

Rule 9 (no panic on input). Fixes found via fuzzing:
- edit.paste: a short/non-array "center" (e.g. [5], []) indexed a[1] and
  panicked; now requires len>=2, else falls back to the canvas centre.
- edit.transform.again: a quad row with <2 columns indexed [1] and panicked;
  now rejected with the other shape checks.
Tests: paste_with_malformed_center_does_not_panic (fast regression) plus
tests/panic_hunt.rs, an opt-in (#[ignore], run --ignored) fuzz that dispatches
every command with adversarial params and asserts none panic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 00:24:01 -04:00
parent ed0b99964d
commit bf111b524e
2 changed files with 62 additions and 2 deletions

View File

@ -114,7 +114,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
let (dx, dy) = if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
(0, 0)
} else {
let c = p.get("center").and_then(Value::as_array).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
let (cx, cy) = c.unwrap_or(((canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0));
let b = clip.bounds;
((cx - (b.x0 + b.x1) as f64 / 2.0).round() as i32, (cy - (b.y0 + b.y1) as f64 / 2.0).round() as i32)
@ -329,7 +329,7 @@ fn transform_again(s: &mut Session) -> Result<Value> {
let v = |x: &Value| -> Vec<f64> { x.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default() };
let r = v(&r);
let q: Vec<Vec<f64>> = q.as_array().map(|a| a.iter().map(v).collect()).unwrap_or_default();
if r.len() != 4 || q.len() != 4 {
if r.len() != 4 || q.len() != 4 || q.iter().any(|row| row.len() < 2) {
return Err(EngineError::Other("the last transform can't be repeated".into()));
}
let h = photocraft_algo::transform::Homography::rect_to_quad([r[0], r[1], r[2], r[3]], [[q[0][0], q[0][1]], [q[1][0], q[1][1]], [q[2][0], q[2][1]], [q[3][0], q[3][1]]])
@ -453,6 +453,18 @@ mod tests {
assert_eq!(s.active().unwrap().doc.layers.len(), 4);
}
#[test]
fn paste_with_malformed_center_does_not_panic() {
// A short/empty `center` array must not panic (Rule 9) — it falls back to the canvas centre.
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
for center in [json!([5]), json!([]), json!("nope"), json!([1, 2, 3])] {
// Fresh clipboard each time is unnecessary; copy persists. Must return Ok, never panic.
assert!(s.execute("edit.paste", json!({ "center": center })).is_ok(), "center={center}");
}
}
#[test]
fn copy_paste_between_documents() {
// Copy in one tab, paste into another: the clipboard is shared across documents.

View File

@ -0,0 +1,48 @@
//! Fuzz every command with adversarial params; a command must return `Err`, never panic.
//! (Rule 9 in AGENTS.md.) Params here keep the canvas small on purpose — validating absurd
//! dimensions is a separate concern; this hunts missing-param / empty-state / bad-index panics.
use photocraft_engine::{command_specs, Session};
use serde_json::{json, Value};
use std::panic::{catch_unwind, AssertUnwindSafe};
fn fresh() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 16})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 1, "y": 1, "width": 6, "height": 5})).unwrap();
s
}
// Slow (fresh session per command × params, in debug). Opt-in: `cargo test -p photocraft-engine
// --test panic_hunt -- --ignored`. Fast targeted regressions live beside the fixed commands.
#[test]
#[ignore = "slow full-registry fuzz; run with --ignored"]
fn no_command_panics_on_adversarial_params() {
// None of these resize the canvas, so the fuzz stays fast.
let params: Vec<Value> = vec![
json!({}),
json!({"x": -9, "y": -9, "radius": 0, "amount": 0, "angle": 0, "opacity": 0, "scale": 0, "levels": 0, "tolerance": 0, "gamma": 0, "layer": 999999, "channel": 999, "index": 999999}),
json!({"x": 1e6, "y": -1e6, "radius": 500, "amount": 1000, "angle": 1e6, "opacity": -100, "scale": -5, "gamma": -1}),
json!({"points": [], "from": [0, 0], "to": [0, 0], "stops": [], "matrix": [0, 0, 0, 0, 0, 0], "colors": [], "name": "", "mode": "", "style": ""}),
];
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut panicked: Vec<String> = Vec::new();
for spec in command_specs() {
for p in &params {
let id = spec.id.to_string();
let pc = p.clone();
let crashed = catch_unwind(AssertUnwindSafe(|| {
let mut s = fresh();
let _ = s.execute(&id, pc);
}))
.is_err();
if crashed {
panicked.push(format!("{} <- {}", spec.id, p));
break;
}
}
}
std::panic::set_hook(prev);
assert!(panicked.is_empty(), "{} commands panicked:\n{}", panicked.len(), panicked.join("\n"));
}