Files
vectorcraft/crates/format/tests/prop_format.rs
T
Brandon ThomasandClaude Opus 5.5 3e323bfa2a 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>
2026-10-05 06:28:59 +07:00

150 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `.vectorcraft` format: round trips, robustness against garbage, versioning, unknown fields.
// 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 std::sync::Arc;
use proptest::prelude::*;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, NodeId};
use vectorcraft_format::{base64_decode, base64_encode, load, save, sniff};
use vectorcraft_testkit::fixtures;
use vectorcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq};
use vectorcraft_testkit::strategies::arb_ops;
fn rich_doc() -> Document {
(*fixtures::rich_session().doc().unwrap().doc).clone()
}
proptest! {
#![proptest_config(ProptestConfig { cases: 128, failure_persistence: None, ..ProptestConfig::default() })]
#[test]
fn base64_roundtrip(data in prop::collection::vec(any::<u8>(), 0..300)) {
let s = base64_encode(&data);
prop_assert_eq!(s.len() % 4, 0);
prop_assert_eq!(base64_decode(&s), Some(data.clone()));
// Whitespace is ignored.
let spaced: String = s.chars().flat_map(|c| [c, '\n']).collect();
prop_assert_eq!(base64_decode(&spaced), Some(data));
}
#[test]
fn base64_decode_never_panics(s in ".{0,64}") {
let _ = base64_decode(&s);
}
/// Arbitrary bytes are rejected (or, if they happen to parse, yield a valid document) — never a panic.
#[test]
fn load_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..512)) {
if let Ok(d) = load(&bytes) {
check_document(&d).map_err(TestCaseError::fail)?;
}
}
/// Mutating a valid file (byte flips / truncation) never panics the loader.
#[test]
fn load_mutated_file_never_panics(cut in 0usize..4000, flips in prop::collection::vec((0usize..4000, any::<u8>()), 0..8)) {
let mut bytes = save(&Document::new(100.0, 100.0), false);
for (i, b) in flips {
let n = bytes.len();
bytes[i % n] = b;
}
bytes.truncate(cut.min(bytes.len()));
let _ = load(&bytes);
}
/// Documents produced by random editing survive save → load (numbers to 1e-12).
#[test]
fn random_documents_roundtrip(ops in arb_ops(5..40)) {
let mut s = fixtures::session();
for op in &ops {
let _ = op.apply(&mut s);
}
let d = s.doc().unwrap().doc.clone();
check_native_roundtrip(&d).map_err(TestCaseError::fail)?;
// Pretty and compact encodings load to the same document.
let a = doc_json(&load(&save(&d, true)).unwrap());
let b = doc_json(&load(&save(&d, false)).unwrap());
prop_assert_eq!(a, b);
}
/// Embedded image bytes survive the base64 round trip exactly.
#[test]
fn images_roundtrip(data in prop::collection::vec(any::<u8>(), 0..2000), key in "[a-z]{1,8}") {
let mut d = Document::new(10.0, 10.0);
d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(data.clone()) });
let back = load(&save(&d, false)).unwrap();
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data));
}
}
#[test]
fn rich_document_roundtrips() {
let d = rich_doc();
check_native_roundtrip(&d).unwrap();
let bytes = save(&d, true);
assert!(sniff(&bytes));
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["format"], "vectorcraft");
assert_eq!(v["version"], vectorcraft_format::VERSION);
}
#[test]
fn sniff_distinguishes_formats() {
assert!(sniff(&save(&Document::new(1.0, 1.0), false)));
assert!(!sniff(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"));
assert!(!sniff(b"%PDF-1.7"));
assert!(!sniff(b""));
assert!(!sniff(&[0xff, 0xfe, 0x00]));
}
#[test]
fn rejects_newer_versions_and_other_formats() {
let mut v: Value = serde_json::from_slice(&save(&Document::new(1.0, 1.0), false)).unwrap();
v["version"] = json!(vectorcraft_format::VERSION + 1);
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(vectorcraft_format::FormatError::TooNew(_))));
v["version"] = json!(vectorcraft_format::VERSION);
v["format"] = json!("other");
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(vectorcraft_format::FormatError::NotVectorcraft(_))));
v["format"] = json!("vectorcraft");
v["images"] = json!({"x": {"mime": "image/png", "data": "not base64!"}});
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(vectorcraft_format::FormatError::BadImage(_))));
}
#[test]
fn unknown_fields_are_preserved() {
let mut v: Value = serde_json::from_slice(&save(&Document::new(1.0, 1.0), false)).unwrap();
v["document"]["unknown"] = json!({"futureFeature": {"a": [1, 2, 3]}});
let d = load(&serde_json::to_vec(&v).unwrap()).unwrap();
let again: Value = serde_json::from_slice(&save(&d, false)).unwrap();
assert_eq!(again["document"]["unknown"]["futureFeature"]["a"], json!([1, 2, 3]));
}
#[test]
fn load_repairs_next_id() {
let d = rich_doc();
let mut v: Value = serde_json::from_slice(&save(&d, false)).unwrap();
v["document"]["next_id"] = json!(1);
let mut back = load(&serde_json::to_vec(&v).unwrap()).unwrap();
check_document(&back).unwrap();
let fresh = back.alloc_id();
assert!(back.node(fresh).is_none(), "allocated id {fresh} collides");
let max = fixtures::all_ids(&back).into_iter().map(|NodeId(i)| i).max().unwrap();
assert!(fresh.0 > max);
}
#[test]
fn approx_equal_documents_compare_equal() {
let d = rich_doc();
assert!(json_approx_eq(&doc_json(&d), &doc_json(&d), 0.0));
}
#[test]
fn bug_f64_bit_exact_roundtrip() {
check_native_roundtrip_exact(&rich_doc()).unwrap();
let mut d = Document::new(126.86291501015239, 214.69463130731182);
d.raster_effects_ppi = 0.1 + 0.2;
check_native_roundtrip_exact(&d).unwrap();
}