vectorcraft/crates/engine/tests/import_fuzz.rs

1283 lines
59 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.

//! 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`), 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.
//!
//! `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
}
/// 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::uncompressed()).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",
"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::<Vec<_>>().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<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",
"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}</{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(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!("<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_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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>{sheet}</style><g class=\"a b\"><text id=\"t\" class=\"a\" x=\"1 2 3\" y=\"9\" rotate=\"5\" style=\"{decls}\">ab<tspan dy=\"3\">c</tspan></text></g>{}</svg>",
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("/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<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(),
// 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::<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())?;
}
}
/// A page box with junk in it: inverted, empty, huge, NaN or outside the media box.
fn arb_box() -> impl Strategy<Value = Option<[f64; 4]>> {
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::<bool>(),
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<u8> {
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 <FEFF> /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<Value = String> {
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::<bool>(),
outlines in any::<bool>(),
) {
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, &params).unwrap()["data"].as_str().unwrap().to_string()
}
/// 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 = 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 _ = 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 _ = s.execute("select.all", &json!({}));
let _ = 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 _ = s.execute("select.all", &json!({}));
if let Some(name) = r["imported"].get(0) {
let _ = s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"}));
let _ = s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36}));
}
})
}
/// Characters that break JSON and GPL palettes.
fn arb_edit() -> impl Strategy<Value = (usize, char)> {
(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::<bool>()) {
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))?;
}
}
/// A small image of each raster format Place reads, with resolution metadata where it has some.
fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
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::<u8>()), 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<u8> {
static AI: std::sync::OnceLock<Vec<u8>> = 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 _ = s.execute("document.exportPdf", &json!({"preset": name}));
let _ = 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<u8> {
static PDF: std::sync::OnceLock<Vec<u8>> = 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<u8>, 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::<u8>()), 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::<u8>()), 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<u8>] {
use vectorcraft_format::{SaveOptions, save_with};
static SAMPLES: std::sync::OnceLock<Vec<Vec<u8>>> = 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<u8> {
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::<u8>()), 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<u8> {
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::<u8>()), 0..12)) {
static SAMPLE: std::sync::OnceLock<Vec<u8>> = 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::<u8>()), 0..12)) {
static SAMPLE: std::sync::OnceLock<Vec<u8>> = 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::<bool>()) {
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<u8> {
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<Value = String> {
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)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
"<< /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::<u8>()), 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<u8> {
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<Value = Vec<(i32, String)>> {
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<u8> {
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::<u8>(), 0..400), section in any::<bool>()) {
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::<bool>(),
) {
let bytes = hostile_dxf(&block, &body);
survive_dxf("hostile dxf", &bytes, fit)?;
}
#[test]
fn dxf_mutated_export_never_panics(
r12 in any::<bool>(),
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<u8>; 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<u8>] {
static SAMPLES: std::sync::OnceLock<Vec<Vec<u8>>> = 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<u8>)]) -> Vec<u8> {
let mut b: Vec<u8> = 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<u8>)]) -> Vec<u8> {
let mut b: Vec<u8> = 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<Value = Vec<u8>> {
prop_oneof![
prop::collection::vec(any::<u8>(), 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::<u8>()), 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::<u8>()), 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 = 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 = s.execute("print.preview", &settings).ok().and_then(|v| v["pages"].as_u64());
let _ = s.execute("print.setup", &settings);
if pages.is_some_and(|n| n <= 8) {
let _ = 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 _ = s.execute("perspective.grid.preset", &json!({"name": name}));
let _ = s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3}));
let _ = 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))?;
}
}