AF.5: bound hostile Affinity files; fuzz the new import paths

- One budget for the whole read (layers, pixels, characters, table
  cells), shared with embedded documents and charged also when their
  reading fails; the first reading is dropped before the second.
- Tables: at most 65 536 cells; lines and cell formats that don't
  match the grid are left out instead of shifted.
- Paragraph formats matched to runs by binary search; font names
  longer than 256 bytes are dropped.
- Overlays: one work budget per import; encoded originals decoded
  within their layer's size; table cell outlines recoloured too.
- Engine: master page copies capped, each page's master found in
  linear time, line gaps looked up once per font, the working CMYK
  table built once, objects past MAX_COORD left out.
- Embedded content stays with its node; pixel masks under artistic
  text are read whole and include stroke reach; gradient overlays on
  hidden layers are kept.
- Render: contexts and raster effect images stay within
  MAX_RASTER_SIDE per side (vello overflowed near u16::MAX; found by
  the new fuzz target).
- Tests: affinity tests/import_limits.rs, feature samples in
  import_fuzz.rs, shared builders in engine/tests/support/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dwetscher
2026-10-22 18:24:57 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent fd50bd6f84
commit f73a3df974
16 changed files with 1330 additions and 524 deletions
+14 -380
View File
@@ -4,257 +4,28 @@
//! Affinity import tests, whose reader VectorCraft shares.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[path = "support/affinity_docs.rs"]
mod affinity_docs;
#[path = "../../affinity/tests/support/text_fixture.rs"]
mod text_fixture;
use std::sync::Arc;
use affinity_docs::*;
use kurbo::Rect;
use text_fixture::{array_f64, array_i32, attrs, block, run, shared_objects, utf8};
use vectorcraft_affinity::synth::{self, F, Method, tag};
use text_fixture::{array_f64, array_i32, attrs, block, run, utf8};
use vectorcraft_affinity::synth::{F, tag};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, StrokeAlign};
use vectorcraft_doc::text::{TextKind, TextObject, VerticalAlign};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_engine::cmd::fileio::{Loaded, load};
type Field = (vectorcraft_affinity::stream::Tag, F);
/// Encode a stream whose shared arrays may list inline objects.
fn stream(mut root: Vec<Field>) -> Vec<u8> {
let mut next_id = 1000;
for (_, value) in &mut root {
shared_objects(value, &mut next_id);
}
synth::stream(&root)
}
/// 300 dpi: a document pixel is 0.24 pt.
const DPI: f64 = 300.0;
const PT: f64 = 72.0 / DPI;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn rgba(r: f32, g: f32, b: f32) -> F {
F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([r, g, b, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))])
}
fn cmyk(c: f32, m: f32, y: f32, k: f32) -> F {
F::Obj(
tag(b"FilS"),
vec![(tag(b"Colr"), F::Obj(tag(b"CMYK"), vec![(tag(b"_col"), F::Struct([c, m, y, k, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]))],
)
}
/// A magenta rectangle named `name`.
fn named_rect(id: u32, name: &str, at: (f64, f64), size: (f64, f64)) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
(tag(b"BFil"), cmyk(0.0, 1.0, 0.0, 0.0)),
(tag(b"Desc"), F::Str(name.into())),
],
)
}
fn rectangle(id: u32, at: (f64, f64), size: (f64, f64)) -> F {
named_rect(id, "Magenta box", at, size)
}
/// An artboard (`ABEn`) named `name` over `bounds` at `at`, holding `children`.
fn board(id: u32, name: &str, at: (f64, f64), size: (f64, f64), children: Vec<F>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
(tag(b"Desc"), F::Str(name.into())),
(tag(b"ABEn"), F::Bool(true)),
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
(tag(b"Chld"), F::Shared(children)),
],
)
}
fn artistic_text(id: u32, text: &str, size: f64) -> F {
F::Def(
id,
vec![tag(b"TxtA")],
vec![
(
tag(b"StSt"),
F::Obj(
tag(b"Stry"),
vec![(
tag(b"Blok"),
F::Shared(vec![block(
utf8(text),
vec![run(text.chars().count() as i32, Some(attrs("Inter", "Inter-Bold", 700, false, size)))],
&[1],
)]),
)],
),
),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 1000.0, 0.0, 1.0, 500.0])),
],
)
}
fn page(x0: f64, w: f64, h: f64) -> F {
F::Obj(tag(b"PagR"), vec![(tag(b"rctp"), F::F64s(vec![x0, 0.0, x0 + w, h]))])
}
/// A Publisher spread of A4 pages at 300 dpi holding `nodes`.
fn spread(id: u32, pages: usize, nodes: Vec<F>) -> F {
let w = 2480.0 * pages as f64;
F::Def(
id,
vec![tag(b"Sprd")],
vec![
(tag(b"SprB"), F::F64s(vec![0.0, 0.0, w, 3508.0])),
(
tag(b"SpMd"),
F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(2480.0 * i as f64, 2480.0, 3508.0)).collect()))]),
),
(tag(b"Chld"), F::Shared(nodes)),
],
)
}
fn publisher_with(spreads: Vec<F>) -> Vec<u8> {
let data = stream(vec![
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(DPI))])),
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(spreads))])),
]);
synth::container(&[("doc.dat", &data, Method::Zstd)], None)
}
/// A two-page spread: a layer with a CMYK rectangle, and centred artistic text.
fn publisher() -> Vec<u8> {
let layer = F::Def(
3,
vec![tag(b"Scop")],
vec![(tag(b"Desc"), F::Str("Background".into())), (tag(b"Chld"), F::Shared(vec![rectangle(10, (100.0, 100.0), (100.0, 200.0))]))],
);
publisher_with(vec![spread(2, 2, vec![layer, artistic_text(20, "Hello\u{2029}World\u{2029}", 100.0)])])
}
/// One A4 page at 300 dpi holding `node`.
fn page_with(node: F) -> Vec<u8> {
publisher_with(vec![spread(2, 1, vec![node])])
}
/// A document at 72 dpi holding `nodes` on a canvas without pages; `embedded` is `edc/1`.
fn canvas_with(nodes: Vec<F>, embedded: Option<&[u8]>) -> Vec<u8> {
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 1000.0, 1000.0])), (tag(b"Chld"), F::Shared(nodes))]);
let data = stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
match embedded {
Some(inner) => {
let mut entry = b"EmDc\0\0\0\0".to_vec();
entry.extend_from_slice(inner);
synth::container(&[("doc.dat", &data, Method::Zstd), ("edc/1", &entry, Method::Zstd)], None)
}
None => synth::container(&[("doc.dat", &data, Method::Zstd)], None),
}
}
/// An instance of the embedded document `edc/1`, scaled 2× with its content's centre at (500, 300).
fn embedded_node(id: u32) -> F {
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
F::Def(
id,
vec![tag(b"EmbN")],
vec![
(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content), (tag(b"PBBx"), F::Enum(2, 0))])),
(tag(b"Xfrm"), F::F64s(vec![2.0, 0.0, 500.0, 0.0, 2.0, 300.0])),
],
)
}
/// A blue 2 px line on a table edge.
fn table_edge() -> F {
F::Obj(
tag(b"TEdg"),
vec![
(tag(b"Line"), F::Obj(tag(b"LDsc"), vec![(tag(b"LDeL"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))])),
(tag(b"Fill"), F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), cmyk(1.0, 0.5, 0.0, 0.0))])),
],
)
}
/// A 3 × 2 table whose cell (1, 1) is merged into (1, 0); cells "A".."E" in reading order, each
/// ended by a cell break. Cells are 100 × 50 px from (0, 0).
fn table_node(id: u32, transform: [f64; 6]) -> F {
table_with(id, transform, ["A", "B", "C", "D", "E"])
}
/// [`table_node`] with these cell texts.
fn table_with(id: u32, transform: [f64; 6], texts: [&str; 5]) -> F {
let brk = || F::Obj(tag(b"BrGl"), vec![(tag(b"HdBk"), F::Enum(4, 1))]);
let segments: Vec<F> = texts
.iter()
.enumerate()
.map(|(i, t)| {
let mut fields = vec![(tag(b"Utf8"), F::Str((*t).into()))];
if i > 0 {
fields.insert(0, (tag(b"Glys"), F::Shared(vec![brk()])));
}
F::Obj(tag(b"GSeg"), fields)
})
.collect();
let len = (texts.iter().map(|t| t.chars().count()).sum::<usize>() + texts.len() - 1) as i32;
let glyphs = F::Obj(tag(b"GStr"), vec![(tag(b"Mixd"), F::Shared(segments))]);
let story = block(glyphs, vec![run(len, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))], &[]);
let cell = |merged: bool| {
let mut f = vec![(tag(b"AliY"), F::Enum(1, 0)), (tag(b"Inse"), F::F64s(vec![5.0, 6.0, 7.0, 8.0]))];
if merged {
f.push((tag(b"BrLf"), F::Bool(true)));
}
F::Obj(tag(b"TCel"), f)
};
let pos = |values: &[f64]| F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", values))]);
let edges = |n: usize| F::Obj(tag(b"TEds"), vec![(tag(b"Edge"), F::Shared((0..n).map(|_| table_edge()).collect()))]);
let table = F::Obj(
tag(b"Tabl"),
vec![
(tag(b"CPos"), pos(&[0.0, 100.0, 200.0, 300.0])),
(tag(b"RPos"), pos(&[0.0, 50.0, 100.0])),
(tag(b"CEdg"), edges(8)),
(tag(b"REdg"), edges(9)),
(tag(b"Cell"), F::Obj(tag(b"TCls"), vec![(tag(b"Cell"), F::Shared((0..6).map(|i| cell(i == 4)).collect()))])),
],
);
F::Def(
id,
vec![tag(b"TxtT")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])),
(tag(b"TxtH"), F::Obj(tag(b"TbFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 300.0, 100.0])), (tag(b"Tabl"), table)])),
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
],
)
}
/// Character attributes without their own fixed leading.
fn auto_leading(mut attrs: F) -> F {
if let F::Obj(_, fields) = &mut attrs {
for (t, v) in fields.iter_mut() {
if *t == tag(b"Ints") {
*v = array_i32(b"Ints", &[0, 0, 0, 0, 0]);
}
}
}
attrs
}
fn open(name: &str, bytes: &[u8]) -> Loaded {
let loaded = load(name, bytes).unwrap();
assert!(!loaded.preview_only, "{:?}", loaded.warnings);
@@ -603,48 +374,6 @@ fn embedded_documents_nest_only_so_deep() {
assert!(loaded.warnings.iter().any(|w| w.contains("nested more than four deep")), "{:?}", loaded.warnings);
}
/// A two-page master spread (pages 0–100 and 100–200 wide) with a box on each page; the
/// document's page shows its second page, as Affinity's instance says (MPOf 1).
fn with_master(pages: usize) -> Vec<u8> {
let master = F::Def(
7,
vec![tag(b"Sprd")],
vec![
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared(vec![page(0.0, 100.0, 150.0), page(100.0, 100.0, 150.0)]))])),
(
tag(b"Chld"),
F::Shared(vec![
named_rect(30, "Left master box", (10.0, 10.0), (20.0, 20.0)),
named_rect(31, "Right master box", (110.0, 10.0), (30.0, 40.0)),
]),
),
],
);
let instance = F::Def(
5,
vec![tag(b"MPIN")],
vec![
(tag(b"SLnk"), F::Obj(tag(b"ILSN"), vec![(tag(b"ILOb"), F::Shared(vec![master, F::Ref(5)]))])),
(tag(b"PgOf"), F::U32(0)),
(tag(b"MPOf"), F::U32(1)),
(tag(b"PgCt"), F::U32(pages as u32)),
],
);
let spread = F::Def(
2,
vec![tag(b"Sprd")],
vec![
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(100.0 * i as f64, 100.0, 150.0)).collect()))])),
(tag(b"Chld"), F::Shared(vec![instance, named_rect(40, "Page box", (50.0, 100.0), (10.0, 10.0))])),
],
);
let data = stream(vec![(
tag(b"DocR"),
F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread])), (tag(b"MpCh"), F::Shared(vec![F::Ref(7)]))]),
)]);
synth::container(&[("doc.dat", &data, Method::Zstd)], None)
}
#[test]
fn master_pages_show_on_the_pages_that_use_them() {
let loaded = open("master.afpub", &with_master(1));
@@ -680,13 +409,6 @@ fn pixel(d: &Document, x: usize, y: usize) -> [u8; 4] {
img.to_straight()[(y * img.width as usize + x) * 4..][..4].try_into().unwrap()
}
fn with_effects(mut node: F, effects: Vec<F>) -> F {
if let F::Def(_, _, fields) = &mut node {
fields.push((tag(b"FiEf"), F::Shared(effects)));
}
node
}
#[test]
fn shadows_go_where_affinity_puts_them() {
// A blue shadow 10 px straight below (angle π/2), blurred 2 px, at half opacity.
@@ -809,37 +531,6 @@ fn outline_effects_on_stroked_shapes_go_around_their_stroke() {
);
}
/// Artistic text "Wide line" over "Mid", the paragraphs aligned `first` and `second`
/// (0 left, 1 centre, 2 right).
fn mixed_alignment(first: i32, second: i32) -> F {
let para = |end: i32, align: i32| {
vec![(tag(b"Indx"), F::I32(end)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[align]))]))]
};
let blk = F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), utf8("Wide line\u{2029}Mid\u{2029}")),
(
tag(b"GAtt"),
F::Obj(
tag(b"GlAS"),
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))]))],
),
),
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(10, first), para(14, second)]))])),
],
);
F::Def(
50,
vec![tag(b"TxtA")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 100.0])),
],
)
}
#[test]
fn artistic_text_aligns_its_lines_within_the_longest() {
// (first, second alignment) → where "Mid" goes in the span of "Wide line" (0 its left end,
@@ -867,19 +558,6 @@ fn artistic_text_aligns_its_lines_within_the_longest() {
assert!(texts(&d)[0].paras.iter().all(|p| p.left_indent == 0.0 && p.right_indent == 0.0));
}
/// Frame text of two 100 px lines in a frame `height` tall.
fn two_line_frame(height: f64) -> F {
let blk = block(utf8("One\u{2029}Two\u{2029}"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 100.0))))], &[0]);
F::Def(
60,
vec![tag(b"TxtF")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, height]))])),
],
)
}
#[test]
fn frame_text_hangs_from_cap_height_and_shows_lines_whose_baseline_fits() {
use vectorcraft_doc::text::FirstBaseline;
@@ -949,30 +627,6 @@ fn underlined_and_struck_through_characters_stay_so() {
);
}
/// A 16 × 16 CMYK pixel layer of pure cyan, opaque or (`transparent`) with no alpha, in a
/// document that embeds `profile` as its CMYK profile.
fn cmyk_layer(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> {
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(4, 0)), (tag(b"BmpW"), F::I32(16)), (tag(b"BmpH"), F::I32(16))];
for c in 1..=5 {
// Tile state 2: every sample 255; 0: every sample 0.
let full = c == 1 || (c == 5 && !transparent);
bitmap.push((tag(&[b'T', b'W', b'i', b'0' + c]), F::I32(1)));
bitmap.push((tag(&[b'T', b'H', b'i', b'0' + c]), F::I32(1)));
bitmap.push((tag(&[b'S', b't', b'a', b'0' + c]), F::U8s(vec![if full { 2 } else { 0 }])));
}
let layer = F::Def(10, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), (tag(b"Desc"), F::Str("Photo".into()))]);
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 16.0, 16.0])), (tag(b"Chld"), F::Shared(vec![layer]))]);
let mut root = vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
if let Some(icc) = profile {
let mut blob = vec![0x2d];
blob.extend(tag(b"Data").0.to_le_bytes());
blob.extend((icc.len() as u32).to_le_bytes());
blob.extend_from_slice(icc);
root.push((tag(b"CMYP"), F::Obj(tag(b"ICCP"), vec![(tag(b"Data"), F::Raw(blob))])));
}
synth::container(&[("doc.dat", &stream(root), Method::Zstd)], None)
}
/// The image of the node named "Photo", and the blob it shows.
fn photo(d: &Document) -> &vectorcraft_doc::ImageBlob {
let NodeKind::Image(img) = &named(d, "Photo").kind else { panic!("an image") };
@@ -1012,32 +666,12 @@ fn cmyk_pixel_layers_without_a_profile_keep_their_inks_or_use_the_working_profil
assert!(loaded.warnings.iter().any(|w| w.contains("CMYK pixel layers with transparency")), "{:?}", loaded.warnings);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(128))]
#[test]
fn mutated_documents_never_panic(edits in proptest::collection::vec((0usize..4096, proptest::prelude::any::<u8>()), 1..16)) {
// The stream is mutated before it is packed, so edits reach the object graph instead of
// failing the archive's checksum.
let mut data = {
let shadow = F::Obj(tag(b"Shad"), vec![(tag(b"Radi"), F::F64(2.0)), (tag(b"Offs"), F::F64(3.0)), (tag(b"Colr"), rgba(0.0, 0.0, 0.0))]);
let nodes = vec![
with_effects(rectangle(10, (1.0, 2.0), (3.0, 4.0)), vec![shadow]),
artistic_text(20, "Hi\u{2029}there", 40.0),
table_node(30, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]),
];
stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread(2, 2, nodes)]))]))])
};
let n = data.len();
for (at, v) in edits {
if let Some(b) = data.get_mut(at % n) {
*b = v;
}
}
let file = synth::container(&[("doc.dat", &data, Method::Stored)], None);
if let Ok(loaded) = load("mutated.af", &file)
&& let Some(board) = loaded.doc.artboards.first()
{
let _ = vectorcraft_render::Renderer::new().render_region(&loaded.doc, board.rect, 0.05, false);
}
}
#[test]
fn objects_beyond_the_document_range_are_left_out() {
// 5 million points from the origin, past `MAX_COORD`.
let far = named_rect(11, "Far box", (5.0e6, 0.0), (10.0, 10.0));
let loaded = open("far.af", &canvas_with(vec![rectangle(10, (0.0, 0.0), (50.0, 50.0)), far], None));
let names: Vec<_> = all(&loaded.doc).iter().filter_map(|n| n.name.clone()).collect();
assert!(names.iter().any(|n| n == "Magenta box") && !names.iter().any(|n| n == "Far box"), "{names:?}");
assert!(loaded.warnings.iter().any(|w| w.contains("beyond the document range")), "{:?}", loaded.warnings);
}