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:
parent
ed0b99964d
commit
bf111b524e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ¶ms {
|
||||
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"));
|
||||
}
|
||||
Loading…
Reference in New Issue