Files
vectorcraft/crates/engine/tests/text_import_fuzz.rs
dotCooCoo 1c4ead3703 Place: Word .docx files place as area type with their formatting
File > Place could not place a Word document: a .docx file was "not a
format VectorCraft reads". The reference app's File > Place reads
Microsoft Word files as text.

File > Place now reads .docx files, written from ECMA-376 Part 1
(WordprocessingML, Transitional and Strict), and sets their text as
area type the way a text or RTF file is placed. The package is read
through its relationships. The formatting comes from the document
defaults, the paragraph style (else the default paragraph style), the
character style (else the default character style), the styles each
is based on, and direct formatting. Bold, italic, caps, small caps,
strikethrough and hidden text are toggle properties: a style that
turns one on reverses the state the styles before it give.

Each run keeps its font (theme fonts from the theme part), size, bold
and italic, underline, strikethrough, all caps, small caps,
superscript or subscript (in Document Setup's proportions), raised or
lowered text and color. Each paragraph keeps its alignment, start,
end, first-line and hanging indents, space before and after, and
direction; the start indent of a right-to-left paragraph is its right
indent. Table cells are separated by tabs and each table row is a
paragraph. Text in insertions, hyperlinks, content controls and
fields' results is read. Hidden and deleted text, field instructions,
pictures, text boxes, headers, footers, notes and list numbers are
left out.

A ZIP archive named .docx, or one that holds word/document.xml, is
read as a Word document. fileio/zip.rs gains a reader for stored and
deflated files that checks each file's CRC. A package with more than
10,000 files, a part that unpacks to more than 16 MiB or nests
elements more than 256 deep, and a document with more than 4 MiB of
text or 100,000 paragraphs or runs are refused with an error. The
Place dialog lists Word files, and file.place, file.place.info and
file.place.queue report the format `docx`. .doc files are still not
read.

The Word document tests in tests_textimport.rs fail without the
change, and text_import_fuzz.rs places random and damaged packages.

Docs: docs/file-format-parity.md, docs/mcp.md,
docs/target-app-parity.md.
2026-10-18 01:21:31 +07:00

206 lines
10 KiB
Rust
Raw Permalink 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 text files placed with File → Place never crash: any bytes, with any Text Import
//! Options, place as area type or fail with an error, and so do hostile RTF files and Word
//! documents.
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_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::json;
use vectorcraft_testkit::catch_quiet;
proptest! {
#![proptest_config(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })]
#[test]
fn any_bytes_place_as_text_without_panics(
bytes in prop::collection::vec(prop_oneof![any::<u8>(), Just(b'\r'), Just(b'\n'), Just(b' ')], 0..400),
bom in prop::sample::select(vec![&b""[..], b"\xEF\xBB\xBF", b"\xFF\xFE", b"\xFE\xFF"]),
ansi in any::<bool>(),
mac in any::<bool>(),
lines in any::<bool>(),
paras in any::<bool>(),
spaces in prop::option::of(0u64..8),
) {
// Exercise import/options/thumbnails with bundled fonts, independently of host font collections.
// Font lookup and fallback have dedicated tests in vectorcraft-text.
vectorcraft_text::FontDb::global().set_system_fallback(false);
let data = [bom, &bytes[..]].concat();
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let text = json!({
"characterSet": if ansi { "ansi" } else { "unicode" },
"platform": if mac { "mac" } else { "windows" },
"removeLineReturns": lines,
"removeParagraphReturns": paras,
"replaceSpaces": spaces,
});
let p = json!({"name": "fuzz.txt", "dataBase64": vectorcraft_format::base64_encode(&data), "text": text, "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
#[test]
fn any_rtf_places_without_panics(
tokens in prop::collection::vec(arb_rtf_token(), 0..200),
name in prop::sample::select(vec!["fuzz.rtf", "fuzz.doc"]),
) {
vectorcraft_text::FontDb::global().set_system_fallback(false);
let data = [&b"{\\rtf1"[..], &tokens.concat()].concat();
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": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
#[test]
fn any_docx_places_without_panics(
body in prop_oneof![1 => prop::collection::vec(arb_docx_token(), 0..120), 3 => prop::collection::vec(arb_docx_paragraph(), 0..12)],
styles in prop_oneof![1 => prop::collection::vec(arb_docx_token(), 0..40), 3 => prop::collection::vec(arb_docx_style(), 0..6)],
changes in prop_oneof![
3 => Just(vec![]),
1 => prop::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..3),
],
name in prop::sample::select(vec!["fuzz.docx", "fuzz.bin"]),
) {
vectorcraft_text::FontDb::global().set_system_fallback(false);
let w = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
let document = format!("<w:document {w}><w:body>{}</w:body></w:document>", body.concat());
let styles = format!("<w:styles {w}>{}</w:styles>", styles.concat());
let files = [("word/document.xml", document.as_bytes()), ("word/styles.xml", styles.as_bytes())];
let mut data = vectorcraft_engine::cmd::fileio::zip::store(&files).unwrap();
// A changed byte anywhere: in a header, a size, the directory or the XML.
for (at, byte) in changes {
let i = at.index(data.len());
data[i] = byte;
}
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": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
}
/// A well-formed paragraph, or a table row of two such paragraphs.
fn arb_docx_paragraph() -> impl Strategy<Value = String> {
prop_oneof![
3 => arb_docx_para(),
1 => (arb_docx_para(), arb_docx_para()).prop_map(|(a, b)| format!("<w:tbl><w:tr><w:tc>{a}</w:tc><w:tc>{b}</w:tc></w:tr></w:tbl>")),
]
}
/// A well-formed paragraph: properties and runs of empty elements and text.
fn arb_docx_para() -> impl Strategy<Value = String> {
let leaves = || prop::collection::vec(arb_docx_leaf(), 0..4);
(leaves(), prop::collection::vec((leaves(), arb_docx_leaf()), 0..6)).prop_map(|(ppr, runs)| {
let runs: String = runs.iter().map(|(rpr, t)| format!("<w:r><w:rPr>{}</w:rPr><w:t>{t}</w:t></w:r>", rpr.concat())).collect();
format!("<w:p><w:pPr>{}</w:pPr>{runs}</w:p>", ppr.concat())
})
}
/// Elements the Word document reader knows that hold others, opened or closed by
/// [`arb_docx_token`].
const DOCX_TAGS: &str = "p r t pPr rPr tbl tr tc sdt sdtContent hyperlink del ins style docDefaults rPrDefault pPrDefault";
/// Empty elements the Word document reader knows.
const DOCX_EMPTY: &str = "b i caps smallCaps strike dstrike vanish tab ptab br cr noBreakHyphen softHyphen bidi p";
/// Well-formed document defaults, or a paragraph or character style `S` or `C` (based on `S` or
/// not), with properties of empty elements.
fn arb_docx_style() -> impl Strategy<Value = String> {
let leaves = || prop::collection::vec(arb_docx_leaf(), 0..4).prop_map(|l| l.concat());
let kind = prop::sample::select(vec![("paragraph", "S"), ("character", "C"), ("paragraph", "Normal\" w:default=\"1")]);
prop_oneof![
(leaves(), leaves()).prop_map(|(r, p)| format!(
"<w:docDefaults><w:rPrDefault><w:rPr>{r}</w:rPr></w:rPrDefault><w:pPrDefault><w:pPr>{p}</w:pPr></w:pPrDefault></w:docDefaults>"
)),
(kind, any::<bool>(), leaves(), leaves()).prop_map(|((t, id), based, r, p)| {
let based = if based { "<w:basedOn w:val=\"S\"/>" } else { "" };
format!("<w:style w:type=\"{t}\" w:styleId=\"{id}\">{based}<w:rPr>{r}</w:rPr><w:pPr>{p}</w:pPr></w:style>")
}),
]
}
/// Pieces of WordprocessingML the Word document reader knows (paragraphs, runs, properties,
/// tables, fields, styles), a cut-off tag, entities and text.
fn arb_docx_token() -> impl Strategy<Value = String> {
let tags = prop::sample::select(DOCX_TAGS.split_whitespace().collect::<Vec<_>>());
let open = (tags, any::<bool>()).prop_map(|(t, end)| format!("<{}w:{t}>", if end { "/" } else { "" }));
let styles = prop::sample::select(vec!["<w:style w:type=\"paragraph\" w:styleId=\"S\">", "<w:style w:type=\"character\" w:styleId=\"C\">"]);
prop_oneof![open, styles.prop_map(str::to_string), arb_docx_leaf(), Just("<w:t".to_string())]
}
/// An empty WordprocessingML element the reader knows, with or without a value in or out of
/// range, entities or text.
fn arb_docx_leaf() -> impl Strategy<Value = String> {
let empty = prop::sample::select(DOCX_EMPTY.split_whitespace().collect::<Vec<_>>());
let valued = prop::sample::select(vec![
("pStyle", "val", "S"),
("rStyle", "val", "C"),
("basedOn", "val", "S"),
("sz", "val", "-7"),
("sz", "val", "1e400"),
("sz", "val", "12pt"),
("position", "val", "99999999999"),
("color", "val", "FF00"),
("color", "val", "auto"),
("u", "val", "none"),
("vertAlign", "val", "superscript"),
("jc", "val", "distribute"),
("jc", "val", "end"),
("ind", "hanging", "-360"),
("ind", "left", "3in"),
("spacing", "after", "x"),
("fldChar", "fldCharType", "begin"),
("fldChar", "fldCharType", "separate"),
("fldChar", "fldCharType", "end"),
("rFonts", "asciiTheme", "majorHAnsi"),
("rFonts", "ascii", ""),
("br", "type", "page"),
("b", "val", "0"),
]);
prop_oneof![
empty.prop_map(|e| format!("<w:{e}/>")),
valued.prop_map(|(e, a, v)| format!("<w:{e} w:{a}=\"{v}\"/>")),
Just("&amp;&#x1F600;&lt;".to_string()),
"[a-z ]{0,6}",
]
}
/// Control words the RTF reader knows, and one it doesn't.
const RTF_WORDS: &str = "par line pard plain b i ul ulnone fs f cf u uc qc qj li fi sb sa rtlpar cell row tab fonttbl colortbl red blue \
pict bin deff super sub up dn scaps caps ansicpg mac stylesheet upr ud zzunknown";
/// A piece of an RTF file: a brace, a control word the reader knows or doesn't (with a parameter
/// in or out of range, or none), a control symbol, text or raw bytes.
fn arb_rtf_token() -> impl Strategy<Value = Vec<u8>> {
let words = prop::sample::select(RTF_WORDS.split_whitespace().collect::<Vec<_>>());
let param = prop::option::of(prop_oneof![-70_000i64..70_000, Just(i64::from(i32::MAX)), Just(-99_999_999_999)]);
let word = |(w, p, space): (&str, Option<i64>, bool)| {
let p = p.map(|v| v.to_string()).unwrap_or_default();
format!("\\{w}{p}{}", if space { " " } else { "" }).into_bytes()
};
prop_oneof![
Just(b"{".to_vec()),
Just(b"}".to_vec()),
Just(b"\\*".to_vec()),
Just(b";".to_vec()),
(words, param, any::<bool>()).prop_map(word),
prop::sample::select(vec!["\\'e9", "\\'9", "\\~", "\\-", "\\_", "\\\\", "\\{", "\\}", "\\\r\n", "\\|"]).prop_map(|s| s.as_bytes().to_vec()),
"[a-z ]{0,6}".prop_map(String::into_bytes),
prop::collection::vec(any::<u8>(), 0..4),
]
}