Robustness: VectorCraft never crashes — no panics in shipped code

Shipped code no longer unwraps, expects or panics: every one of the 124
unwrap()/expect()/panic!/unreachable! sites outside tests now handles the
failure (Result/Option, ?, let-else, defaults) and reports errors to the user.

- Lints: workspace clippy denies unwrap_used, expect_used, panic, todo,
  unimplemented and unreachable; clippy.toml allows them in tests, and
  integration tests, examples and testkit opt out explicitly.
- Safety net (vectorcraft_engine::guard): a panic in a top-level command rolls
  the document back and returns EngineError::Internal; a panicking tool is
  reset; the MCP server answers with an internal error and keeps serving; the
  UI frame and each control request are guarded.
- APIs that hid failures now return them: Rendered::to_png/to_jpeg/to_webp
  return Result (JPEG/WebP errors used to write empty files),
  FontDb::face returns Option, Region::top returns Option.
- Fuzzing: engine/tests/import_fuzz.rs feeds garbage, hostile and mutated SVG
  and PDF through import → render → export (30k cases locally, no panics).
- Docs: AGENTS.md/CLAUDE.md rule, docs/development.md "Robustness" section,
  README pointer and a PR template checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-05 06:28:59 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 0f241aac9f
commit 3e323bfa2a
89 changed files with 1041 additions and 303 deletions
+2
View File
@@ -1,6 +1,8 @@
//! Every-command sweep: each registered command, in three fixture states, with `{}` and with fuzzed
//! junk params, must return Ok or Err (never panic), never leave an interaction open, and never
//! corrupt the tree.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_engine::{Session, command_specs};
+285
View File
@@ -0,0 +1,285 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG and PDF input
//! must import as an error or as a document that then renders and exports, without a panic.
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use vectorcraft_doc::Document;
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::rich_session;
/// Import must not panic; whatever comes back must render and export without panicking either.
fn survive(what: &str, import: impl FnOnce() -> Option<Document>) -> Result<(), TestCaseError> {
let r = catch_quiet(|| {
if let Some(d) = import() {
let mut r = vectorcraft_render::Renderer::new();
if let Some(ab) = d.artboards.first() {
let scale = (256.0 / ab.rect.width().max(ab.rect.height()).max(1.0)).min(1.0);
if vectorcraft_render::raster_size(ab.rect, scale).is_ok() {
let _ = r.render_region(&d, ab.rect, scale, true).to_png();
}
}
let _ = vectorcraft_svg::export(&d, &vectorcraft_svg::ExportOptions::default());
let _ = vectorcraft_engine::export_pdf(&d, &vectorcraft_pdf::PdfOptions::default());
let _ = vectorcraft_format::save_file(&d);
}
});
r.map_err(|msg| TestCaseError::fail(format!("{what}: panicked: {msg}")))
}
fn rich_doc() -> Document {
(*rich_session().doc().unwrap().doc).clone()
}
fn rich_svg() -> String {
vectorcraft_svg::export(&rich_doc(), &vectorcraft_svg::ExportOptions::default())
}
fn rich_pdf() -> Vec<u8> {
vectorcraft_engine::export_pdf(&rich_doc(), &vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap()
}
/// Numbers that tend to break arithmetic: zero, negatives, huge, tiny, exponents, junk.
fn arb_num() -> impl Strategy<Value = String> {
prop_oneof![
(-1000.0f64..1000.0).prop_map(|v| format!("{v:.2}")),
Just("0".to_string()),
Just("-0".to_string()),
Just("1e308".to_string()),
Just("-1e308".to_string()),
Just("1e-308".to_string()),
Just("1e15".to_string()),
Just("4294967296".to_string()),
Just("-2147483649".to_string()),
Just("NaN".to_string()),
Just("inf".to_string()),
Just("".to_string()),
Just("1e".to_string()),
Just("--1".to_string()),
]
}
// ---------- SVG ----------
fn arb_svg_attr() -> impl Strategy<Value = String> {
let names = prop::sample::select(vec![
"x",
"y",
"width",
"height",
"rx",
"ry",
"r",
"cx",
"cy",
"x1",
"y1",
"x2",
"y2",
"dx",
"dy",
"font-size",
"stroke-width",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-miterlimit",
"opacity",
"fill-opacity",
"offset",
"startOffset",
"letter-spacing",
"rotate",
"textLength",
"fx",
"fy",
"fr",
"points",
"viewBox",
"transform",
"d",
]);
let value = prop_oneof![
arb_num(),
prop::collection::vec(arb_num(), 0..7).prop_map(|v| v.join(" ")),
prop::collection::vec(arb_num(), 0..7).prop_map(|v| format!("matrix({})", v.join(" "))),
prop::collection::vec(arb_num(), 0..3).prop_map(|v| format!("rotate({}) scale({})", v.join(" "), v.join(","))),
prop::collection::vec((prop::sample::select(vec!["M", "L", "C", "Q", "A", "Z", "H", "V", "S", "T", "m", "a"]), arb_num()), 0..12)
.prop_map(|v| v.iter().map(|(c, n)| format!("{c}{n} {n}")).collect::<Vec<_>>().join(" ")),
prop::collection::vec(arb_num(), 0..2).prop_map(|v| format!("{}%", v.join(""))),
];
(names, value).prop_map(|(n, v)| format!(" {n}=\"{v}\""))
}
fn arb_svg_element(depth: u32) -> BoxedStrategy<String> {
let tag = prop::sample::select(vec![
"rect",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"path",
"text",
"tspan",
"textPath",
"g",
"use",
"image",
"linearGradient",
"radialGradient",
"stop",
"pattern",
"clipPath",
"mask",
"symbol",
"defs",
"svg",
"marker",
]);
let attrs = || prop::collection::vec(arb_svg_attr(), 0..6).prop_map(|v| v.concat());
let leaf = (tag.clone(), attrs(), "[a-z ]{0,6}").prop_map(|(t, a, txt)| format!("<{t} id=\"e\"{a} href=\"#e\">{txt}</{t}>"));
if depth == 0 {
return leaf.boxed();
}
prop_oneof![
leaf,
(tag, attrs(), prop::collection::vec(arb_svg_element(depth - 1), 0..4)).prop_map(|(t, a, ch)| format!("<{t}{a}>{}</{t}>", ch.concat()))
]
.boxed()
}
/// A character-level edit that keeps the string valid UTF-8 (the importer takes `&str`).
fn mutate_text(src: &str, cut: usize, edits: &[(usize, char)]) -> String {
let mut chars: Vec<char> = src.chars().collect();
for &(i, c) in edits {
if !chars.is_empty() {
let n = chars.len();
chars[i % n] = c;
}
}
chars.truncate(cut.min(chars.len()));
chars.into_iter().collect()
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })]
#[test]
fn svg_garbage_never_panics(s in ".{0,300}") {
survive("svg garbage", || vectorcraft_svg::import(&s).ok())?;
}
#[test]
fn svg_hostile_markup_never_panics(
root in prop::collection::vec(arb_svg_attr(), 0..4),
body in prop::collection::vec(arb_svg_element(2), 0..6),
) {
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"{}>{}</svg>", root.concat(), body.concat());
survive(&svg, || vectorcraft_svg::import(&svg).ok())?;
}
#[test]
fn svg_mutated_export_never_panics(
cut in 0usize..20_000,
edits in prop::collection::vec((0usize..20_000, prop::sample::select(vec!['<', '>', '"', '/', '-', '9', 'e', '.', ' ', '#', '%', '&', ';', 'x'])), 0..10),
) {
let svg = mutate_text(&rich_svg(), cut, &edits);
survive("mutated svg", || vectorcraft_svg::import(&svg).ok())?;
}
}
// ---------- PDF ----------
fn handmade_pdf(content: &str, resources: &str, media: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox {media} /Contents 4 0 R /Resources {resources} >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![];
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// One content-stream operation with junk operands.
fn arb_pdf_op() -> impl Strategy<Value = String> {
let op = prop::sample::select(vec![
"m", "l", "c", "v", "y", "h", "re", "f", "f*", "F", "S", "s", "B", "B*", "b", "n", "W", "W*", "q", "Q", "cm", "w", "J", "j", "M", "d", "ri",
"i", "gs", "g", "G", "rg", "RG", "k", "K", "cs", "CS", "sc", "SC", "scn", "SCN", "sh", "Do", "BT", "ET", "Tf", "Td", "TD", "Tm", "T*", "Tc",
"Tw", "Tz", "TL", "Tr", "Ts", "Tj", "TJ", "'", "\"", "BMC", "BDC", "EMC", "d0", "d1",
]);
let operand = prop_oneof![
arb_num(),
prop::collection::vec(arb_num(), 0..5).prop_map(|v| format!("[{}]", v.join(" "))),
Just("/F1".to_string()),
Just("/Sh0".to_string()),
Just("/Im0".to_string()),
Just("/GS0".to_string()),
Just("/DeviceRGB".to_string()),
Just("/Pattern".to_string()),
Just("(Hi)".to_string()),
Just("<00ff>".to_string()),
Just("[(a) -250 (b)]".to_string()),
];
(prop::collection::vec(operand, 0..7), op).prop_map(|(args, op)| format!("{} {op}", args.join(" ")))
}
fn arb_resources() -> impl Strategy<Value = String> {
prop::sample::select(vec![
"<< >>".to_string(),
"<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>".to_string(),
"<< /ExtGState << /GS0 << /CA 0.5 /ca -3 /BM /Multiply /SMask /None >> >> >>".to_string(),
"<< /Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1e308 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >> >>".to_string(),
"<< /Shading << /Sh0 << /ShadingType 3 /ColorSpace /DeviceRGB /Coords [0 0 0 0 0 -5] /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [] /N -1 >> >> >> >>".to_string(),
"<< /XObject << /Im0 << /Type /XObject /Subtype /Image /Width 4294967295 /Height 0 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 3 >> >> >>".to_string(),
"<< /Pattern << /P0 << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0 0] /XStep 0 /YStep -0 /Resources << >> >> >> >>".to_string(),
])
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })]
#[test]
fn pdf_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..600)) {
let mut b = b"%PDF-1.7\n".to_vec();
b.extend(bytes);
survive("pdf garbage", || vectorcraft_pdf::import(&b).ok())?;
}
#[test]
fn pdf_hostile_content_never_panics(
ops in prop::collection::vec(arb_pdf_op(), 0..40),
resources in arb_resources(),
media in prop::sample::select(vec!["[0 0 100 100]", "[0 0 0 0]", "[0 0 1e308 1e308]", "[100 100 0 0]", "[-5 -5 -1 -1]", "[0 0 14400 14400]", "[]", "[0 0 NaN 5]"]),
) {
let pdf = handmade_pdf(&ops.join("\n"), &resources, media);
survive(&ops.join(" "), || vectorcraft_pdf::import(&pdf).ok())?;
}
#[test]
fn pdf_mutated_export_never_panics(
cut in 0usize..40_000,
flips in prop::collection::vec((0usize..40_000, prop::sample::select(vec![b'0', b'9', b'-', b'.', b' ', b'[', b']', b'<', b'>', b'/', b'(', b'e', 0u8, 0xff])), 0..10),
) {
let mut b = rich_pdf();
for (i, c) in flips {
let n = b.len();
b[i % n] = c;
}
b.truncate(cut.min(b.len()).max(9));
survive("mutated pdf", || vectorcraft_pdf::import(&b).ok())?;
}
}
+2
View File
@@ -1,5 +1,7 @@
//! Minimal reproductions of engine bugs found by the property tests. Each is `#[ignore]`d with a
//! `BUG:` reason until fixed; remove the attribute when the fix lands.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_engine::Session;
+2
View File
@@ -1,5 +1,7 @@
//! Model-based / stateful property tests: random command sequences drawn from the registry, with
//! structural invariants checked after every step and history/serialization invariants at the end.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use serde_json::json;