//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input, //! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset //! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records //! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files //! (damaged ones, hostile programs) read by the PostScript interpreter, nor Affinity documents //! (mutated object streams, archives and indexed PNG previews, hostile image dimensions). //! //! `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 serde_json::{Value, json}; use vectorcraft_doc::Document; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::rich_session; /// 64 cases each, unless `PROPTEST_CASES` asks for more. fn config() -> ProptestConfig { let mut c = ProptestConfig { failure_persistence: None, ..ProptestConfig::default() }; if std::env::var_os("PROPTEST_CASES").is_none() { c.cases = 64; } c } // ---------- Affinity previews ---------- /// Original pixels in a synthetic container envelope, not an Affinity document writer. fn affinity_preview_sample() -> Vec { let im = image::RgbaImage::from_pixel(2, 1, image::Rgba([220, 40, 60, 255])); let mut png = Vec::new(); im.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let mut b = vec![0; 72]; b[..4].copy_from_slice(vectorcraft_affinity::MAGIC); b[4..6].copy_from_slice(&12u16.to_le_bytes()); b[8..12].copy_from_slice(b"nsrP"); b[12..16].copy_from_slice(b"#Inf"); b[24..32].copy_from_slice(&72u64.to_le_bytes()); b[64..68].copy_from_slice(b"Prot"); b.extend(b"\xff\xff\xff\xffThmb"); b.extend(1u32.to_le_bytes()); b.extend((png.len() as u32 + 13).to_le_bytes()); b.extend(29u32.to_le_bytes()); b.extend(0u32.to_le_bytes()); b.extend((png.len() as u32).to_le_bytes()); b.push(1); b.extend(png); b } /// A synthetic native document exercising the reader's paths: curves (with live corners), every /// parametric shape it knows, a compound, gradients, a stroke, text, a mask and an artboard. Not /// an Affinity writer: the layout is the one the reader accepts. fn affinity_native_stream() -> Vec { use vectorcraft_affinity::synth::{self, F, tag}; let f32s = |v: &[f32]| F::Struct(v.iter().flat_map(|x| x.to_le_bytes()).collect()); let rec = |x: f64, y: f64, role: u8| { let mut r = x.to_le_bytes().to_vec(); r.extend(y.to_le_bytes()); r.extend([1, role]); r }; let mut id = 100; let mut next = || { id += 1; id }; let colour = |id: u32| F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), f32s(&[0.2, 0.4, 0.6, 0.8]))]); let gradient = |a: u32, b: u32, c: u32, kind: u16| { F::Def( a, vec![tag(b"FDsc")], vec![ ( tag(b"FDeF"), F::Def( b, vec![tag(b"FilG")], vec![ (tag(b"Type"), F::Enum(kind, 0)), (tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(vec![colour(c), F::Ref(c)]))])), ], ), ), (tag(b"FDeX"), F::F64s(vec![10.0, 0.0, 5.0, 0.0, 20.0, 5.0])), ], ) }; let curve = F::Obj( tag(b"PCvD"), vec![( tag(b"Data"), F::Pos(vec![ F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![rec(0.0, 0.0, 0), rec(5.0, 0.0, 1), rec(10.0, 5.0, 2), rec(10.0, 10.0, 0), rec(0.0, 10.0, 0), rec(0.0, 0.0, 0)]), ]), )], ); let mut kids = vec![F::Def( next(), vec![tag(b"PCrv")], vec![(tag(b"Crvs"), curve.clone()), (tag(b"BFFl"), F::Shared(vec![gradient(next(), next(), next(), 0)]))], )]; for (i, class) in [b"ShNR", b"ShpE", b"ShPy", b"ShSt", b"ShSS", b"ShPi", b"ShpT", b"ShTz", b"ShCl"].into_iter().enumerate() { let fields = vec![ (tag(b"Side"), F::U32(6)), (tag(b"Smth"), F::Bool(i % 2 == 0)), (tag(b"Curv"), F::F32(0.5)), (tag(b"CTyp"), F::Raw(vec![0xaa, b'p', b'y', b'T', b'C', 4, 0, 0, 0, 0, 0, 0, 0, 4, 0, 4, 0, 4, 0])), (tag(b"ShCR"), f32s(&[0.25, 0.0, 0.0, 0.0])), (tag(b"AngE"), F::F32(1.0)), ]; kids.push(F::Def( next(), vec![tag(b"ShpN")], vec![ (tag(b"Shpe"), F::Def(next(), vec![tag(class)], fields)), (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 40.0 + i as f64, 30.0])), (tag(b"Xfrm"), F::F64s(vec![1.0, 0.2, i as f64 * 10.0, 0.0, 1.0, 5.0])), (tag(b"BFFl"), F::Shared(vec![gradient(next(), next(), next(), (i % 4) as u16)])), (tag(b"AdCh"), F::Shared(vec![F::Def(next(), vec![tag(b"PCrv")], vec![(tag(b"Crvs"), curve.clone())])])), ], )); } kids.push(F::Def( next(), vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(vec![F::Def(next(), vec![tag(b"PCrv")], vec![(tag(b"Crvs"), curve.clone()), (tag(b"ComO"), F::Enum(2, 0))])]))], )); let run = F::Def( next(), vec![tag(b"GAtt")], vec![(tag(b"Doub"), F::Raw(vec![0x8a, b'b', b'u', b'o', b'D', 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x28, 0x40]))], ); let block = F::Def( next(), vec![tag(b"StBl")], vec![ (tag(b"Glyp"), F::Obj(tag(b"GStr"), vec![(tag(b"Utf8"), F::Str("Hi\u{2029}there\0".into()))])), ( tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![vec![(tag(b"Indx"), F::I32(9)), (tag(b"Item"), run)]]))]), ), ], ); kids.push(F::Def( next(), vec![tag(b"TxtA")], vec![ (tag(b"StSt"), F::Def(next(), vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block]))])), ( tag(b"TxtH"), F::Def(next(), vec![tag(b"ArFr")], vec![(tag(b"FrmB"), F::F64s(vec![0.0, -10.0, 30.0, 0.0])), (tag(b"ArtV"), F::F64(10.0))]), ), ], )); let board = F::Def( next(), vec![tag(b"ShpN")], vec![ (tag(b"ABEn"), F::Bool(true)), (tag(b"Shpe"), F::Def(next(), vec![tag(b"ShNR")], vec![])), (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"Chld"), F::Shared(kids)), ], ); synth::stream(&[ (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(96.0))])), ( tag(b"DocR"), F::Def( 1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![board]))])]))], ), ), ]) } fn affinity_native(stream: &[u8], method: vectorcraft_affinity::synth::Method) -> Vec { vectorcraft_affinity::synth::container(&[("doc.dat", stream, method)], None) } #[test] fn the_native_affinity_sample_reaches_every_reader_path() { let bytes = affinity_native(&affinity_native_stream(), vectorcraft_affinity::synth::Method::Zstd); let l = vectorcraft_engine::cmd::fileio::load("x.af", &bytes).unwrap(); assert!(!l.preview_only, "{:?}", l.warnings); let kinds = l.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).count(); assert!(kinds > 0); let all = format!("{:?}", l.doc.layers); for want in ["Path", "Text", "Compound"].iter().take(2) { assert!(all.contains(want), "{want} missing"); } assert_eq!(l.doc.artboards.len(), 1); } proptest! { #![proptest_config(config())] #[test] fn affinity_native_documents_with_mutated_streams_render_and_export( cut in 0usize..4096, edits in prop::collection::vec((0usize..4096, any::()), 0..24), ) { let mut stream = affinity_native_stream(); let n = stream.len(); for (at, b) in edits { stream[at % n] = b; } stream.truncate(cut.max(16)); let bytes = affinity_native(&stream, vectorcraft_affinity::synth::Method::Stored); survive("Affinity native stream", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?; } #[test] fn affinity_native_containers_with_mutated_bytes_render_and_export( edits in prop::collection::vec((0usize..8192, any::()), 1..16), method in 0u8..3, ) { use vectorcraft_affinity::synth::Method; let m = [Method::Stored, Method::Zlib, Method::Zstd][usize::from(method)]; let mut bytes = affinity_native(&affinity_native_stream(), m); let n = bytes.len(); for (at, b) in edits { bytes[at % n] = b; } survive("Affinity native container", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?; } #[test] fn affinity_garbage_never_panics(tail in prop::collection::vec(any::(), 0..2048)) { let mut bytes = vectorcraft_affinity::MAGIC.to_vec(); bytes.extend(tail); survive("Affinity garbage", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?; } #[test] fn affinity_mutated_previews_render_and_export_without_panicking( cut in 0usize..400, edits in prop::collection::vec((0usize..400, any::()), 0..16), ) { let mut bytes = affinity_preview_sample(); for (at, b) in edits { if let Some(byte) = bytes.get_mut(at) { *byte = b; } } bytes.truncate(cut); survive("Affinity preview", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?; } #[test] fn affinity_placement_is_rejected_without_panicking(tail in prop::collection::vec(any::(), 0..2048)) { let mut bytes = vectorcraft_affinity::MAGIC.to_vec(); bytes.extend(tail); let r = catch_quiet(|| { let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width":100,"height":100})).unwrap(); let p = json!({"name":"renamed.png", "dataBase64":vectorcraft_format::base64_encode(&bytes)}); for cmd in ["file.place", "file.place.info"] { assert!(s.execute(cmd, &p).is_err()); } assert!(s.execute("file.place.queue", &json!({"files":[p]})).is_err()); }); prop_assert!(r.is_ok(), "Affinity placement panicked: {:?}", r.err()); } } /// Import must not panic; whatever comes back must render and export without panicking either. fn survive(what: &str, import: impl FnOnce() -> Option) -> 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 { vectorcraft_engine::export_pdf(&rich_doc(), &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap() } /// Numbers that tend to break arithmetic: zero, negatives, huge, tiny, exponents, junk. fn arb_num() -> impl Strategy { 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 { 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", "fill", "stroke", "stop-color", "mask-type", "data-vectorcraft-mask", "href", "baseline-shift", "word-spacing", "kerning", "writing-mode", "font-weight", "text-anchor", "side", "path", // Filters, spreads, hidden objects and linked images. "spreadMethod", "display", "filter", "clip-path", "stdDeviation", "flood-color", "flood-opacity", "in", "in2", "result", "operator", "values", "tableValues", "type", "preserveAspectRatio", "overflow", "data-vc-blend", ]); 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::>().join(" ")), prop::collection::vec(arb_num(), 0..2).prop_map(|v| format!("{}%", v.join(""))), // Colours (Lab too), mask options and links. prop::collection::vec(arb_num(), 0..4).prop_map(|v| format!("lab({})", v.join(" "))), prop::sample::select(vec!["noclip invert", "invert", "noclip", "alpha", "luminance", "url(#e)", "#e", "#zz", "none", "currentColor"]) .prop_map(str::to_string), prop::sample::select(vec![ "reflect", "repeat", "SourceAlpha", "SourceGraphic", "e", "over", "in", "table", "1 0", "multiply", "img/x%2.png", "file:///C:/nope.png", "https://x/y.png", "data:image/svg+xml;base64,PHN2Zy8+", ]) .prop_map(str::to_string), ]; (names, value).prop_map(|(n, v)| format!(" {n}=\"{v}\"")) } fn arb_svg_element(depth: u32) -> BoxedStrategy { 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", "a", "filter", "feGaussianBlur", "feOffset", "feFlood", "feComposite", "feMerge", "feMergeNode", "feColorMatrix", "feDropShadow", "feComponentTransfer", "feFuncA", ]); 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}")); 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}>{}", 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 = 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(config())] #[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!("{}", root.concat(), body.concat()); survive(&svg, || vectorcraft_svg::import(&svg).ok())?; } #[test] fn svg_hostile_css_never_panics( sheet in r#"[a-z#.>*\[\]=:;{}@ !"'/,-]{0,160}"#, decls in r#"[a-z0-9.%: ;!-]{0,60}"#, body in prop::collection::vec(arb_svg_element(1), 0..3), ) { let svg = format!( "abc{}", 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 { 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 { 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("/CS0".to_string()), Just("/DeviceCMYK".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 { 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(), // Ink colour spaces with junk names, alternates and tint transforms (spot swatch import). "<< /ColorSpace << /CS0 [/Separation /All /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [1 1 1 1] /N 1 >>] >> >>".to_string(), "<< /ColorSpace << /CS0 [/Separation /#00 /Lab << /FunctionType 2 /Domain [0 1] /C0 [1e308] /C1 [NaN 5] /N -1 >>] >> >>".to_string(), "<< /ColorSpace << /CS0 [/DeviceN [] /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(), "<< /ColorSpace << /CS0 [/DeviceN [/A /None /Cyan /All] [/ICCBased << /N 4 >>] << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(), "<< /Shading << /Sh0 << /ShadingType 2 /ColorSpace [/Separation /Ink /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> >>".to_string(), ]) } proptest! { #![proptest_config(config())] #[test] fn pdf_garbage_never_panics(bytes in prop::collection::vec(any::(), 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())?; } } /// A page box with junk in it: inverted, empty, huge, NaN or outside the media box. fn arb_box() -> impl Strategy> { prop_oneof![ Just(None), Just(Some([10.0, 10.0, 90.0, 90.0])), Just(Some([90.0, 90.0, 10.0, 10.0])), Just(Some([0.0, 0.0, 0.0, 0.0])), Just(Some([-1e308, -1e308, 1e308, 1e308])), Just(Some([f64::NAN, 0.0, 50.0, f64::INFINITY])), Just(Some([200.0, 200.0, 300.0, 300.0])), ] } proptest! { #![proptest_config(config())] /// Every page box, rotation, page pick, password and Crop To, through `document.open` and /// `document.pdfInfo` (with a thumbnail). #[test] fn pdf_import_options_never_panic( boxes in prop::collection::vec(arb_box(), 5), rotate in prop::sample::select(vec![0, 90, 180, 270, 45, -90, 1_000_000_000]), encrypt in any::(), password in prop::sample::select(vec!["", "pw", "wrong", "\u{0}\u{ff}"]), pages in prop::sample::select(vec!["1", "2", "1-2", "2-1", "0", "-", "1-", "all", "1,1,2", "99999999999999999999"]), crop in prop::sample::select(vec!["bounding", "art", "crop", "trim", "bleed", "media", "nope"]), ) { use vectorcraft_testkit::pdf::{PdfPage, pdf}; let page = PdfPage { media: boxes[0].unwrap_or([0.0, 0.0, 100.0, 100.0]), crop: boxes[1], bleed: boxes[2], trim: boxes[3], art: boxes[4], rotate, ..PdfPage::new(0.0, 0.0, "0 0 1 rg 20 20 50 50 re f") }; let bytes = pdf(&[page.clone(), page], encrypt.then_some("pw")); let b64 = vectorcraft_format::base64_encode(&bytes); let r = catch_quiet(|| { let mut s = vectorcraft_engine::Session::new(); let p = json!({"name": "x.pdf", "dataBase64": b64, "pages": pages, "cropTo": crop, "password": password, "thumbnail": 1, "thumbnailSize": 64}); let _ = s.execute("document.pdfInfo", &p); if s.execute("document.open", &p).is_ok() { let d = (*s.doc().unwrap().doc).clone(); survive("pdf options", || Some(d)).unwrap(); } }); r.map_err(|msg| TestCaseError::fail(format!("{pages} {crop} {rotate}: panicked: {msg}")))?; } } /// Hostile content over layers (optional content, one group off), soft masks, tiling patterns, /// a patch mesh, an unextended shading and fonts: the import's own readings of the file. fn rich_resources_pdf(content: &str, config: &str) -> Vec { use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog}; let x = first_extra(1); let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); let extra = [ "<< /Type /OCG /Name (A) >>".to_string(), "<< /Type /OCG /Name /Usage << /Print << /PrintState /OFF >> >> >>".to_string(), stream( "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB /I true /K true >>", "1 g 0 0 50 50 re f /OC /MC0 BDC 0 g 0 0 5 5 re f EMC", ), stream( "/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 1e-9 10] /XStep 0.001 /YStep 10 /Resources << >>", "0 0 5 5 re f /P0 scn 0 0 1 1 re f", ), stream( "/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode", "000A0A0A250A3F0A5A255A3F5A5A5A5A3F5A255A0A3F0A250AFF000000FF000000FFFFFFFF>", ), stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC 6 0 R", "/X0 Do"), ]; let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5); let resources = format!( "/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>" ); let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R 99 0 R] /D << {} >> >> ", config.replace("{B}", &format!("{b} 0 R"))); let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); pdf_with_catalog(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, &catalog, None) } fn arb_rich_op() -> impl Strategy { prop_oneof![ arb_pdf_op(), prop::sample::select(vec![ "/OC /MC0 BDC", "/OC /MC1 BDC", "/OC /Nope BDC", "/Span << /MCID 3 >> BDC", "/X BMC", "EMC", "/X0 Do", "/X1 Do", "/GS0 gs", "/GS1 gs", "/CS0 cs 1 0 0 /P0 scn", "/Pattern cs /P0 scn", "/Sh0 sh", "/Sh1 sh", "BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET", // Lines wrapped in a frame, digits set apart by gaps, glyph outlines stroked (#508). "BT /F1 10 Tf 10 80 Td (Wrapped line ) Tj 0 -12 Td (and its next ) Tj 0 -12 Td (end.) Tj ET", "BT /F1 10 Tf 10 40 Td (3) Tj 20 0 Td (5331) Tj 40 0 Td (0106) Tj ET", "0.5 w 10 10 m 14 18 l 18 10 l h S", "7 Tr", "q 10 10 50 50 re W n", "Q", "0 0 100 100 re f", "10 10 20 20 re B", ]) .prop_map(String::from), ] } proptest! { #![proptest_config(config())] #[test] fn pdf_layers_masks_patterns_and_text_never_panic( ops in prop::collection::vec(arb_rich_op(), 0..40), config in prop::sample::select(vec!["/OFF [{B}]", "/BaseState /OFF", "/BaseState /Unchanged /ON [{B}] /Locked [{B}]", "", "/OFF {B}"]), layers in any::(), outlines in any::(), ) { let bytes = rich_resources_pdf(&ops.join("\n"), config); let opts = vectorcraft_pdf::ImportOptions { layers, text_as: if outlines { vectorcraft_pdf::TextAs::Outlines } else { vectorcraft_pdf::TextAs::Text }, ..Default::default() }; survive(&ops.join(" "), || vectorcraft_pdf::import_with_report(&bytes, &opts).ok().map(|r| r.document))?; } } // ---------- library files ---------- /// The `data` of a library file `cmd` writes for the rich document. fn saved(cmd: &str, params: Value) -> String { rich_session().execute(cmd, ¶ms).unwrap()["data"].as_str().unwrap().to_string() } /// `r`, after failing the case if the command panicked. The engine's guard reports a panic as /// `EngineError::Internal`. fn no_panic(r: Result) -> Result { if let Err(vectorcraft_engine::EngineError::Internal { cmd, msg }) = &r { panic!("{cmd} panicked: {msg}"); } r } /// Library file `data` loaded with `load`; what it holds is then used on the rich document /// (`then`, given the load's result), which renders and exports. fn survive_library(what: &str, load: &str, data: &str, then: impl FnOnce(&mut vectorcraft_engine::Session, &Value)) -> Result<(), TestCaseError> { survive(what, || { let mut s = rich_session(); let r = no_panic(s.execute(load, &json!({"data": data, "name": "fuzz"}))).ok()?; then(&mut s, &r); Some((*s.doc().ok()?.doc).clone()) }) } fn swatches(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "swatch.library.load", data, |s, r| { let _ = no_panic(s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"}))); }) } fn styles(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "graphicStyle.loadLibrary", data, |s, r| { let _ = no_panic(s.execute("select.all", &json!({}))); let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": r["library"], "apply": true}))); }) } fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "flattener.presets.import", data, |s, r| { let _ = no_panic(s.execute("select.all", &json!({}))); if let Some(name) = r["imported"].get(0) { let _ = no_panic(s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"}))); let _ = no_panic(s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36}))); } }) } /// Swatch exchange (`.ase`) bytes loaded as a swatch library, then added to the rich document. fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> { survive(what, || { let mut s = rich_session(); let load = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.ase"}); let r = no_panic(s.execute("swatch.library.load", &load)).ok()?; let _ = no_panic(s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"}))); Some((*s.doc().ok()?.doc).clone()) }) } /// A random swatch exchange block (type, body). The body holds a name, a color model when the block /// is a color, and random bytes for the rest. fn arb_ase_block() -> impl Strategy)> { let kind = prop::sample::select(vec![0xC001u16, 0xC002, 0x0001, 0x0042]); let model = prop::sample::select(vec![*b"RGB ", *b"CMYK", *b"LAB ", *b"Gray", *b"HSB "]); (kind, "[ -~]{0,8}", model, prop::collection::vec(any::(), 0..24)).prop_map(|(kind, name, model, rest)| { let units: Vec = name.encode_utf16().chain([0]).collect(); let mut body = (units.len() as u16).to_be_bytes().to_vec(); body.extend(units.iter().flat_map(|u| u.to_be_bytes())); if kind == 0x0001 { body.extend(model); } body.extend(rest); (kind, body) }) } /// Characters that break JSON and GPL palettes. fn arb_edit() -> impl Strategy { (0usize..20_000, prop::sample::select(vec!['{', '}', '[', ']', '"', ':', ',', '-', '9', 'e', '.', ' ', '\n', '#', 'n', 'x', '\t'])) } proptest! { #![proptest_config(config())] #[test] fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", "])) { let data = format!("{head}{s}"); swatches("swatch library garbage", &data)?; styles("style library garbage", &data)?; flattener_presets("flattener preset garbage", &data)?; } #[test] fn mutated_swatch_libraries_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10), gpl in any::()) { let text = saved("swatch.library.save", json!({"format": if gpl { "gpl" } else { "vcswatches" }})); swatches("mutated swatch library", &mutate_text(&text, cut, &edits))?; } #[test] fn mutated_style_libraries_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { let text = saved("graphicStyle.saveLibrary", json!({})); styles("mutated style library", &mutate_text(&text, cut, &edits))?; } #[test] fn mutated_flattener_presets_never_panic(cut in 0usize..5_000, edits in prop::collection::vec(arb_edit(), 0..10)) { let text = saved("flattener.presets.export", json!({"names": ["high", "medium", "low"]})); flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?; } #[test] fn swatch_exchange_garbage_never_panics(blocks in prop::collection::vec(arb_ase_block(), 0..12), tail in prop::collection::vec(any::(), 0..16)) { let mut bytes = b"ASEF\0\x01\0\0".to_vec(); bytes.extend((blocks.len() as u32).to_be_bytes()); for (kind, body) in &blocks { bytes.extend(kind.to_be_bytes()); bytes.extend((body.len() as u32).to_be_bytes()); bytes.extend(body); } bytes.extend(tail); swatch_exchange("swatch exchange garbage", &bytes)?; } #[test] fn mutated_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..200), edits in prop::collection::vec((0usize..200, any::()), 0..12)) { let mut bytes = vectorcraft_testkit::ase::sample(); for (at, b) in edits { let n = bytes.len(); bytes[at % n] = b; } if let Some(cut) = cut { bytes.truncate(cut); } swatch_exchange("mutated swatch exchange file", &bytes)?; } } /// A small image of each raster format Place reads, with resolution metadata where it has some. fn raster_samples() -> Vec<(&'static str, Vec)> { let img = image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10])); let mut out = vec![]; for (name, f) in [ ("a.png", image::ImageFormat::Png), ("a.jpg", image::ImageFormat::Jpeg), ("a.tif", image::ImageFormat::Tiff), ("a.bmp", image::ImageFormat::Bmp), ] { let mut b = vec![]; img.write_to(&mut std::io::Cursor::new(&mut b), f).unwrap(); out.push((name, b)); } out[0].1 = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out[0].1, (300.0, 150.0)); out } proptest! { #![proptest_config(config())] /// Mutated image headers (resolution metadata, chunk and segment lengths) never crash reading /// their resolution or placing them. #[test] fn mutated_images_place_without_panics(which in 0usize..4, cut in 0usize..400, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { let (name, mut bytes) = raster_samples().swap_remove(which); for (at, b) in edits { if let Some(x) = bytes.get_mut(at) { *x = b; } } bytes.truncate(cut.max(8)); let r = catch_quiet(|| { let _ = vectorcraft_engine::cmd::fileio::ppi::resolution(&bytes); let _ = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&bytes, (72.0, 72.0)); let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8}); let _ = s.execute("file.place.info", &p); let _ = s.execute("file.place", &p); }); prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); } } // ---------- SVG saved with hidden layers and editing data ---------- /// The rich document with a hidden layer, saved as SVG with its editing data. fn saved_svg() -> String { let mut d = rich_doc(); if let Some(l) = d.layers.first_mut() { std::sync::Arc::make_mut(l).visible = false; } let opts = vectorcraft_svg::ExportOptions { hidden_layers: true, preserve_editing: true, ..Default::default() }; vectorcraft_svg::export_full(&d, &opts, Some(&vectorcraft_format::save(&d, false))).svg } proptest! { #![proptest_config(config())] #[test] fn svg_mutated_save_never_panics( cut in 0usize..40_000, edits in prop::collection::vec((0usize..40_000, prop::sample::select(vec!['<', '>', '"', '/', '-', '9', 'e', '.', ' ', '#', '%', '&', ';', 'x', ']', 'A'])), 0..10), ) { let svg = mutate_text(&saved_svg(), cut, &edits); survive("mutated saved svg", || vectorcraft_engine::cmd::fileio::load("saved.svg", svg.as_bytes()).ok().map(|l| l.doc))?; } } // ---------- PDF saved with editing data ---------- /// The rich document saved as a PDF-compatible .ai (the native document embedded, uncompressed). fn saved_ai() -> Vec { static AI: std::sync::OnceLock> = std::sync::OnceLock::new(); AI.get_or_init(|| { let r = rich_session().execute("document.save", &json!({"format": "ai", "compression": {"compressText": false}})).unwrap(); vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() }) .clone() } proptest! { #![proptest_config(config())] #[test] fn ai_mutated_save_never_panics( cut in 0usize..200_000, flips in prop::collection::vec((0usize..200_000, prop::sample::select(vec![b'0', b'9', b'-', b'.', b' ', b'[', b']', b'<', b'>', b'/', b'(', b'{', b'"', 0u8, 0xff])), 0..10), ) { let mut b = saved_ai(); for (i, c) in flips { let n = b.len(); b[i % n] = c; } b.truncate(cut.min(b.len()).max(9)); survive("mutated ai", || vectorcraft_engine::cmd::fileio::load("saved.ai", &b).ok().map(|l| l.doc))?; } } // ---------- PDF presets files ---------- /// Import PDF presets from `data`, then export with each one imported (and save the .ai). fn pdf_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "pdf.preset.import", data, |s, r| { for name in r["imported"].as_array().into_iter().flatten() { let _ = no_panic(s.execute("document.exportPdf", &json!({"preset": name}))); let _ = no_panic(s.execute("document.save", &json!({"format": "ai", "preset": name}))); } }) } proptest! { #![proptest_config(config())] #[test] fn pdf_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcpdfpresets\", ", "{\"format\": \"vcpdfpresets\", \"presets\": [{\"name\": \"x\", \"settings\": "])) { pdf_presets("pdf presets garbage", &format!("{head}{s}"))?; } #[test] fn mutated_pdf_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { let text = saved("pdf.preset.export", json!({"names": ["VectorCraft Default", "Smallest File Size", "PDF/X-4:2010"]})); pdf_presets("mutated pdf presets", &mutate_text(&text, cut, &edits))?; } } // ---------- what Paste takes from other apps ---------- /// Load `p` into the clipboard with `cmd`, then paste it into a new document: neither may panic. fn paste_from_elsewhere(cmd: &str, p: Value) -> Result<(), TestCaseError> { let r = catch_quiet(|| { let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); if s.execute(cmd, &p).is_ok() { // Paste may refuse what it was given, e.g. a damaged PDF whose art spans more than the // canvas ("result would exceed the canvas"): an error is a fine outcome, a panic isn't. let _ = s.execute("edit.paste", &json!({"center": [50, 50]})); } }); prop_assert!(r.is_ok(), "{cmd}: panicked: {:?}", r.err()); Ok(()) } /// [`rich_pdf`], made once. fn rich_pdf_once() -> Vec { static PDF: std::sync::OnceLock> = std::sync::OnceLock::new(); PDF.get_or_init(rich_pdf).clone() } /// `bytes` with `edits` (positions wrap around) cut to `cut` bytes, as base64. fn mutated_b64(mut bytes: Vec, cut: usize, edits: &[(usize, u8)]) -> String { let n = bytes.len().max(1); for (at, b) in edits { if let Some(x) = bytes.get_mut(at % n) { *x = *b; } } bytes.truncate(cut.max(8)); vectorcraft_format::base64_encode(&bytes) } proptest! { #![proptest_config(config())] #[test] fn pasted_bitmaps_never_panic(which in 0usize..4, cut in 0usize..400, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { let (_, image) = raster_samples().swap_remove(which); paste_from_elsewhere("clipboard.importImage", json!({"dataBase64": mutated_b64(image, cut, &edits), "mime": "image/png"}))?; } #[test] fn pasted_pdf_never_panics(cut in 0usize..40_000, edits in prop::collection::vec((0usize..40_000, any::()), 0..12)) { paste_from_elsewhere("clipboard.importPdf", json!({"dataBase64": mutated_b64(rich_pdf_once(), cut, &edits)}))?; } // ASCII with control characters and line breaks, a mark and multi-byte characters (characters // no font has would send every layout to the slow font fallback). #[test] fn pasted_text_never_panics(text in r"[\x00-\x7f\u{300}\u{feff}\u{fffd}é€]{0,200}") { paste_from_elsewhere("clipboard.importText", json!({ "text": text }))?; } } // ---------- native files: compressed, and saved for older versions ---------- /// The rich document saved compressed, as v1 and as v2 (pretty). fn native_samples() -> &'static [Vec] { use vectorcraft_format::{SaveOptions, save_with}; static SAMPLES: std::sync::OnceLock>> = std::sync::OnceLock::new(); SAMPLES.get_or_init(|| { let d = rich_doc(); [ SaveOptions { compress: true, ..SaveOptions::default() }, SaveOptions { version: 1, ..SaveOptions::default() }, SaveOptions { version: 2, pretty: true, ..SaveOptions::default() }, ] .iter() .map(|o| save_with(&d, o).unwrap()) .collect() }) } fn gzip(bytes: &[u8]) -> Vec { use std::io::Write as _; let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); gz.write_all(bytes).unwrap(); gz.finish().unwrap() } proptest! { #![proptest_config(config())] /// Damaged native files of every kind: flipped bytes and cuts in the compressed stream, in v1 /// and v2 text, and in the text inside an intact compressed stream. #[test] fn native_mutated_files_never_panic(which in 0usize..4, cut in 0usize..80_000, edits in prop::collection::vec((0usize..80_000, any::()), 0..10)) { let samples = native_samples(); let mut bytes = samples.get(which).cloned().unwrap_or_else(|| { let mut f = vectorcraft_format::save(&rich_doc(), false); f.truncate(cut.max(16)); f }); for &(at, b) in &edits { let n = bytes.len(); bytes[at % n] = b; } if which < samples.len() { bytes.truncate(cut.max(2)); } else { bytes = gzip(&bytes); } let _ = vectorcraft_format::sniff(&bytes); let _ = vectorcraft_format::preview(&bytes); survive("mutated native file", || vectorcraft_engine::cmd::fileio::load("x.vectorcraft", &bytes).ok().map(|l| l.doc))?; } } /// The rich document as EPS (with a preview, a thumbnail and the document it carries), and the /// document read back from it. fn eps_sample() -> Vec { let mut s = rich_session(); let r = s.execute("document.export", &json!({"format": "eps", "useArtboards": false})).unwrap(); vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() } proptest! { #![proptest_config(config())] /// Damaged EPS files: their sections, embedded document and thumbnail read as nothing, or as /// a document that renders and exports. #[test] fn eps_mutated_files_never_panic(cut in 0usize..400_000, edits in prop::collection::vec((0usize..400_000, any::()), 0..12)) { static SAMPLE: std::sync::OnceLock> = std::sync::OnceLock::new(); let mut bytes = SAMPLE.get_or_init(eps_sample).clone(); for &(at, b) in &edits { let n = bytes.len(); bytes[at % n] = b; } bytes.truncate(cut.max(4)); let _ = vectorcraft_eps::sections(&bytes); let _ = vectorcraft_eps::thumbnail(&bytes); survive("mutated EPS file", || vectorcraft_eps::native(&bytes).and_then(|n| vectorcraft_format::load(&n).ok()))?; } /// Damaged EPS files without the document: the PostScript interpreter (or the preview /// fallback) reads them as an error or a document. #[test] fn eps_mutated_postscript_never_panics(cut in 0usize..400_000, edits in prop::collection::vec((0usize..400_000, any::()), 0..12)) { static SAMPLE: std::sync::OnceLock> = std::sync::OnceLock::new(); let mut bytes = SAMPLE.get_or_init(foreign_eps_sample).clone(); for &(at, b) in &edits { let n = bytes.len(); bytes[at % n] = b; } bytes.truncate(cut.max(4)); survive("mutated EPS PostScript", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; } /// Hostile PostScript programs: operators in any order with any operands, opened and placed; /// as files in the legacy Illustrator format too (the creator line is what turns on its /// documented `u` … `U` groups), the groups unbalanced, deep and among clips. #[test] fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::()) { let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" }; let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" ")); survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?; let r = catch_quiet(|| { let mut s = rich_session(); let _ = s.execute("file.place", &json!({"name": "x.eps", "dataBase64": vectorcraft_format::base64_encode(ps.as_bytes())})); }); r.map_err(|msg| TestCaseError::fail(format!("placing hostile PostScript panicked: {msg}")))?; } } /// [`eps_sample`] as another app would write it: without the document it carries, so its /// PostScript is read. fn foreign_eps_sample() -> Vec { let mut bytes = eps_sample(); let marker = b"%VectorCraft_BeginData: native"; let at = bytes.windows(marker.len()).position(|w| w == marker).unwrap(); bytes[at + marker.len() - 1] = b'x'; bytes } /// The operators and operands of a hostile PostScript program. fn arb_ps_token() -> impl Strategy { prop_oneof![ arb_num(), prop::sample::select(vec![ "moveto", "lineto", "curveto", "rlineto", "rmoveto", "closepath", "fill", "eofill", "stroke", "clip", "eoclip", "newpath", "gsave", "grestore", "save", "restore", "concat", "translate", "scale", "rotate", "setlinewidth", "setdash", "setgray", "setrgbcolor", "setcmykcolor", "sethsbcolor", "setcolorspace", "setcolor", "setpattern", "makepattern", "shfill", "arc", "arcn", "arct", "arcto", "rectfill", "rectclip", "rectstroke", "image", "imagemask", "colorimage", "currentfile", "filter", "readhexstring", "readstring", "show", "stringwidth", "charpath", "findfont", "scalefont", "makefont", "setfont", "selectfont", "def", "bind", "load", "dup", "exch", "pop", "roll", "index", "copy", "for", "repeat", "loop", "exit", "if", "ifelse", "exec", "stopped", "begin", "end", "dict", "array", "string", "get", "put", "getinterval", "putinterval", "aload", "astore", "forall", "[", "]", "<<", ">>", "{", "}", "matrix", "currentmatrix", "setmatrix", "transform", "itransform", "invertmatrix", "pathbbox", "clippath", "initclip", "currentpoint", "eexec", "cleartomark", "mark", "counttomark", "showpage", "gstate", "setgstate", "clipsave", "cliprestore", "languagelevel", "cvx", "findresource", "defineresource", "undefineresource", "resourcestatus", "resourceforall", "/Category", "/Generic", "/ProcSet", "(*)", "(G?n*\\\\*?)", "{pop}", "8 string", "(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource", "dictstack", "8 array", "1183615869 internaldict", "cshow", "currentsystemparams", "currentuserparams", "flushfile", "xcheck", "systemdict", "/userdict", "SharedFontDirectory", "(1 2 add) 0 () /SubFileDecode filter cvx exec", "currentfile cvx exec", "/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "currentfile 0 () /SubFileDecode filter cvx exec", "cvlit", "{ (a) cshow }", "/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode", "/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "", "<~87cURD]i,\"Ebo80~>", "true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 ]", "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 1] /Function << /FunctionType 2 /C0 [0 0 0] /C1 [1 1 1] /N 1 >> >>", "<< /PatternType 2 /Shading << /ShadingType 3 /ColorSpace /DeviceGray /Coords [0 0 0 9 9 9] /Function << /FunctionType 3 /Functions [] /Bounds [] /Encode [] >> >> >>", "<< /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 8 /ImageMatrix [2 0 0 2 0 0] /DataSource (abcdefghijkl) >>", "u", "U", "u u u", "U U", "{ u } 300 repeat", "1 1 300 { pop u 0 0 9 9 rectclip } for", // Probing, files, forms and devices. "status", "token", "bytesavailable", "resetfile", "pdfmark", "execform", "nulldevice", "strokepath", "pathforall", "currenthsbcolor", "currentcolorrendering", "gcheck", "rootfont", "setcachedevice2", "writestring", "(%stdout) (w) file", "//x", "{ //dup }", "/ReusableStreamDecode", "currentfile /ASCII85Decode filter /ReusableStreamDecode filter", "<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>", // Tiling patterns, uncoloured ones and patterns drawing patterns. "<< /PatternType 1 /PaintType 1 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop 0 0 3 3 rectfill } >> matrix makepattern", "<< /PatternType 1 /PaintType 2 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop dup setpattern 0 0 3 3 rectfill } >>", "[/Pattern /DeviceRGB] setcolorspace", "1 0 0", "setpattern", // Mesh and function shadings, sampled functions. "<< /ShadingType 4 /ColorSpace /DeviceGray /DataSource [0 0 0 0 0 9 0 1 0 0 9 1 1 9 9 0] >>", "<< /ShadingType 5 /ColorSpace /DeviceGray /VerticesPerRow 2 /DataSource [0 0 0 9 0 1 0 9 1 9 9 0] >>", "<< /ShadingType 6 /ColorSpace /DeviceGray /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 9 0 9 0 1] /DataSource <00ff10ff20ff30> >>", "<< /ShadingType 7 /ColorSpace /DeviceRGB /BitsPerCoordinate 32 /BitsPerComponent 16 /BitsPerFlag 8 /Decode [0 9 0 9 0 1 0 1 0 1] /DataSource (abc) >>", "<< /ShadingType 1 /ColorSpace /DeviceGray /Function << /FunctionType 0 /Domain [0 1 0 1] /Range [0 1] /Size [2 2] /BitsPerSample 8 /DataSource <00ff00ff> >> >>", "<< /FunctionType 0 /Domain [0 1] /Range [0 1 0 1 0 1] /Size [99999999] /BitsPerSample 32 /DataSource () >>", // Masked images. "<< /ImageType 3 /InterleaveType 1 /DataDict << /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 8 /ImageMatrix [2 0 0 2 0 0] /DataSource (abcdefghijklmnop) >> /MaskDict << /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 1 /ImageMatrix [2 0 0 2 0 0] >> >>", "<< /ImageType 3 /InterleaveType 2 /DataDict << /ImageType 1 /Width 3 /Height 1 /BitsPerComponent 8 /ImageMatrix [3 0 0 1 0 0] /DataSource (abcdefghijklmnop) >> /MaskDict << /ImageType 1 /Width 3 /Height 5 /BitsPerComponent 1 /ImageMatrix [3 0 0 5 0 0] >> >>", // Type 3 fonts and the show operators that space their glyphs. "/T3 << /FontType 3 /FontMatrix [0.1 0 0 0.1 0 0] /FontBBox [0 0 9 9] /Encoding [/a /b] /BuildChar { pop pop 9 0 setcharwidth 0 0 5 5 rectfill } >> definefont setfont", "/T4 << /FontType 3 /FontMatrix [1 0 0 1 0 0] /BuildGlyph { pop pop (x) show } /Encoding [/a] >> definefont setfont", "glyphshow", "xshow", "xyshow", "awidthshow", "kshow", "/a", "[1 2 3]", // Several data sources reading one file in turn, and sources of uneven lengths. "/f currentfile /ASCIIHexDecode filter def", "{f 3 string readstring pop}", "{f 0 string readstring pop}", "true 3 colorimage", "3 2 8 [3 0 0 2 0 0] {f 5 string readstring pop} {(ab)} {f 1 string readstring pop} true 3 colorimage", "<< /ImageType 1 /Width 3 /Height 2 /BitsPerComponent 4 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [3 0 0 2 0 0] /MultipleDataSources true /DataSource [(a) (bc) {f 2 string readstring pop} (d)] >>", // Local VM restored: dictionary changes undone, saves restored twice. "/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store", // Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`). "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit" ]) .prop_map(str::to_string), ] } proptest! { #![proptest_config(config())] /// Damaged password-protected PDFs, opened with the open password, the permissions password /// (which reads the encryption dictionary to find the open password) or a wrong one. #[test] fn mutated_encrypted_pdfs_never_panic( cut in 0usize..2_000, edits in prop::collection::vec((0usize..2_000, any::()), 0..12), password in prop::sample::select(vec!["pw", "pw-owner", "wrong", ""]), ) { use vectorcraft_testkit::pdf::{PdfPage, pdf}; let bytes = pdf(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 20 20 50 50 re f")], Some("pw")); let b64 = mutated_b64(bytes, cut, &edits); let r = catch_quiet(|| { let mut s = vectorcraft_engine::Session::new(); let p = json!({"name": "x.pdf", "dataBase64": b64, "password": password}); let _ = s.execute("document.pdfInfo", &p); if s.execute("document.open", &p).is_ok() { let d = (*s.doc().unwrap().doc).clone(); survive("encrypted pdf", || Some(d)).unwrap(); } }); r.map_err(|msg| TestCaseError::fail(format!("{password}: panicked: {msg}")))?; } } // ---------- DXF ---------- /// The rich document exported as DXF (2018: hatches, splines, text, images; R12: polylines). fn rich_dxf(version: &str) -> Vec { vectorcraft_engine::cmd::fileio::encode(&rich_doc(), "dxf", &json!({"version": version, "preserve": "editability"})).unwrap() } /// Group-code pairs as DXF text. fn dxf_text(pairs: &[(i32, String)]) -> String { pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect() } /// One entity: a type and a run of groups with hostile numbers and names. fn arb_dxf_entity() -> impl Strategy> { let kind = prop::sample::select(vec![ "LINE", "CIRCLE", "ARC", "ELLIPSE", "LWPOLYLINE", "POLYLINE", "VERTEX", "SEQEND", "SPLINE", "SOLID", "3DFACE", "HATCH", "TEXT", "MTEXT", "INSERT", "ATTRIB", "DIMENSION", "VIEWPORT", ]); let code = prop::sample::select(vec![ 2, 6, 7, 8, 10, 11, 12, 13, 20, 21, 22, 23, 40, 41, 42, 43, 44, 45, 48, 50, 51, 60, 62, 66, 67, 70, 71, 72, 73, 74, 75, 76, 90, 91, 92, 93, 94, 95, 96, 97, 210, 220, 230, 370, 420, 440, 450, ]); let value = prop_oneof![arb_num(), Just("B".to_string()), Just("*U1".to_string()), Just(r"{\fA;x}\P%%c^".to_string())]; (kind, prop::collection::vec((code, value), 0..24)).prop_map(|(k, g)| std::iter::once((0, k.to_string())).chain(g).collect()) } /// A drawing whose block "B" holds `block` (it may insert itself) and whose entities are `body`. fn hostile_dxf(block: &[Vec<(i32, String)>], body: &[Vec<(i32, String)>]) -> Vec { let head: Vec<(i32, String)> = [(0, "SECTION"), (2, "BLOCKS"), (0, "BLOCK"), (2, "B"), (10, "0"), (20, "0")].iter().map(|(c, v)| (*c, v.to_string())).collect(); let mid: Vec<(i32, String)> = [(0, "ENDBLK"), (0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")].iter().map(|(c, v)| (*c, v.to_string())).collect(); let tail = vec![(0, "ENDSEC".to_string()), (0, "EOF".to_string())]; let all: Vec<(i32, String)> = head.into_iter().chain(block.concat()).chain(mid).chain(body.concat()).chain(tail).collect(); dxf_text(&all).into_bytes() } /// Import with every option at once, and place it; whatever comes back must render and export. fn survive_dxf(what: &str, bytes: &[u8], fit: bool) -> Result<(), TestCaseError> { let o = vectorcraft_cad::ImportOptions { fit, center: !fit, merge_layers: fit, ..Default::default() }; survive(what, || vectorcraft_cad::import(bytes, &o).ok().map(|r| r.document))?; let _ = vectorcraft_cad::info(bytes); let _ = vectorcraft_cad::is_dxf(bytes); survive(what, || { let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width": 300, "height": 200})).ok()?; let p = json!({"name": "x.dxf", "dataBase64": vectorcraft_format::base64_encode(bytes), "dxf": {"fit": fit}, "thumbnail": 8}); let _ = s.execute("file.place.info", &p); let _ = s.execute("file.place", &p); let _ = s.execute("document.dxfInfo", &p); Some((*s.doc().ok()?.doc).clone()) }) } proptest! { #![proptest_config(config())] #[test] fn dxf_garbage_never_panics(bytes in prop::collection::vec(any::(), 0..400), section in any::()) { let mut b = if section { b" 0\nSECTION\n 2\nENTITIES\n".to_vec() } else { vec![] }; b.extend(bytes); survive_dxf("dxf garbage", &b, section)?; } #[test] fn dxf_hostile_entities_never_panic( block in prop::collection::vec(arb_dxf_entity(), 0..4), body in prop::collection::vec(arb_dxf_entity(), 0..8), fit in any::(), ) { let bytes = hostile_dxf(&block, &body); survive_dxf("hostile dxf", &bytes, fit)?; } #[test] fn dxf_mutated_export_never_panics( r12 in any::(), cut in 0usize..60_000, edits in prop::collection::vec((0usize..60_000, prop::sample::select(vec![b'0', b'1', b'9', b'-', b'.', b'e', b'\n', b' ', b'A', b'^', b'\\', 0xC3])), 0..12), ) { static SAMPLES: std::sync::OnceLock<[Vec; 2]> = std::sync::OnceLock::new(); let samples = SAMPLES.get_or_init(|| [rich_dxf("2018"), rich_dxf("R12")]); let mut bytes = samples[usize::from(r12)].clone(); for &(at, b) in &edits { let n = bytes.len(); bytes[at % n] = b; } bytes.truncate(cut.max(8)); survive_dxf("mutated dxf", &bytes, cut % 2 == 0)?; } } // ---------- Windows metafiles: opened, placed and pasted ---------- /// The rich document as EMF and as WMF. fn metafile_samples() -> &'static [Vec] { static SAMPLES: std::sync::OnceLock>> = std::sync::OnceLock::new(); SAMPLES.get_or_init(|| { let mut s = rich_session(); ["emf", "wmf"] .iter() .map(|f| { let r = s.execute("document.export", &json!({ "format": f })).unwrap(); vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() }) .collect() }) } /// An EMF of a 100 × 100 mm frame on a device of 10 pixels a millimetre holding `records`. fn emf_of(records: &[(u32, Vec)]) -> Vec { let mut b: Vec = vec![]; let mut head = vec![0u8; 108]; head[..4].copy_from_slice(&1u32.to_le_bytes()); head[4..8].copy_from_slice(&108u32.to_le_bytes()); for (at, v) in [(32, 10_000i32), (36, 10_000), (72, 1000), (76, 1000), (80, 100), (84, 100), (100, 100_000), (104, 100_000)] { head[at..at + 4].copy_from_slice(&v.to_le_bytes()); } head[40..44].copy_from_slice(b" EMF"); b.extend(head); for (kind, body) in records { let mut body = body.clone(); body.resize(body.len().div_ceil(4) * 4, 0); b.extend(kind.to_le_bytes()); b.extend((8 + body.len() as u32).to_le_bytes()); b.extend(body); } b.extend([14, 0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 20, 0, 0, 0]); b } /// A placeable WMF of a 1 × 1 inch box holding `records`. fn wmf_of(records: &[(u16, Vec)]) -> Vec { let mut b: Vec = vec![0xD7, 0xCD, 0xC6, 0x9A, 0, 0, 0, 0, 0, 0, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05, 0, 0, 0, 0]; let sum = b.as_chunks::<2>().0.iter().fold(0u16, |a, w| a ^ u16::from_le_bytes(*w)); b.extend(sum.to_le_bytes()); b.extend([1, 0, 9, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); for (function, body) in records { let mut body = body.clone(); body.resize(body.len().div_ceil(2) * 2, 0); b.extend((3 + body.len() as u32 / 2).to_le_bytes()); b.extend(function.to_le_bytes()); b.extend(body); } b.extend([3, 0, 0, 0, 0, 0]); b } /// Open the bytes as `name`, and place them: neither may panic. fn survive_metafile(what: &str, name: &str, bytes: &[u8]) -> Result<(), TestCaseError> { survive(what, || vectorcraft_engine::cmd::fileio::load(name, bytes).ok().map(|l| l.doc))?; let r = catch_quiet(|| { let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); let _ = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})); }); prop_assert!(r.is_ok(), "{what}: placing panicked: {:?}", r.err()); Ok(()) } /// A record body: random bytes, or small numbers (coordinates, counts and offsets that land). fn arb_body() -> impl Strategy> { prop_oneof![ prop::collection::vec(any::(), 0..96), prop::collection::vec(prop_oneof![Just(0i32), Just(1), Just(-1), 0i32..200, Just(i32::MAX), Just(i32::MIN)], 0..24) .prop_map(|v| v.iter().flat_map(|x| x.to_le_bytes()).collect()), ] } /// WMF record functions VectorCraft reads, and one it doesn't. const WMF_FUNCTIONS: [u16; 33] = [ 0x0324, 0x0325, 0x0538, 0x041B, 0x0418, 0x061C, 0x0817, 0x081A, 0x0830, 0x0416, 0x0415, 0x02FA, 0x02FC, 0x02FB, 0x012D, 0x01F0, 0x0521, 0x0A32, 0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0999, ]; proptest! { #![proptest_config(config())] #[test] fn mutated_metafiles_never_panic(which in 0usize..2, cut in 0usize..200_000, edits in prop::collection::vec((0usize..200_000, any::()), 0..16)) { let mut bytes = metafile_samples()[which].clone(); let n = bytes.len(); for &(at, b) in &edits { bytes[at % n] = b; } bytes.truncate(cut.max(8)); survive_metafile("mutated metafile", ["x.emf", "x.wmf"][which], &bytes)?; } #[test] fn random_emf_records_never_panic(records in prop::collection::vec((0u32..124, arb_body()), 0..32)) { survive_metafile("EMF records", "x.emf", &emf_of(&records))?; } #[test] fn random_wmf_records_never_panic(records in prop::collection::vec((prop::sample::select(WMF_FUNCTIONS.to_vec()), arb_body()), 0..32)) { survive_metafile("WMF records", "x.wmf", &wmf_of(&records))?; } #[test] fn pasted_metafiles_never_panic(which in 0usize..2, cut in 0usize..200_000, edits in prop::collection::vec((0usize..200_000, any::()), 0..12)) { paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?; } } // ---------- print presets files ---------- /// Import print presets from `data`, then lay out (and print, when short) the rich document with /// each one imported. fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "print.presets.import", data, |s, r| { let list = no_panic(s.execute("print.presets.list", &json!({}))).unwrap_or_default(); for name in r["imported"].as_array().into_iter().flatten() { let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue }; let settings = json!({ "settings": p["settings"] }); let pages = no_panic(s.execute("print.preview", &settings)).ok().and_then(|v| v["pages"].as_u64()); let _ = no_panic(s.execute("print.setup", &settings)); if pages.is_some_and(|n| n <= 8) { let _ = no_panic(s.execute("file.print", &settings)); } } }) } proptest! { #![proptest_config(config())] #[test] fn print_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcprintpresets\", ", "{\"format\": \"vcprintpresets\", \"presets\": [{\"name\": \"x\", \"settings\": "])) { print_presets("print presets garbage", &format!("{head}{s}"))?; } #[test] fn mutated_print_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { let mut s = rich_session(); s.execute("print.presets.save", &json!({"name": "Tiles", "settings": {"scaling": "tileFull", "scale": {"width": 300, "height": 300}, "marks": {"trim": true}}})).unwrap(); s.execute("print.presets.save", &json!({"name": "Seps", "settings": {"output": {"mode": "separations", "inks": [{"name": "Cyan", "print": false}]}}})).unwrap(); let text = s.execute("print.presets.export", &json!({"names": ["Tiles", "Seps", "[Default]"]})).unwrap()["data"].as_str().unwrap().to_string(); print_presets("mutated print presets", &mutate_text(&text, cut, &edits))?; } } // ---------- perspective grid presets files ---------- /// Import perspective grid presets from `data`, then apply each one and draw on its grid. fn perspective_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "perspective.presets.import", data, |s, r| { for name in r["imported"].as_array().into_iter().flatten() { let _ = no_panic(s.execute("perspective.grid.preset", &json!({"name": name}))); let _ = no_panic(s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3}))); let _ = no_panic( s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}})), ); } }) } proptest! { #![proptest_config(config())] #[test] fn perspective_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcperspective\", ", "{\"format\": \"vcperspective\", \"presets\": [{\"name\": \"x\", \"kind\": 3, \"scale\": "])) { perspective_presets("perspective presets garbage", &format!("{head}{s}"))?; } #[test] fn mutated_perspective_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { let mut s = rich_session(); s.execute("perspective.presets.save", &json!({"name": "Tall", "kind": 3, "units": "inches", "scale": [1, 4], "angle": 25, "thirdVp": [1, 30]})).unwrap(); s.execute("perspective.presets.save", &json!({"name": "Flat", "preset": "[1P-Low View]", "gridline": 4, "groundColor": "#00ff00"})).unwrap(); let text = s.execute("perspective.presets.export", &json!({"names": ["Tall", "Flat", "[2P-High View]"]})).unwrap()["data"].as_str().unwrap().to_string(); perspective_presets("mutated perspective presets", &mutate_text(&text, cut, &edits))?; } }