Files
vectorcraft/crates/engine/tests/import_fuzz.rs
T
dotCooCoo 8c14609370 Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
A shortcut set another app saved as slash-name text (a .kys file whose
text starts with a slash name and "{", such as "/Menus {") opens in
Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts ›
Import… in the desktop app, or from a drop on the window, whether or
not a document is open. The dialog proposes a target for an entry when
an earlier import kept one for that name, or else when one tool,
command or panel matches the name clearly by its id or by one of its
labels. It decodes the file's key and modifier codes from the codes
earlier imports kept, from the example in the AiCommandPalette README
(/Key 48 for the 0 key, with /Modifiers 0 taken as no modifiers), and
from VectorCraft's default shortcuts where at least two entries vote and
four votes in five agree. The user picks the remaining targets, presses
the key for each unknown key code or marks it No equivalent, and chooses
the modifiers for each unknown modifier code. Apply assigns the ready
rows with the Keyboard Shortcuts rules: a tool takes a single key,
optionally with Shift. It goes through the rows from the last one back
and leaves a row out when a later row it applies has the same shortcut
or the same tool, command or panel. Keyboard Shortcuts then shows the
result as unsaved changes until the user clicks OK. Copy Report copies
the set's name, the rows Apply leaves out and the parts of the file that
weren't read.

- shortcutSet.read is a query command: it reads a set and does not
  modify the document or the preferences. It returns each entry with
  its section, codes and line, and up to 1,000 skipped tokens, each cut
  to 80 characters. It reads at most 4 MiB of text and 20,000 entries,
  skips blocks nested more than 16 deep and cuts names at 1,024 bytes.
  With a path it reads at most 4 MiB and 4 bytes of the file, and with
  --automation-read-root (#832) the path must lie inside the read root.
  A file whose text starts with "<", such as an XML shortcut file saved
  as UTF-8, fails with "This shortcut file is in a form VectorCraft
  can't read."
- importMatches.list, importMatches.set and importMatches.reset read
  and change the matches kept with the preferences; resetting the
  preferences keeps them. With Remember My Choices on (the default),
  Apply keeps the targets and codes the user chose, and for the rows it
  assigns, it also keeps the matched targets and the codes decoded by
  agreement. A later import proposes them.
- The name matcher compares a name with ids by exact match and shared
  words. It compares the name with labels, in English and in the
  interface language, by exact match, shared words and character
  pairs. It proposes a match only when one tool, command or panel
  scores at least 0.6 and at least 0.1 more than the next one.
- When Import Shortcut Set is opened from Keyboard Shortcuts, Cancel,
  Escape and ui.dialog.cancel close it and return to Keyboard Shortcuts
  as it was (dialogs::PARENT). A document's queued Missing Fonts dialog
  (#792) opens after Keyboard Shortcuts closes.
- File › Open has a "Shortcut sets" filter, and in the desktop app
  Keyboard Shortcuts' Import… shows .json and .kys files.

Docs: docs/mcp.md, docs/control-protocol.md, docs/development.md,
docs/target-app-parity.md, docs/roadmap.md.
2026-10-14 13:22:07 +07:00

3379 lines
155 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 (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset
//! (`.vcflattener`) libraries, shortcut sets other apps save as text (`.kys`), 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 the editing data of
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the
//! font files a folder search reads (damaged fonts, collections whose headers give any count of
//! faces), nor scripts written for the reference app checked without running them (garbage,
//! generated script text and mutated scripts).
//!
//! `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<u8> {
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<u8> {
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<u8> {
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::<u8>()), 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::<u8>()), 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::<u8>(), 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::<u8>()), 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::<u8>(), 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!(no_panic(s.execute(cmd, &p)).is_err()); }
assert!(no_panic(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<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()
}
/// Type shifted two billion points down exports to PDF: an input of
/// `svg_hostile_markup_never_panics`, cut down to its type.
#[test]
fn svg_type_shifted_two_billion_points_exports_to_pdf() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><text baseline-shift="-2147483649">f</text></svg>"#;
let d = vectorcraft_svg::import(svg).unwrap();
assert!(vectorcraft_engine::export_pdf(&d, &vectorcraft_pdf::PdfOptions::default()).is_ok());
}
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> {
handmade_pdf_with(content, resources, media, &[])
}
/// The same, followed by the objects `more` (numbered from 5).
fn handmade_pdf_with(content: &str, resources: &str, media: &str, more: &[Vec<u8>]) -> Vec<u8> {
let page = [
"<< /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),
];
pdf_of(&page.into_iter().map(String::into_bytes).chain(more.iter().cloned()).collect::<Vec<_>>())
}
/// The PDF of the objects `objs` (numbered from 1, the catalog first).
fn pdf_of(objs: &[Vec<u8>]) -> Vec<u8> {
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", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
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 _ = no_panic(s.execute("document.pdfInfo", &p));
if no_panic(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 << /OP true /OPM 1 /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /OP true /op false /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> /GS2 << /OP 7 /op (x) /OPM -3 >> /GS3 << /op true >> >> /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",
// Overprint (#1388): set, malformed, and for fills alone.
"/GS2 gs",
"/GS3 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()
}
/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as
/// `EngineError::Internal`.
fn no_panic(r: Result<Value, vectorcraft_engine::EngineError>) -> Result<Value, vectorcraft_engine::EngineError> {
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})));
})
}
/// Libraries panel library file `data` imported, then each kind's first item used on the rich
/// document and moved into its first group, the group deleted and the library exported (#926).
fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "library.import", data, |s, r| {
let lib = r["id"].clone();
let Ok(got) = no_panic(s.execute("library.get", &json!({"library": lib}))) else { return };
let _ = no_panic(s.execute("select.all", &json!({})));
for (list, kind, key) in
[("graphics", "graphic", "id"), ("colors", "fillColor", "name"), ("charStyles", "charStyle", "name"), ("paraStyles", "paraStyle", "name")]
{
if let Some(item) = got[list].get(0) {
let _ = no_panic(s.execute("library.use", &json!({"library": lib, "kind": kind, "item": item[key]})));
let to = json!({"library": lib, "kind": kind, "item": item[key], "group": got["groups"][0]["name"]});
let _ = no_panic(s.execute("library.moveItem", &to));
}
}
if let Some(g) = got["groups"].get(0) {
let _ = no_panic(s.execute("library.deleteGroup", &json!({"library": lib, "group": g["name"]})));
}
let _ = no_panic(s.execute("library.export", &json!({"library": lib})));
})
}
/// A Libraries panel library of the rich document's first object (a graphic), a colour, its
/// type's character and paragraph styles and a group, exported, made once.
fn saved_library() -> String {
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
TEXT.get_or_init(|| {
let mut s = rich_session();
s.execute("library.create", &json!({"name": "Fuzz"})).unwrap();
let first = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
let g = s.execute("library.add", &json!({"kind": "graphic", "ids": [first]})).unwrap()["id"].clone();
s.execute("library.add", &json!({"kind": "fillColor", "color": "#336699"})).unwrap();
s.execute("select.all", &json!({})).unwrap();
for kind in ["charStyle", "paraStyle"] {
s.execute("library.add", &json!({"kind": kind})).unwrap();
}
s.execute(
"library.createGroup",
&json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}, {"kind": "fillColor", "item": "#336699"}]}),
)
.unwrap();
s.execute("library.export", &json!({})).unwrap()["data"].as_str().unwrap().to_string()
})
.clone()
}
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())
})
}
/// The rich document's swatches with a CMYK spot color and a global Lab color added, saved as a
/// swatch exchange file, made once.
fn saved_ase() -> Vec<u8> {
static ASE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
ASE.get_or_init(|| {
let mut s = rich_session();
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 1, "m": 0.5, "y": 0, "k": 0.2}, "spot": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}, "global": true})).unwrap();
let r = s.execute("swatch.library.save", &json!({"format": "ase"})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
})
.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<Value = (u16, Vec<u8>)> {
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::<u8>(), 0..24)).prop_map(|(kind, name, model, rest)| {
let units: Vec<u16> = 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<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\", ", "{\"name\": \"L\", "])) {
let data = format!("{head}{s}");
swatches("swatch library garbage", &data)?;
styles("style library garbage", &data)?;
flattener_presets("flattener preset garbage", &data)?;
libraries("library 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))?;
}
#[test]
fn mutated_libraries_never_panic(keep in 0.0f64..1.2, edits in prop::collection::vec(arb_edit(), 0..10)) {
// Cut anywhere in the file (whole most of the time), the edits anywhere in it.
let text = saved_library();
let cut = (text.chars().count() as f64 * keep) as usize;
libraries("mutated library", &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::<u8>(), 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::<u8>()), 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)?;
}
#[test]
fn mutated_saved_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..4_000), edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
let mut bytes = saved_ase();
for (at, b) in edits {
let n = bytes.len();
bytes[at % n] = b;
}
if let Some(cut) = cut {
bytes.truncate(cut);
}
swatch_exchange("mutated saved swatch exchange file", &bytes)?;
}
}
// ---------- shortcut sets ----------
/// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an
/// error, or a set within the caps.
fn shortcut_set(bytes: &[u8]) -> Result<(), TestCaseError> {
let mut s = vectorcraft_engine::Session::new();
let matches = json!([
{"kind": "name", "name": "A", "target": "edit.undo"},
{"kind": "key", "name": "1", "target": "K"},
{"kind": "modifiers", "name": "-1", "target": "Cmd"},
]);
s.execute("importMatches.set", &json!({ "matches": matches })).unwrap();
let read = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.kys"});
if let Ok(r) = no_panic(s.execute("shortcutSet.read", &read)) {
prop_assert!(r["entries"].as_array().map_or(0, Vec::len) <= 20_000);
prop_assert!(r["unread"].as_array().map_or(0, Vec::len) <= 1000);
}
Ok(())
}
/// A token of a text shortcut set: a name (escaped spaces), a field, an integer of any size and sign,
/// a brace, a lone backslash, a line end, NUL or bytes that aren't UTF-8.
fn arb_kys_token() -> impl Strategy<Value = Vec<u8>> {
prop_oneof![
"[A-Za-z ]{0,12}".prop_map(|n| format!("/{}", n.replace(' ', "\\ ")).into_bytes()),
prop::sample::select(vec!["/Key", "/Modifiers", "/Context", "/Represent"]).prop_map(|f| f.as_bytes().to_vec()),
any::<i64>().prop_map(|n| n.to_string().into_bytes()),
Just(b"99999999999999999999999".to_vec()),
prop::sample::select(vec!["{", "}", "\\", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()),
prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]),
]
}
proptest! {
#![proptest_config(config())]
#[test]
fn shortcut_set_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..600), head in any::<bool>()) {
let mut data = if head { b"/Menus {".to_vec() } else { vec![] };
data.extend(bytes);
shortcut_set(&data)?;
}
#[test]
fn hostile_shortcut_sets_never_panic(tokens in prop::collection::vec(arb_kys_token(), 0..200), sep in prop::sample::select(vec![" ", "", "\t"])) {
let mut data = b"/Menus {".to_vec();
for t in tokens {
data.extend(sep.as_bytes());
data.extend(t);
}
shortcut_set(&data)?;
}
}
/// A shortcut set past any cap (nesting, entries, name length, file size) is read up to it and
/// marked truncated.
#[test]
fn shortcut_sets_past_the_caps_are_truncated() {
let read = |text: String| vectorcraft_engine::Session::new().execute("shortcutSet.read", &json!({ "data": text })).unwrap();
let deep = format!("/Menus {{ {} /K {{ /Key 1 }} }}", "/A { ".repeat(40));
let many = format!("/Menus {{ {} }}", "/E { /Key 1 } ".repeat(20_001));
let long = format!("/Menus {{ /{} {{ /Key 1 }} }}", "n".repeat(2000));
let big = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(4 << 20));
for text in [deep, many, long, big] {
assert_eq!(read(text)["truncated"], true);
}
}
/// 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));
// A layered PSD with transparency (as VectorCraft exports it), and a PackBits PSB by hand.
let mut s = rich_session();
let r = s.execute("document.export", &json!({"format": "psd", "ppi": 2})).unwrap();
out.push(("a.psd", vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()));
let mut psb = b"8BPS\0\x02\0\0\0\0\0\0\0\x04\0\0\0\x02\0\0\0\x03\0\x08\0\x03".to_vec();
psb.extend([0; 8]);
psb.extend(10u64.to_be_bytes());
psb.extend(2u64.to_be_bytes());
psb.extend((-1i16).to_be_bytes());
psb.extend([0, 1]);
psb.extend((0..8).flat_map(|_| 2u32.to_be_bytes()));
psb.extend((0..8u8).flat_map(|v| [0xfe, v * 30]));
out.push(("a.psb", psb));
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..6, cut in 0usize..4000, 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 _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(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 _ = 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 no_panic(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 _ = no_panic(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))?;
}
/// A damaged EPS another app copied: Paste reads it as an error or as objects.
#[test]
fn pasted_eps_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();
paste_from_elsewhere("clipboard.importEps", json!({"dataBase64": mutated_b64(SAMPLE.get_or_init(foreign_eps_sample).clone(), cut, &edits)}))?;
}
/// 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; in a layer, which
/// the editing data reader reads from the program too.
#[test]
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>(), layers in any::<bool>()) {
let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" };
let (open, close) = if layers {
("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--")
} else {
("", "")
};
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{open}{}{close}\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 _ = no_panic(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 _ = no_panic(s.execute("document.pdfInfo", &p));
if no_panic(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();
no_panic(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 _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
let _ = no_panic(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 _ = no_panic(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 = 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))?;
}
}
// ---------- the editing data of Illustrator files ----------
/// `data` as ASCII85, up to the `~>`.
fn ascii85(data: &[u8]) -> String {
let mut out = String::new();
for chunk in data.chunks(4) {
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
let mut d = [0u8; 5];
for c in d.iter_mut().rev() {
*c = (x % 85) as u8 + b'!';
x /= 85;
}
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
}
out.push_str("~>");
out
}
/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end.
const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n";
/// The editing data of a file with every sort of object the layers reader knows, and some it doesn't.
fn editing_text() -> String {
let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4);
let body = [
"u".to_string(),
square(10),
"U".into(),
"*u".into(),
square(20),
square(30),
"*U".into(),
"q".into(),
square(40),
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
"Q".into(),
"1 Xw".into(),
"u".into(),
"/AI11Text :\n0 /FreeUndo ,\n;".into(),
"/AI11Text :\n1 /FreeUndo ,\n;\n1 (Style) XW".into(),
"U".into(),
"0 Xw".into(),
"1 0 0 0 1 0 Bg".into(),
"Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n1 0 0 1 0 0 Xm\n0 0 0 0 0 0 Bm\n1 0 0 1 5 5 Bm\n0 BB".into(),
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
"0 0 0 1 (Black global) 0.4 0 Xk".into(),
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(),
square(60),
]
.join("\n");
let layer = |name: &str, visible: u8, body: &str| {
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
};
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n",
layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))),
layer("Two", 0, &square(80))
)
}
fn zstd(text: &str) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(text.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest)
}
/// An EPS drawing a square whose private data holds `editing`.
fn editing_eps(editing: &str) -> Vec<u8> {
let lines: Vec<String> = ascii85(&zstd(editing)).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto closepath fill\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
lines.join("\n")
)
.into_bytes()
}
/// The text document of a story of centred type with two styles, as an editing copy keeps it.
fn text_document_text() -> String {
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 3 4 ] >> >> >> ] >> >>\n\
/1 << /1 [ << /0 << /0 (Hi\\rthere\\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 2 >> /6 0 >> >> /1 9 >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 9 >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8200.0 8180.0 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
<< /99 /S /0 << /0 [ -5.0 0.0 ] >> /15 << /0 3 >> >> ] >> << /99 /L /0 << /0 [ 0.0 16.8 ] >> /6 [ << /99 /S /0 << /0 [ -9.0 0.0 ] >> /15 << /0 6 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n"
.to_string()
}
/// The text document of area type threaded through two frames and of type on a path, as an
/// editing copy keeps it (frames on the canvas, y down).
fn frames_document_text() -> String {
let area = |x0: f64, x1: f64| {
let (y0, y1) = (8201.5, 8221.5);
let corners = [(x0, y0), (x0, y1), (x1, y1), (x1, y0), (x0, y0)];
let segments: Vec<String> = corners.windows(2).map(|w| format!("{0} {1} {0} {1} {2} {3} {2} {3}", w[0].0, w[0].1, w[1].0, w[1].1)).collect();
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 /7 18 >> >> >>", segments.join(" "))
};
let path = "<< /0 << /0 [ 0 0 ] /1 << /0 [ 8151.5 8171.5 8151.5 8171.5 8191.5 8161.5 8191.5 8171.5 8191.5 8171.5 8211.5 8181.5 8231.5 8171.5 8231.5 8171.5 ] >> /2 << /0 2 /6 [ 0.5 2.0 ] >> >> >>";
let story = |text: &str, frames: &str, style: &str| {
let n = text.chars().count() - text.matches('\\').count();
format!(
"<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 2 /1 4 /2 6 >> /6 0 >> >> /1 {n} >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << {style} >> >> >> /1 {n} >> ] >> >> /1 << /0 [ {frames} ] >> >>"
)
};
format!(
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} {} {path} ] >> >>\n/1 << /1 [ {} {} ] /2 << /1 12.0 >> >>\n",
area(8151.5, 8201.5),
area(8211.5, 8241.5),
story(
"A story in two frames\\rand more\\r",
"<< /0 0 >> << /0 1 >>",
"/1 9.0 /8 50 /53 << /99 /CAITextPaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >>"
),
story("On a path\\r", "<< /0 2 >>", "/1 8.0"),
)
}
/// The text objects of [`frames_document_text`]: story 0 in its two frames, story 1 in its one (a
/// text object names its frame among its story's).
const FRAME_OBJECTS: &str = "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n1 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n0 /FrameIndex ,\n1 /StoryIndex ,\n;\n";
/// The fuzzed fixture, as it is, reads as area type in two threaded frames and type on a path.
#[test]
fn the_frames_fixture_reads_as_threaded_area_type_and_type_on_a_path() {
let l = vectorcraft_engine::cmd::fileio::load("x.eps", &text_eps_of(&frames_document_text(), FRAME_OBJECTS)).unwrap();
let mut kinds = vec![];
l.doc.walk(|n| {
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
kinds.push(match t.kind {
vectorcraft_doc::TextKind::Area { .. } => "area",
vectorcraft_doc::TextKind::OnPath { .. } => "path",
_ => "point",
});
}
});
assert_eq!(kinds, ["area", "area", "path"], "{:?}", l.warnings);
assert_eq!(l.doc.text_threads.len(), 1);
}
/// The smallest failing input `eps_text_frames_never_panic` found: the first frame's bottom edge
/// has a control point at x = 8e115 (`8e0115`). That frame holds no text, and the story flows on
/// into the second frame.
#[test]
fn a_text_frame_with_a_control_point_at_8e115_opens_with_its_story_in_the_next_frame() {
let text = mutate_text(&frames_document_text(), 1397, &[(191, '1'), (1586, 'e')]);
assert!(text.contains(" 8e0115 "));
let l = vectorcraft_engine::cmd::fileio::load("x.eps", &text_eps_of(&text, FRAME_OBJECTS)).unwrap();
let mut texts = vec![];
l.doc.walk(|n| {
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
texts.push(t.plain_text());
}
});
assert_eq!(texts, ["", "A story in two frames\nand more", "On a path"], "{:?}", l.warnings);
survive("a frame with a control point at 8e115 pt", || Some(l.doc)).unwrap();
}
/// An EPS with a hidden text object whose story is in `document`.
fn text_eps(document: &str) -> Vec<u8> {
text_eps_of(document, "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n")
}
/// An EPS with the text `objects` on a hidden layer, their stories in `document`.
fn text_eps_of(document: &str, objects: &str) -> Vec<u8> {
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
let editing = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
%AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n{objects}LB\n%AI5_EndLayer--\n\
%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
lines.join("\n")
);
editing_eps(&editing)
}
proptest! {
#![proptest_config(config())]
/// An Illustrator EPS whose text document is damaged or hostile: read as its type, or without it.
#[test]
fn eps_text_document_never_panics(cut in 0usize..1_500, edits in prop::collection::vec((0usize..1_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1'])), 0..12)) {
let text = mutate_text(&text_document_text(), cut, &edits);
let bytes = text_eps(&text);
survive("mutated EPS text document", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
}
/// The same for area type threaded through frames and type on a path, opened as a document
/// (its threads flow).
#[test]
fn eps_text_frames_never_panic(cut in 0usize..2_500, edits in prop::collection::vec((0usize..2_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1', '2'])), 0..12)) {
let text = mutate_text(&frames_document_text(), cut, &edits);
let bytes = text_eps_of(&text, FRAME_OBJECTS);
survive("mutated EPS text frames", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
}
/// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page.
#[test]
fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) {
let text = mutate_text(&editing_text(), cut, &edits);
let bytes = editing_eps(&text);
survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
}
/// A `.ai` whose editing data is damaged or hostile.
#[test]
fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::<u8>()), 0..4)) {
let text = mutate_text(&editing_text(), cut, &edits);
let mut private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
for (at, b) in damage {
let n = private.len();
if let Some(x) = private.get_mut(at % n) {
*x = b;
}
}
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false).0))?;
}
}
/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named,
/// not included, so its program can't be run and its layers are read from it.
const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\
%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\
u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n";
/// The fixture, as it is, opens with its layer and sublayer.
#[test]
fn the_named_prolog_fixture_opens_with_its_layers() {
let l = vectorcraft_engine::cmd::fileio::load("x.ai", NAMED_PROLOG_AI.as_bytes()).unwrap();
let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect();
assert_eq!(names, [Some("A::B".to_string())], "{:?}", l.warnings);
assert_eq!(l.doc.layers[0].children().unwrap().len(), 3);
}
proptest! {
#![proptest_config(config())]
/// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its
/// layers, or refused.
#[test]
fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) {
let text = mutate_text(NAMED_PROLOG_AI, cut, &edits);
survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?;
}
}
/// The operators, operands and section comments of hostile editing data.
fn arb_ai_token() -> impl Strategy<Value = String> {
prop_oneof![
arb_num(),
prop::sample::select(vec![
"m",
"l",
"L",
"c",
"C",
"v",
"V",
"y",
"Y",
"h",
"H",
"N",
"n",
"F",
"f",
"S",
"s",
"B",
"b",
"W",
"(b) *",
"u",
"U",
"*u",
"*U",
"q",
"Q",
"LB",
"Lb",
"Ln",
"(name) Ln",
"g",
"G",
"k",
"K",
"x",
"X",
"Xa",
"XA",
"Xx",
"XX",
"(Ink) 0.5 x",
"O",
"R",
"XR",
"w",
"J",
"j",
"M",
"d",
"[6 3] 0 d",
"[]0 d",
"Xy",
"2 0.5 1 1 1 Xy",
"99 -4 Xy",
"Xw",
"1 Xw",
"A",
"1 A",
"Ae",
"XW",
"1 (style) XW",
"9 () XW",
"Bd",
"(G) 1 3 Bd",
"Bs",
"0 0 0 0 1 0 0 2 1 6 50 0 Bs",
"BD",
"Bb",
"1 Bb",
"BB",
"2 BB",
"1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg",
"Bg",
"Bm",
"1e308 0 0 1e-308 0 0 Bm",
"Bh",
"XN",
"/DeviceCMYK XN",
"/DeviceGray XN",
"[ 1 0 0 1 0 0 ] 0 0 2 2 2 2 8 3 1 0 1 0",
"[ 1 0 0 1 0 0 ] 0 0 99999 99999 99999 99999 1 1 0 0 0 0",
"[",
"]",
"/Name",
"(text)",
"<ff00>",
":",
";",
",",
"/ArtDictionary :",
"/XMLUID : (A_x41_) ; (AI10_ArtUID) ,",
"(n) /String (AIArtName) ,",
"/Document :",
"/Array :",
"/Dictionary :",
"0 0 /RealPoint (PositionPoint1) ,",
"/AI11Text :",
"0 /StoryIndex ,",
"/SymbolInstance :",
"/Binary : /ASCII85Decode ,",
"~>",
"p",
"P",
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p",
"(Dots) 0 0 1 1 0 0 0 0 0 [0 0 0 0 1e308 0] P",
"Xk",
"XK",
"0 0 0 1 (Global) 0.4 0 Xk",
"1 0.5 0 (Global) 0 1 XK",
"E",
"\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n",
"\n%AI3_EndPattern\n",
"To",
"frobnicate",
"\n%AI5_BeginLayer\n",
"\n%AI5_EndLayer--\n",
"\n%_",
"\n%AI5_BeginRaster\n",
"\n%AI5_EndRaster\n",
"\n%%BeginData: 12\rXI\n",
"\n%%EndData\n",
"\n%AI5_BeginGradient: (G)\n",
"\n%AI14_BeginSymbol\n",
"\n%AI10_EndSymbol\n",
"\n%AI17_Begin_Content_if_version_gt:24 4\n",
"\n%AI17_Alternate_Content\n",
"\n%AI17_End_Versioned_Content\n",
"\n%AI3_Cropmarks: 0 0 1e308 -1e308\n",
"\n%AI5_ArtSize: 0 0\n",
"\n%AI9_ColorModel: 2\n",
"\n%AI5_BeginPlace\n",
"\n%AI26_BeginPlacedObjectPreview\n",
"\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 abcdefgh",
"\n%AI26_BeginPlacedObjectPreview\n4096 4096 1e9 32 17 ",
"\n%AI26_EndPlacedObjectPreview\n",
"[1 0 0 -1 0 0] 0 0 2 1 4 4 0 (a.png) `",
"[1e308 0 0 1e308 0 0] 0 0 -1e308 1e308 4 4 0 (a.png) `",
"`",
"~",
"\n%AI5_EndPlace\n",
"Xm",
"50 -50 -100 -100 5000 -2000 Xm",
"0 0 0 0 1e308 -1e308 Xm",
"Xd",
"0 1 Xd",
"1 0 Xd",
"XD",
"(00800080) Xt 0 XD",
"X!",
"/Mesh X!",
"/End X!",
"X#",
"(DeviceRGB) /CS X#",
"[0 0] /P X#",
"[1 0 0 1 0 0 10 10 12 10 1 8 10 1] /N X#",
"[1e308 -1e308 0] /N X#",
"0 /E X#",
"\n%AI9_BeginArtStyles\n",
"\n%AI9_EndArtStyles\n",
"/KnownStyle :",
"(style) /Name ,",
"/ActiveStyle :",
"/CompoundFilter :",
"/BasicFilter :",
"(Stroke Style Filter) 0 0 /Filter ,",
"(Fill Style Filter) 0 0 /Filter ,",
"(Adobe Outer Glow) 1 0 /Filter ,",
"(Adobe Drop Shadow) 1 0 /Filter ,",
"1 /Visible ,",
"2 /FillOrStroke ,",
"1 /Bool (BSDashAdj) ,",
"0.75 /Real (opac) ,",
"1e308 /Real (blur) ,",
"0 /Real ,",
"; (BSVariableWidthTValuesArray) ,",
"; (BSCenteredWidthsArray) ,",
"/StrokeStyle : 0 R",
"/FillStyle : 0 O",
"/Def ;",
"/Execution ;",
"[5 5] 1e308 d",
"-0.5 /Real ,",
"1.5 /Real ,",
"-2 /Real ,",
"; (BSLeftWidthsArray) ,",
"; (BSRightWidthsArray) ,",
"[0.5 1e308 0 0 0 0 1 0 0 1] /N X#",
"(DeviceGray) /CS X#",
"\n%\n",
"\n%AI5_EndPlace\n",
"\n%%PageTrailer\n",
])
.prop_map(str::to_string),
]
}
/// The testkit's sample editing data, compressed as older `.ai` files compress it.
fn ai_sample_compressed() -> Vec<u8> {
[&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat()
}
/// `data` compressed (zlib).
fn deflated(data: &[u8]) -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
proptest! {
#![proptest_config(config())]
/// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any
/// operands, sections and dictionaries left open, images of any size.
#[test]
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
use vectorcraft_testkit::ai;
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false).0))?;
survive("hostile editing data, type as outlines", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], true, false).0))?;
survive("hostile editing data, trusted", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, true).0))?;
let eps = ai::eps(data.as_bytes(), ai::page_ps());
survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
// An EPS whose page is its art's box, not its artboard: what prints outside it is compared too.
let boxed = ai::eps(data.replace("%AI3_Cropmarks: 0 0 200 100", "%AI3_Cropmarks: -50 -50 300 200").as_bytes(), ai::page_ps());
survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?;
}
/// The testkit's sample damaged, as it is and compressed, in EPS, `.ai` and PDF files.
#[test]
fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
use vectorcraft_testkit::ai;
static PLAIN: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
static PACKED: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
for (what, sample) in [("editing data", PLAIN.get_or_init(ai::sample_data)), ("compressed editing data", PACKED.get_or_init(ai_sample_compressed))] {
let mut bytes = sample.clone();
for &(at, b) in &edits {
let n = bytes.len();
bytes[at % n] = b;
}
bytes.truncate(cut.max(4));
let file = ai::ai(&bytes, ai::page_pdf());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc))?;
let file = ai::pdf(&bytes, ai::page_pdf());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.pdf", &file).ok().map(|l| l.doc))?;
let file = ai::eps(&bytes, ai::page_ps());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?;
}
}
}
// ---------- font files a folder search reads ----------
/// A small font file holding what a folder search reads: an outline table's tag, the `name` and
/// `OS/2` tables of the bundled Source Sans 3 renamed "Findme Sans 3", and an `fvar` table with a
/// weight axis and three named instances (named by name ids 2, 1 and 4).
fn search_font() -> Vec<u8> {
let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3");
let be32 = |at: usize| u32::from_be_bytes(font[at..at + 4].try_into().unwrap()) as usize;
let table = |tag: &[u8; 4]| {
let n = u16::from_be_bytes([font[4], font[5]]) as usize;
let r = (0..n).map(|i| 12 + 16 * i).find(|&r| &font[r..r + 4] == tag).unwrap();
font[be32(r + 8)..be32(r + 8) + be32(r + 12)].to_vec()
};
// fvar 1.0: the axis array at 16, one axis of 20 bytes, three instances of 8.
let mut fvar = vec![];
for v in [1u16, 0, 16, 2, 1, 20, 3, 8] {
fvar.extend(v.to_be_bytes());
}
fvar.extend(b"wght");
for v in [100i32, 400, 900] {
fvar.extend((v << 16).to_be_bytes());
}
fvar.extend([0, 0, 1, 0]);
for (name, weight) in [(2u16, 300i32), (1, 600), (4, 900)] {
fvar.extend(name.to_be_bytes());
fvar.extend(0u16.to_be_bytes());
fvar.extend((weight << 16).to_be_bytes());
}
let tables: [(&[u8; 4], Vec<u8>); 4] = [(b"OS/2", table(b"OS/2")), (b"fvar", fvar), (b"glyf", vec![0; 4]), (b"name", table(b"name"))];
let mut out = 0x0001_0000_u32.to_be_bytes().to_vec();
for v in [tables.len() as u16, 0, 0, 0] {
out.extend(v.to_be_bytes());
}
let mut offset = 12 + 16 * tables.len();
for (tag, data) in &tables {
out.extend(*tag);
for v in [0, offset as u32, data.len() as u32] {
out.extend(v.to_be_bytes());
}
offset += data.len().next_multiple_of(4);
}
for (_, data) in &tables {
out.extend(data);
out.resize(out.len().next_multiple_of(4), 0);
}
out
}
/// `font` as a collection of `faces % 5` faces that all read it, whose header gives `faces` as the
/// count.
fn search_collection(font: &[u8], faces: u32) -> Vec<u8> {
let k = (faces % 5) as usize;
let base = 12 + 4 * k;
let mut out = b"ttcf".to_vec();
out.extend(0x0001_0000_u32.to_be_bytes());
out.extend(faces.to_be_bytes());
for _ in 0..k {
out.extend((base as u32).to_be_bytes());
}
// The tables' offsets count from the start of the collection.
let mut f = font.to_vec();
let n = u16::from_be_bytes([f[4], f[5]]) as usize;
for i in 0..n {
let at = 12 + 16 * i + 8;
let offset = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + base as u32;
f[at..at + 4].copy_from_slice(&offset.to_be_bytes());
}
out.extend(f);
out
}
/// The fonts a search for "Findme Sans 3" looks for: the face, and a style of the family as if the
/// family were installed.
fn findme_wanted() -> vectorcraft_text::WantedFonts {
use vectorcraft_text::WantedFont;
vectorcraft_text::WantedFonts::new(&[
WantedFont { family: "Findme Sans 3".into(), style: "Regular".into(), installed: None },
WantedFont { family: "FINDME SANS 3".into(), style: "Black".into(), installed: Some("Findme Sans 3".into()) },
WantedFont { family: "SourceSans3-Regular".into(), style: "Bold".into(), installed: None },
])
}
/// Whether reading `bytes` as a font file a search reads panics.
fn search_reads(what: &str, bytes: &[u8]) -> Result<Vec<usize>, TestCaseError> {
let path = vectorcraft_testkit::temp_dir("font-search-fuzz").join(format!("{:?}.ttc", std::thread::current().id()));
std::fs::write(&path, bytes).unwrap();
catch_quiet(|| findme_wanted().provided_by(&path)).map_err(|e| TestCaseError::fail(format!("{what}: a search reading the file panicked: {e}")))
}
/// The samples the fuzz properties mutate reach the matching: each provides the face it names.
#[test]
fn the_search_font_samples_are_read_whole() {
let font = search_font();
assert_eq!(search_reads("font", &font).unwrap(), [0, 2]);
assert_eq!(search_reads("collection", &search_collection(&font, 3)).unwrap(), [0, 2]);
// Two faces, and a header that gives seven.
assert_eq!(search_reads("collection claiming more faces", &search_collection(&font, 7)).unwrap(), [0, 2]);
}
proptest! {
#![proptest_config(config())]
#[test]
fn font_files_a_search_reads_never_panic(
faces in prop::option::of(any::<u32>()),
cut in prop::option::of(0usize..12_000),
edits in prop::collection::vec((0usize..12_000, any::<u8>()), 0..12),
) {
let font = search_font();
let mut bytes = match faces {
Some(n) => search_collection(&font, n),
None => font,
};
for (at, b) in edits {
let n = bytes.len();
bytes[at % n] = b;
}
if let Some(cut) = cut {
bytes.truncate(cut);
}
search_reads("mutated font file", &bytes)?;
}
}
// ---------- scripts checked without running them ----------
/// A script written for these tests, with invented names: directives in both forms, member chains,
/// files, a dialog, menu commands, E4X, an operator and a message to another app.
const SCRIPT_SAMPLE: &str = concat!(
"#target acme_2-12-en_US\n",
"//@include \"lib/util.jsxinc\"\n",
"#includepath \"lib;shared\"\n",
"#targetengine session\n",
"var doc = app.activeDocument;\n",
"var w = new Window(\"dialog { text: 'Tidy' }\");\n",
"var f = File.saveDialog('Save'); f.open('w'); f.write(doc.acmeName); f.close();\n",
"app.executeMenuCommand('acmecopyall');\n",
"var x = <root><item id=\"1\">{doc.acmeName}</item></root>;\n",
"for each (var i in x..item) { $.writeln(i.@id); }\n",
"Vec.prototype['+'] = function (o) { return o; };\n",
"var bt = new BridgeTalk(); bt.target = 'acme'; bt.send();\n",
"if (item.typename == 'AcmeShape') { alert(/a[/]b/g.test(s) ? 'yes' : \"no\"); }\n",
);
/// The pieces generated scripts are made of.
const SCRIPT_PIECES: &[&str] = &[
"#target acme",
"//@include 'a.jsxinc'",
"#includepath 'x;..'",
"#pragma",
"app",
"activeDocument",
"selection",
"var doc = ",
".",
".acmeItems",
"(",
")",
"[",
"]",
"{",
"}",
";",
"=",
"'str'",
"\"open",
"/re[/]x/g",
"/",
"<a b=\"{v}\">",
"</a>",
"<!--",
".@",
"..",
"`t",
"=>",
"\n",
"\r",
"\u{2028}",
"@JSXBIN@",
"executeMenuCommand",
"new File(p)",
"Window(",
"import",
"/*",
"\\",
"0x1F",
" ",
];
/// `script.check` with `p`, which mustn't panic, and whose lists keep to their limit.
fn checked(what: &str, p: Value) -> Result<(), TestCaseError> {
let r = catch_quiet(|| no_panic(vectorcraft_engine::Session::new().execute("script.check", &p)))
.map_err(|e| TestCaseError::fail(format!("{what}: script.check panicked: {e}")))?;
if let Ok(r) = r {
for list in [&r["targets"], &r["directives"], &r["includes"], &r["problems"], &r["dom"]["members"], &r["menuCommands"]["strings"]] {
prop_assert!(list.as_array().is_some_and(|l| l.len() <= 1000), "{}: {}", what, list);
}
}
Ok(())
}
/// The sample the properties mutate reaches every part of the check.
#[test]
fn the_script_sample_is_checked_whole() {
let r = vectorcraft_engine::Session::new().execute("script.check", &json!({"data": SCRIPT_SAMPLE})).unwrap();
assert_eq!(r["kind"], "script");
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands", "dialogs", "hostOnly"]));
assert_eq!(r["problems"], json!([]));
}
proptest! {
#![proptest_config(config())]
#[test]
fn script_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..2_000), compiled in any::<bool>()) {
let name = if compiled { "garbage.jsxbin" } else { "garbage.jsx" };
checked("script garbage", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": name}))?;
}
#[test]
fn generated_scripts_never_panic(pieces in prop::collection::vec(prop::sample::select(SCRIPT_PIECES.to_vec()), 0..120)) {
checked("generated script", json!({"data": pieces.concat()}))?;
}
#[test]
fn mutated_scripts_never_panic(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, any::<u8>()), 0..12)) {
let mut bytes = SCRIPT_SAMPLE.as_bytes().to_vec();
for (at, b) in edits {
let n = bytes.len();
bytes[at % n] = b;
}
bytes.truncate(cut);
checked("mutated script", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "sample.jsx"}))?;
}
}
// ---------- deep nesting ----------
//
// The tree is walked by recursion, so a file nested deeply enough overflowed the stack: an abort,
// which nothing catches. Every reader caps what it reads, and no document is deeper than
// `MAX_DEPTH`. These run on a thread with the stack of a Windows main thread (1 MB), where an
// overflow ends the test run.
use vectorcraft_doc::MAX_DEPTH;
/// `f` on a thread with a 1 MB stack.
fn on_a_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap();
}
/// Open `bytes` as the file `name`: an error, or a document within the cap that renders and
/// exports. Returns the document and the import's warnings.
fn open_deep(name: &str, bytes: &[u8]) -> Option<(Document, Vec<String>)> {
let loaded = vectorcraft_engine::cmd::fileio::load(name, bytes).ok()?;
assert!(!loaded.doc.too_deep(None), "{name}: deeper than the cap");
let doc = loaded.doc.clone();
survive(name, move || Some(doc)).unwrap();
Some((loaded.doc, loaded.warnings))
}
fn merged(warnings: &[String]) -> bool {
warnings.iter().any(|w| w.contains("nested more than"))
}
fn paths_in(doc: &Document) -> usize {
let mut paths = 0;
doc.walk(|n| paths += usize::from(n.path_data().is_some()));
paths
}
const SVG_NS: &str = "xmlns=\"http://www.w3.org/2000/svg\"";
/// `n` groups in one another, each named (so it is kept) and with a rectangle beside the next.
fn nested_svg(n: usize) -> String {
let open: String = (0..n).map(|i| format!("<g id=\"g{i}\"><rect width=\"5\" height=\"5\"/>")).collect();
format!("<svg {SVG_NS} width=\"100\" height=\"100\">{open}<rect width=\"10\" height=\"10\"/>{}</svg>", "</g>".repeat(n))
}
#[test]
fn deeply_nested_svg_is_refused_or_merged() {
on_a_small_stack(|| {
// Within what the reader takes: opened, the groups past the cap merged into the last.
let (doc, warnings) = open_deep("deep.svg", nested_svg(40).as_bytes()).expect("40 groups open");
assert!(merged(&warnings), "{warnings:?}");
assert_eq!(paths_in(&doc), 41, "no art is lost");
let (_, warnings) = open_deep("shallow.svg", nested_svg(20).as_bytes()).expect("20 groups open");
assert!(!merged(&warnings), "{warnings:?}");
// As deep as the reader goes (on a stack of its own): opened and merged the same.
let most = vectorcraft_svg::MAX_ELEMENT_DEPTH;
let (doc, warnings) = open_deep("deepest.svg", nested_svg(most - 2).as_bytes()).expect("opens at the reader's cap");
assert!(merged(&warnings), "{warnings:?}");
assert_eq!(paths_in(&doc), most - 1, "no art is lost");
// Past it: an error that says why, whatever the depth.
for n in [most, 2 * most, 200_000] {
let e = vectorcraft_engine::cmd::fileio::load("deep.svg", nested_svg(n).as_bytes()).err().expect("refused").to_string();
assert!(e.contains(&format!("nested more than {most} deep")), "{n}: {e}");
}
// Unnamed groups of one object aren't kept, but usvg walks them all the same.
let plain = format!(
"<svg {SVG_NS} width=\"9\" height=\"9\">{}<rect width=\"1\" height=\"1\"/>{}</svg>",
"<g>".repeat(2 * most),
"</g>".repeat(2 * most)
);
assert!(open_deep("plain.svg", plain.as_bytes()).is_none());
// A chain of `<use>`: shallow XML, a deep tree.
let links: String = (0..3000).map(|i| format!("<g id=\"u{i}\"><use href=\"#u{}\"/></g>", i + 1)).collect();
let chain = format!(
"<svg {SVG_NS} width=\"9\" height=\"9\"><defs>{links}<rect id=\"u3000\" width=\"1\" height=\"1\"/></defs><use href=\"#u0\"/></svg>"
);
assert!(open_deep("chain.svg", chain.as_bytes()).is_none());
// Placed and pasted, as opened.
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
for n in [40, 2 * most] {
let p = json!({"name": "deep.svg", "dataBase64": vectorcraft_format::base64_encode(nested_svg(n).as_bytes())});
let _ = no_panic(s.execute("file.place", &p));
let _ = no_panic(s.execute("clipboard.importSvg", &json!({"svg": nested_svg(n)})));
let _ = no_panic(s.execute("edit.paste", &json!({})));
assert!(!s.doc().unwrap().doc.too_deep(None));
}
});
}
/// A page whose content is `content`, with `resources`.
fn deep_pdf(content: &str, resources: &str) -> Vec<u8> {
handmade_pdf(content, resources, "[0 0 100 100]")
}
/// A stream object with the entries `dict`, its `data` compressed.
fn packed(dict: &str, data: &str) -> Vec<u8> {
packed_as_is(dict, &deflated(data.as_bytes()))
}
/// A stream object with the entries `dict`, its data `z` compressed already.
fn packed_as_is(dict: &str, z: &[u8]) -> Vec<u8> {
// (A length the test gives stands.)
let length = if dict.contains("/Length") { String::new() } else { format!(" /Length {}", z.len()) };
[format!("<< {dict} /Filter /FlateDecode{length} >>\nstream\n").as_bytes(), z, b"\nendstream"].concat()
}
/// A page whose content is `content`, with `resources` and the objects `more` (numbered from 5).
fn pdf_with(content: &str, resources: &str, more: &[Vec<u8>]) -> Vec<u8> {
handmade_pdf_with(content, resources, "[0 0 100 100]", more)
}
/// Arrays in arrays, and dictionaries in dictionaries, far deeper than any reader goes: as the
/// operands of content.
fn nested_operands() -> [String; 2] {
let (n, d) = (100_000, 50_000);
[format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(n), "]".repeat(n)), format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(d), ">> ".repeat(d))]
}
/// A page drawing through the compressed stream that `container` makes of each of
/// [`nested_operands`] (object 5), one file each.
fn nested_in(content: &str, resources: &str, container: impl Fn(&str) -> Vec<Vec<u8>>) -> Vec<Vec<u8>> {
nested_operands().iter().map(|deep| pdf_with(content, resources, &container(deep))).collect()
}
const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]";
/// A Type 3 font (object 5) whose glyph `a` is object 6.
fn type3_font(resources: &str) -> Vec<u8> {
format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 6 0 R >> \
/Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources {resources} >>"
)
.into_bytes()
}
/// A page whose box is object 6, kept with object 7 (a number) in a compressed object stream
/// (object 5, with the entries `stream`): a file with a cross-reference stream, as it must be.
fn pdf_with_object_stream(boxed: &str, stream: &str) -> Vec<u8> {
let content = "0 0 5 5 re f";
let inside = format!("{boxed} 77");
let index = format!("6 0 7 {} ", boxed.len() + 1);
let objs = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox 6 0 R /Contents 4 0 R /Resources << >> >>".to_vec(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes(),
packed(&format!("/Type /ObjStm /N 2 /First {} {stream}", index.len()), &format!("{index}{inside}")),
];
let mut out = b"%PDF-1.7\n".to_vec();
// One entry an object: its kind, then where it is (an offset, or the stream it is in and its
// place there).
let mut entries = vec![[0u8, 0, 0, 0, 0, 255, 255]];
let at = |kind: u8, a: usize, b: u16| {
let (a, b) = ((a as u32).to_be_bytes(), b.to_be_bytes());
[kind, a[0], a[1], a[2], a[3], b[0], b[1]]
};
for (i, o) in objs.iter().enumerate() {
entries.push(at(1, out.len(), 0));
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
entries.extend([at(2, 5, 0), at(2, 5, 1), at(1, out.len(), 0)]);
let xref = out.len();
out.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len() * 7).as_bytes());
out.extend_from_slice(&entries.concat());
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
/// `n` objects from object 5 on, each made by `link` of the number of the next, then `last`.
fn chain(n: usize, link: impl Fn(usize) -> String, last: &str) -> Vec<Vec<u8>> {
(0..n).map(|i| link(i + 6).into_bytes()).chain([last.as_bytes().to_vec()]).collect()
}
/// A page painting with the shading whose function is object 5.
fn pdf_with_function(more: &[Vec<u8>]) -> Vec<u8> {
let shading = "/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 5 0 R >> >>";
pdf_with("/S sh", &format!("<< {shading} >>"), more)
}
/// A page filling in the colour space that is object 5.
fn pdf_with_color_space(more: &[Vec<u8>]) -> Vec<u8> {
pdf_with("/C cs 0 sc 0 0 5 5 re f /C CS 0 SC 0 0 5 5 re S", "<< /ColorSpace << /C 5 0 R >> >>", more)
}
/// Every hostile PDF here opens as an error or as a document within the cap, on a small stack.
fn open_each(name: &str, files: impl IntoIterator<Item = Vec<u8>> + Send + 'static) {
let name = name.to_string();
on_a_small_stack(move || {
for (i, bytes) in files.into_iter().enumerate() {
open_deep(&format!("{name}-{i}.pdf"), &bytes);
// As `.ai` (the PDF part of one) and placed, too.
open_deep(&format!("{name}-{i}.ai"), &bytes);
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let _ = no_panic(s.execute("file.place", &json!({"name": "deep.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})));
assert!(!s.doc().unwrap().doc.too_deep(None));
}
});
}
// Nesting where only the reader of the decoded data sees it: inside each kind of compressed
// stream a page draws through.
#[test]
fn pdf_nesting_inside_a_compressed_form() {
open_each("form", nested_in("/F Do", "<< /XObject << /F 5 0 R >> >>", |deep| vec![packed(FORM, deep)]));
}
#[test]
fn pdf_nesting_inside_a_compressed_pattern() {
let tile = "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>";
open_each("pattern", nested_in("/Pattern cs /P scn 0 0 50 50 re f", "<< /Pattern << /P 5 0 R >> >>", |deep| vec![packed(tile, deep)]));
}
#[test]
fn pdf_nesting_inside_a_compressed_glyph() {
let glyph = |deep: &str| vec![type3_font("<< >>"), packed("", &format!("10 0 d0 {deep}"))];
open_each("glyph", nested_in("BT /F1 12 Tf 10 10 Td (aa) Tj ET", "<< /Font << /F1 5 0 R >> >>", glyph));
}
#[test]
fn pdf_nesting_inside_a_compressed_soft_mask() {
let group = format!("{FORM} /Group << /S /Transparency /CS /DeviceGray >>");
let state = "<< /ExtGState << /G << /Type /ExtGState /SMask << /S /Luminosity /G 5 0 R >> >> >> >>";
open_each("mask", nested_in("/G gs 0 0 50 50 re f", state, move |deep| vec![packed(&group, deep)]));
}
#[test]
fn pdf_nesting_inside_a_compressed_annotation() {
let annots = "<< >> /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /AP << /N 5 0 R >> >>]";
open_each("annotation", nested_in("0 0 5 5 re f", annots, |deep| vec![packed(FORM, deep)]));
}
#[test]
fn pdf_nesting_inside_compressed_page_content() {
let files = nested_operands().map(|deep| {
let mut objs: Vec<Vec<u8>> = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"].map(Vec::from).into();
objs.push(b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 4 0 R] /Resources << >> >>".to_vec());
objs.push(packed("", &deep));
pdf_of(&objs)
});
open_each("content", files);
}
#[test]
fn pdf_nesting_inside_an_object_stream() {
let (n, d) = (100_000, 50_000);
open_each(
"objects",
[
// A plain one first: the file is read as it should be.
pdf_with_object_stream("[0 0 100 100]", ""),
pdf_with_object_stream(&format!("{}0 0 100 100{}", "[".repeat(n), "]".repeat(n)), ""),
pdf_with_object_stream(&format!("{}{}", "<< /A ".repeat(d), ">> ".repeat(d)), ""),
// Its length is an object kept in the stream itself.
pdf_with_object_stream("[0 0 100 100]", "/Length 7 0 R"),
// It extends itself.
pdf_with_object_stream("[0 0 100 100]", "/Extends 5 0 R"),
],
);
let plain = vectorcraft_engine::cmd::fileio::load("plain.pdf", &pdf_with_object_stream("[0 0 120 80]", "")).expect("an object stream is read");
assert_eq!(plain.doc.artboards[0].rect.width(), 120.0);
}
// What refers to itself, or to the next of its kind without end.
#[test]
fn pdf_page_trees_that_hold_themselves() {
let page = |parent: usize| format!("<< /Type /Page /Parent {parent} 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << >> >>");
let content = "<< /Length 13 >>\nstream\n0 0 5 5 re f\nendstream";
let own = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [2 0 R 3 0 R 2 0 R] /Count 1 >>".to_string(),
page(2),
content.to_string(),
];
// Thousands of nodes, each holding the next; the page is the last.
let n = 30_000;
let mut deep = vec!["<< /Type /Catalog /Pages 5 0 R >>".to_string(), "null".to_string(), "null".to_string(), content.to_string()];
deep.extend((0..n).map(|i| format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", i + 6)));
deep.push(page(n + 4));
// Two nodes holding each other.
let pair = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [5 0 R] /Count 1 >>".to_string(),
page(5),
content.to_string(),
"<< /Type /Pages /Kids [2 0 R 3 0 R] /Count 1 >>".to_string(),
];
let bytes = |objs: &[String]| pdf_of(&objs.iter().map(|o| o.clone().into_bytes()).collect::<Vec<_>>());
open_each("pages", [bytes(&own), bytes(&deep), bytes(&pair)]);
}
#[test]
fn pdf_functions_that_hold_themselves() {
let stitch = |next: usize| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>");
let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>";
let calculator = "/FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1]";
let n = 100_000;
open_each(
"functions",
[
pdf_with_function(&[stitch(5).into_bytes()]),
pdf_with_function(&[stitch(6).into_bytes(), stitch(5).into_bytes()]),
pdf_with_function(&chain(20_000, stitch, ramp)),
// A calculator function of procedures in procedures.
pdf_with_function(&[packed(calculator, &format!("{}{}", "{".repeat(n), "}".repeat(n)))]),
pdf_with_function(&[packed(calculator, &format!("{{ {} 0 0 0 {} }}", "true {".repeat(n), "} if ".repeat(n)))]),
],
);
}
#[test]
fn pdf_color_spaces_that_hold_themselves() {
let indexed = |next: usize| format!("[/Indexed {next} 0 R 1 <000000ffffff>]");
let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 0] /N 1 >>";
let profile = |alternate: &str| format!("<< /N 3 /Alternate {alternate} /Length 4 >>\nstream\njunk\nendstream").into_bytes();
open_each(
"spaces",
[
pdf_with_color_space(&[indexed(5).into_bytes()]),
pdf_with_color_space(&[indexed(6).into_bytes(), indexed(5).into_bytes()]),
pdf_with_color_space(&chain(20_000, indexed, "/DeviceRGB")),
pdf_with_color_space(&[b"[/Separation /Ink 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]),
pdf_with_color_space(&[b"[/DeviceN [/Ink] 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]),
pdf_with_color_space(&[b"[/Pattern 5 0 R]".to_vec()]),
pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("5 0 R")]),
pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("[/ICCBased 6 0 R]")]),
// As the space of a shading and of an image.
pdf_with(
"/S sh",
"<< /Shading << /S << /ShadingType 2 /ColorSpace 5 0 R /Coords [0 0 9 0] /Function 6 0 R >> >> >>",
&[indexed(5).into_bytes(), tint.as_bytes().to_vec()],
),
pdf_with(
"q 50 0 0 50 0 0 cm /I Do Q",
"<< /XObject << /I 6 0 R >> >>",
&[
indexed(5).into_bytes(),
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace 5 0 R /Length 1 >>\nstream\n0\nendstream"
.to_vec(),
],
),
],
);
}
/// Spaces past what a stream may decode to (256 MB), compressed (about 260 kB).
fn spaces_past_the_cap() -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
for _ in 0..257 {
e.write_all(&[b' '; 1 << 20]).unwrap();
}
e.finish().unwrap()
}
// Streams the reader would fail on (#1700, #1701): it applies a predictor before checking its
// parameters (`Columns 0` divides by zero, a huge `Columns` allocates a row that large), and
// inflates without a ceiling.
#[test]
fn pdf_streams_the_reader_would_fail_on_are_refused() {
let zero = "/DecodeParms << /Predictor 2 /Columns 0 >>";
let huge = "/DecodeParms << /Predictor 12 /Colors 4 /Columns 99999999999 >>";
// An inline image whose data is deflated, written in hexadecimal so the content stays text.
let inline = |dp: &str| {
let data: String = deflated(&[0; 8]).iter().map(|b| format!("{b:02x}")).collect();
format!("q 10 0 0 10 0 0 cm BI /W 2 /H 2 /CS /G /BPC 8 /F [/AHx /Fl] /DP [null {dp}] ID {data}> EI Q")
};
let form = |dict: &str, content: &str| pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed(&format!("{FORM} {dict}"), content)]);
let files = [
("a form with Columns 0", form(zero, "0 0 5 5 re f")),
("a form with a huge Columns", form(huge, "0 0 5 5 re f")),
("an inline image with Columns 0", deep_pdf(&inline("<< /Predictor 2 /Columns 0 >>"), "<< >>")),
("an inline image with a huge Columns", deep_pdf(&inline("<< /Predictor 15 /Columns 99999999999 >>"), "<< >>")),
("an inline image in a compressed form", form("", &inline("<< /Predictor 2 /Columns 0 >>"))),
("an object stream with Columns 0", pdf_with_object_stream("[0 0 100 100]", zero)),
("a form of spaces past the cap", pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed_as_is(FORM, &spaces_past_the_cap())])),
];
on_a_small_stack(move || {
for (what, bytes) in &files {
for name in ["hostile.pdf", "hostile.ai"] {
let e = vectorcraft_engine::cmd::fileio::load(name, bytes).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("would decode to more than 256 MB")), "{what} as {name}: {e:?}");
}
}
});
// Within range, the same files open.
let fine = "/DecodeParms << /Predictor 1 /Columns 0 >>";
for bytes in [form(fine, "0 0 5 5 re f"), deep_pdf(&inline("<< /Predictor 1 >>"), "<< >>")] {
vectorcraft_engine::cmd::fileio::load("fine.pdf", &bytes).expect("opens");
}
}
#[test]
fn pdf_art_that_draws_itself() {
let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>";
let tile = format!("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources {own}");
let group = format!("{FORM} /Group << /S /Transparency >> /Resources {own}");
open_each(
"itself",
[
// A form drawing itself twice, a pattern painting its own tile, a glyph showing
// itself, and a mask masked by itself.
pdf_with("/F Do", own, &[packed(&format!("{FORM} /Resources {own}"), "0 0 5 5 re f /F Do 1 0 0 1 3 3 cm /F Do")]),
pdf_with("/Pattern cs /P scn 0 0 50 50 re f", own, &[packed(&tile, "/Pattern cs /P scn 0 0 5 5 re f")]),
pdf_with("BT /F1 12 Tf (a) Tj ET", own, &[type3_font(own), packed("", "10 0 d0 0 0 5 5 re f BT /F1 1 Tf (a) Tj ET")]),
pdf_with("/G gs 0 0 50 50 re f", own, &[packed(&group, "/G gs 0 0 5 5 re f")]),
// An image that is its own mask, and objects that are only a reference to each other.
pdf_with("q 50 0 0 50 0 0 cm /F Do Q", own, &[
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 5 0 R /Length 1 >>\nstream\n0\nendstream".to_vec(),
]),
handmade_pdf_with("0 0 5 5 re f", "5 0 R", "6 0 R", &[b"6 0 R".to_vec(), b"5 0 R".to_vec()]),
handmade_pdf_with("0 0 5 5 re f", "<< >>", "[0 0 9 9]", &[b"<< /Length 5 0 R >>\nstream\nx\nendstream".to_vec()]),
],
);
}
/// An EPS whose program is `body`.
fn deep_eps(body: &str) -> Vec<u8> {
format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n").into_bytes()
}
/// A test for each file: it opens as an error or as a document within the cap, on a small stack.
macro_rules! deep_files {
($($test:ident: $name:literal, $bytes:expr;)*) => {$(
#[test]
fn $test() {
on_a_small_stack(|| {
open_deep($name, &$bytes);
});
}
)*};
}
deep_files! {
// PDF: clips inside clips (saved states never restored); arrays in arrays as an operand and as
// an object; dictionaries in dictionaries as a resource and inline; marked content never ended.
pdf_clips_in_clips: "clips.pdf", deep_pdf(&format!("{} 0 0 5 5 re f", "q 0 0 50 50 re W n ".repeat(20_000)), "<< >>");
pdf_arrays_in_an_operand: "arrays.pdf", deep_pdf(&format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(100_000), "]".repeat(100_000)), "<< >>");
pdf_arrays_in_an_object: "boxes.pdf", handmade_pdf("0 0 5 5 re f", "<< >>", &format!("{}0 0 100 100{}", "[".repeat(100_000), "]".repeat(100_000)));
pdf_dictionaries_in_resources: "dicts.pdf", deep_pdf("0 0 5 5 re f", &format!("{}{}", "<< /A ".repeat(50_000), ">> ".repeat(50_000)));
pdf_dictionaries_in_content: "inline.pdf", deep_pdf(&format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(50_000), ">> ".repeat(50_000)), "<< >>");
pdf_marked_content_never_ended: "marked.pdf", deep_pdf(&format!("{} 0 0 5 5 re f", "/Span << /MCID 0 >> BDC ".repeat(50_000)), "<< >>");
// PostScript: clips inside clips; procedures in procedures; arrays in arrays; a procedure that
// calls itself; dictionaries never ended.
eps_clips_in_clips: "clips.eps", deep_eps(&format!("{} 0 0 5 5 rectfill", "gsave 0 0 50 50 rectclip ".repeat(20_000)));
eps_procedures_in_procedures: "procs.eps", deep_eps(&format!("{}{} 0 0 5 5 rectfill", "{".repeat(100_000), "}".repeat(100_000)));
eps_arrays_in_arrays: "arrays.eps", deep_eps(&format!("{}{} pop 0 0 5 5 rectfill", "[".repeat(100_000), "]".repeat(100_000)));
eps_a_procedure_calling_itself: "runaway.eps", deep_eps("/f { 0 0 5 5 rectfill f } def f");
eps_dictionaries_never_ended: "dicts.eps", deep_eps(&format!("{} 0 0 5 5 rectfill", "1 dict begin ".repeat(100_000)));
}
#[test]
fn deeply_nested_dxf_blocks_are_read_within_the_cap() {
on_a_small_stack(|| {
// Block `B0` inserts `B1`, which inserts `B2`, and so on; the last one holds a line.
let pair = |c: i32, v: &str| (c, v.to_string());
let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")];
for i in 0..2_000 {
pairs.extend([pair(0, "BLOCK"), pair(2, &format!("B{i}")), pair(10, "0"), pair(20, "0")]);
if i + 1 < 2_000 {
pairs.extend([pair(0, "INSERT"), pair(2, &format!("B{}", i + 1)), pair(10, "1"), pair(20, "1")]);
} else {
pairs.extend([pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")]);
}
pairs.push(pair(0, "ENDBLK"));
}
pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES"), pair(0, "INSERT"), pair(2, "B0"), pair(10, "0"), pair(20, "0")]);
pairs.extend([pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "9"), pair(21, "9"), pair(0, "ENDSEC"), pair(0, "EOF")]);
open_deep("blocks.dxf", dxf_text(&pairs).as_bytes()).expect("opens");
});
}
#[test]
fn dxf_blocks_inserting_themselves_are_left_out() {
on_a_small_stack(|| {
let pair = |c: i32, v: &str| (c, v.to_string());
// A line, then an insert of `inserts`.
let block = |name: &str, inserts: &str| {
let line = [pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")];
let insert = [pair(0, "INSERT"), pair(2, inserts), pair(10, "1"), pair(20, "1"), pair(0, "ENDBLK")];
[pair(0, "BLOCK"), pair(2, name), pair(10, "0"), pair(20, "0")].into_iter().chain(line).chain(insert).collect::<Vec<_>>()
};
let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")];
pairs.extend([block("SELF", "SELF"), block("A", "B"), block("B", "A")].concat());
pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES")]);
for name in ["SELF", "A"] {
pairs.extend([pair(0, "INSERT"), pair(2, name), pair(10, "0"), pair(20, "0")]);
}
pairs.extend([pair(0, "ENDSEC"), pair(0, "EOF")]);
let (_, warnings) = open_deep("self.dxf", dxf_text(&pairs).as_bytes()).expect("opens");
assert!(warnings.iter().any(|w| w.contains("inside themselves")), "{warnings:?}");
});
}
#[test]
fn deeply_nested_affinity_groups_are_read_within_the_cap() {
use vectorcraft_affinity::synth::{self, F, Method, tag};
// Groups in groups: within what the reader takes, past its cap on layers (128), past its cap
// on the stream's own nesting (384), and far past both.
let files: Vec<Vec<u8>> = [20u32, 120, 180, 370, 2000]
.into_iter()
.map(|levels| {
let group = |id: u32, fields| F::Def(id, vec![tag(b"Grup")], fields);
let inner = (0..levels).fold(group(10, vec![]), |inner, i| group(11 + i, vec![(tag(b"Chld"), F::Shared(vec![inner]))]));
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![inner]))]);
let stream = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
affinity_native(&stream, Method::Stored)
})
.collect();
on_a_small_stack(move || {
for bytes in &files {
open_deep("deep.afdesign", bytes);
}
});
}
#[test]
fn metafiles_saving_states_without_end_are_read_within_the_cap() {
on_a_small_stack(|| {
// A saved state, a clip and a rectangle, over and over (nothing is ever restored).
let longs = |v: [i32; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>();
let emf: Vec<(u32, Vec<u8>)> = (0..50_000).flat_map(|_| [(33, vec![]), (30, longs([0, 0, 500, 500])), (43, longs([0, 0, 50, 50]))]).collect();
open_deep("deep.emf", &emf_of(&emf));
let shorts = |v: [i16; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>();
let wmf: Vec<(u16, Vec<u8>)> =
(0..20_000).flat_map(|_| [(0x001E, vec![]), (0x0416, shorts([500, 500, 0, 0])), (0x041B, shorts([50, 50, 0, 0]))]).collect();
open_deep("deep.wmf", &wmf_of(&wmf));
});
}
#[test]
fn deeply_nested_plugin_manifests_and_output_are_errors() {
on_a_small_stack(|| {
// (A manifest may be 64 kB.)
let deep = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n));
assert!(vectorcraft_plugins::Manifest::parse(deep(30_000).as_bytes()).is_err());
assert!(vectorcraft_plugins::Manifest::parse(format!("{{\"params\": {}}}", deep(30_000)).as_bytes()).is_err());
let deep = deep(100_000);
for output in [deep.clone(), format!("{{\"objects\": {deep}}}"), format!("{{\"objects\": [{{\"kind\": \"path\", \"path\": {deep}}}]}}")] {
assert!(vectorcraft_plugins::objects::decode(output.as_bytes()).is_err());
}
});
}
/// A document whose symbol `s0` and pattern `p0` are each a line stroked with the brush `loop`,
/// with an instance of the symbol and a square painted with the pattern on the layer. The brush
/// is the `kind`th of those that hold art (art, scatter, pattern), and its art is the `art`th of:
/// an instance of the symbol; that instance inside as many groups as a file holds; a square
/// painted with the pattern. So what the brush draws is drawn with the brush.
fn brushed_document(kind: usize, art: usize) -> Document {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, PatternBrush, Scatter};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, PatternDef, Symbol};
let mut doc = nested_document(1);
let square = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 60.0, 20.0));
let brushed = |doc: &mut Document| {
let mut look = Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0)), 4.0);
for item in &mut look.items {
if let AppearanceItem::Stroke(s) = item {
s.brush = Some("loop".into());
}
}
Node::path(doc.alloc_id(), square(), look)
};
let instance =
|doc: &mut Document| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let painted = |doc: &mut Document| {
let fill = Paint::Pattern { pattern: "p0".into(), xf: vectorcraft_geom::Affine::IDENTITY };
Node::path(doc.alloc_id(), square(), Appearance::basic(fill, Paint::None, 0.0))
};
let (in_symbol, in_pattern) = (brushed(&mut doc), brushed(&mut doc));
doc.symbols.push(Symbol { name: "s0".into(), art: std::sync::Arc::new(in_symbol) });
doc.patterns.push(PatternDef::new("p0", vec![std::sync::Arc::new(in_pattern)]));
let art = match art {
0 => instance(&mut doc),
1 => (0..MAX_DEPTH + 4).fold(instance(&mut doc), |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)])),
_ => painted(&mut doc),
};
let kind = match kind {
0 => BrushKind::Art(ArtBrush { art, ..Default::default() }),
1 => BrushKind::Scatter(Scatter { art, ..Default::default() }),
_ => BrushKind::Pattern(PatternBrush { side: art.clone(), start: Some(art), ..Default::default() }),
};
vectorcraft_brush::store(&mut doc, &[Brush { name: "loop".into(), kind }]);
let layer = doc.layers[0].id;
for on_layer in [instance(&mut doc), painted(&mut doc), brushed(&mut doc)] {
doc.insert(Some(layer), usize::MAX, on_layer).unwrap();
}
doc
}
/// `doc` saved, opened again, drawn, exported and edited: nothing overflows.
fn art_by_name_survives(what: &str, doc: Document) {
let (back, _) = open_deep("named.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens");
let mut s = vectorcraft_engine::Session::new();
s.add_document(back, None);
for (cmd, p) in [
("select.all", json!({})),
("document.inspect", json!({})),
("brush.expand", json!({})),
("edit.undo", json!({})),
("object.expand", json!({})),
("edit.undo", json!({})),
("object.outlineStroke", json!({})),
("edit.undo", json!({})),
("graph.design", json!({"paste": "deep"})),
("graph.create", json!({"type": "line", "x": 10, "y": 10, "width": 80, "height": 60})),
("graph.marker", json!({"design": "deep"})),
("graph.design", json!({"new": "again"})),
("edit.copy", json!({})),
("edit.paste", json!({})),
] {
let _ = no_panic(s.execute(cmd, &p));
assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}");
}
for format in ["svg", "pdf", "eps", "png", "dxf", "emf"] {
let _ = no_panic(s.execute("document.export", &json!({"format": format})));
}
}
/// A test for each brush whose art is drawn with the brush itself ([`brushed_document`]).
macro_rules! brushes_drawn_with_themselves {
($($test:ident: $kind:literal, $art:literal;)*) => {$(
#[test]
fn $test() {
on_a_small_stack(|| art_by_name_survives(stringify!($test), brushed_document($kind, $art)));
}
)*};
}
brushes_drawn_with_themselves! {
an_art_brush_of_its_own_symbol: 0, 0;
an_art_brush_of_its_own_symbol_in_deep_art: 0, 1;
an_art_brush_painted_with_its_own_pattern: 0, 2;
a_scatter_brush_of_its_own_symbol: 1, 0;
a_scatter_brush_of_its_own_symbol_in_deep_art: 1, 1;
a_scatter_brush_painted_with_its_own_pattern: 1, 2;
a_pattern_brush_of_its_own_symbol: 2, 0;
a_pattern_brush_of_its_own_symbol_in_deep_art: 2, 1;
a_pattern_brush_painted_with_its_own_pattern: 2, 2;
}
#[test]
fn a_graph_design_of_deep_art_and_of_a_symbol_holding_itself_is_drawn() {
use vectorcraft_doc::{GraphDesign, Node, NodeKind};
on_a_small_stack(|| {
let mut doc = symbols_document(1, 1, "s0");
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let art = (0..MAX_DEPTH + 4).fold(instance, |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)]));
doc.graph_designs.push(GraphDesign { name: "deep".into(), art: std::sync::Arc::new(art) });
art_by_name_survives("a graph design of deep art", doc);
});
}
/// A layer holding `levels` groups in one another around a rectangle, each with a second
/// rectangle beside the next: `levels + 2` deep.
fn nested_document(levels: usize) -> Document {
use vectorcraft_doc::Node;
use vectorcraft_testkit::fixtures::DocBuilder;
let mut b = DocBuilder::new(100.0, 100.0);
let rect =
|b: &mut DocBuilder, x: f64| b.rect_node(vectorcraft_geom::Rect::new(x, 0.0, x + 10.0, 10.0), vectorcraft_color::Color::rgb8(200, 0, 0));
let mut inner = rect(&mut b, 0.0);
for _ in 0..levels {
let beside = rect(&mut b, 20.0);
inner = Node::group(b.alloc(), vec![std::sync::Arc::new(inner), std::sync::Arc::new(beside)]);
inner.opacity = 0.9;
}
b.add(inner);
b.build()
}
#[test]
fn a_document_at_the_cap_saves_opens_and_exports() {
on_a_small_stack(|| {
let doc = nested_document(MAX_DEPTH - 2);
assert!(!doc.too_deep(None));
let back = vectorcraft_format::load_file(&vectorcraft_format::save_file(&doc)).expect("what can be made opens again");
assert!(back.depth_notes.is_empty());
assert_eq!(back.doc.layers, doc.layers);
survive("at the cap", || Some(doc.clone())).unwrap();
// Every format that keeps the nesting reads its own file back within the cap.
let mut s = vectorcraft_engine::Session::new();
s.add_document(doc, None);
for format in ["svg", "pdf", "eps", "ai"] {
let r = s.execute("document.export", &json!({"format": format})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
open_deep(&format!("cap.{format}"), &bytes).unwrap_or_else(|| panic!("{format} opens"));
}
for cmd in ["select.all", "document.inspect", "document.info", "edit.copy", "edit.paste", "object.ungroup", "edit.undo"] {
no_panic(s.execute(cmd, &json!({}))).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
});
}
#[test]
fn a_native_file_nested_too_deep_is_merged_or_refused() {
on_a_small_stack(|| {
// Saved before the cap by a version that could still open it: opened, the deepest merged.
let old = vectorcraft_format::save_file(&nested_document(MAX_DEPTH + 2));
let (doc, warnings) = open_deep("old.vectorcraft", &old).expect("opens");
assert!(merged(&warnings), "{warnings:?}");
assert_eq!(paths_in(&doc), MAX_DEPTH + 3, "no art is lost");
// Deeper than any version could open, and a hostile file: an error that says why. Written
// by hand: a tree this deep can't be built and saved.
let text = String::from_utf8(vectorcraft_format::save(&nested_document(0), false)).unwrap();
let (open, close) = ("{\"id\":900000,\"kind\":{\"type\":\"group\",\"children\":[", "]}}");
let at = text.find("\"children\":[").expect("the layer's children") + "\"children\":[".len();
for levels in [100, 50_000] {
let deep = format!("{}{}{},{}", &text[..at], open.repeat(levels), close.repeat(levels), &text[at..]);
let e = vectorcraft_engine::cmd::fileio::load("deep.vectorcraft", deep.as_bytes()).err().expect("refused").to_string();
assert!(e.contains("nested too deep"), "{levels}: {e}");
}
});
}
#[test]
fn commands_cannot_nest_deeper_than_the_cap() {
on_a_small_stack(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let mut grouped = 0;
for _ in 0..10_000 {
match no_panic(s.execute("object.group", &json!({}))) {
Ok(_) => grouped += 1,
Err(e) => assert!(e.to_string().contains(vectorcraft_engine::TOO_DEEP), "after {grouped} groups: {e}"),
}
}
// The layer, the groups and the rectangle.
assert_eq!(grouped, MAX_DEPTH - 2);
assert!(!s.doc().unwrap().doc.too_deep(None));
// Other ways down: a clipping mask, a copy pasted, a symbol made of it.
for (cmd, p) in [
("object.clippingMask.make", json!({})),
("edit.copy", json!({})),
("edit.pasteInFront", json!({})),
("symbol.new", json!({"name": "deep"})),
] {
let _ = no_panic(s.execute(cmd, &p));
assert!(!s.doc().unwrap().doc.too_deep(None), "{cmd}");
}
survive("grouped to the cap", || Some((*s.doc().unwrap().doc).clone())).unwrap();
});
}
/// A document whose one object is an instance of the first of `count` symbols: each symbol's art
/// is `levels` groups around an instance of the next symbol, the last one of `last`.
fn symbols_document(count: usize, levels: usize, last: &str) -> Document {
use vectorcraft_doc::{Node, NodeKind, Symbol};
let instance =
|doc: &mut Document, of: String| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: of, xf: vectorcraft_geom::Affine::IDENTITY });
let mut doc = nested_document(1);
for i in 0..count {
let mut art = instance(&mut doc, if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() });
for _ in 0..levels {
art = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(art)]);
}
doc.symbols.push(Symbol { name: format!("s{i}"), art: std::sync::Arc::new(art) });
}
let first = instance(&mut doc, "s0".into());
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, first).unwrap();
doc
}
/// `doc` saved (as an older version, or a hostile file, could have) and opened again: within the
/// caps (`kept`: as it was), drawn, exported and edited.
fn symbols_survive(what: &str, doc: Document, kept: bool) {
let (back, warnings) = open_deep("symbols.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens");
assert_eq!(!warnings.iter().any(|w| w.contains("symbol instances")), kept, "{what}: {warnings:?}");
let mut s = vectorcraft_engine::Session::new();
s.add_document(back, None);
for cmd in [
"select.all",
"document.inspect",
"document.info",
"object.expand",
"symbol.breakLink",
"object.flattenTransparency",
"edit.copy",
"edit.paste",
] {
let _ = no_panic(s.execute(cmd, &json!({})));
assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}");
}
for format in ["svg", "pdf", "eps", "png"] {
let _ = no_panic(s.execute("document.export", &json!({"format": format})));
}
}
#[test]
fn a_symbol_holding_itself_opens_without_the_instance() {
on_a_small_stack(|| {
symbols_survive("a symbol holding itself", symbols_document(1, 1, "s0"), false);
symbols_survive("symbols holding one another", symbols_document(3, 2, "s0"), false);
});
}
#[test]
fn symbols_in_symbols_are_drawn_only_so_deep() {
on_a_small_stack(|| {
// As deep as symbols draw: each one's art as deep as art goes.
symbols_survive("symbols at the cap", symbols_document(3, MAX_DEPTH - 2, "none"), true);
symbols_survive("symbols in symbols", symbols_document(40, MAX_DEPTH - 1, "none"), false);
// A symbol can't be made to hold itself, or one that holds it, by editing either.
let mut s = vectorcraft_engine::Session::new();
s.add_document(symbols_document(2, 1, "none"), None);
s.execute("select.all", &json!({})).unwrap();
for (cmd, p) in [
("symbol.new", json!({"name": "s1"})),
("symbol.update", json!({"name": "s1"})),
("symbol.update", json!({"name": "s0"})),
("symbol.replace", json!({"name": "s1"})),
] {
let _ = no_panic(s.execute(cmd, &p));
assert!(!s.doc().unwrap().doc.too_deep(None), "{cmd} {p}");
}
});
}
#[test]
fn a_pattern_painting_its_own_tile_opens_without_the_paint() {
on_a_small_stack(|| {
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, PatternDef};
let painted = |doc: &mut Document, with: &str| {
let fill = Paint::Pattern { pattern: with.into(), xf: vectorcraft_geom::Affine::IDENTITY };
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 20.0, 20.0));
vectorcraft_doc::Node::path(doc.alloc_id(), square, Appearance::basic(fill, Paint::None, 0.0))
};
// One pattern painting its own tile, and two painting each other's.
for names in [["p", "p"], ["p", "q"]] {
let mut doc = nested_document(1);
let (first, second) = (painted(&mut doc, names[1]), painted(&mut doc, names[0]));
doc.patterns.push(PatternDef::new(names[0], vec![std::sync::Arc::new(first)]));
if names[0] != names[1] {
doc.patterns.push(PatternDef::new(names[1], vec![std::sync::Arc::new(second)]));
}
let on_layer = painted(&mut doc, "p");
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, on_layer).unwrap();
symbols_survive("a pattern painting its own tile", doc, false);
}
});
}