Files
vectorcraft/crates/affinity/tests/import_limits.rs
T
dwetscherandClaude Opus 5.5 f73a3df974 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>
2026-10-22 18:24:57 +07:00

183 lines
7.9 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.
//! Crafted documents can't make the reader allocate or work without bound: tables, shared
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
//! a file links or embeds the same content. Original synthetic documents in the reader's
//! accepted schema; no Affinity software or files were used.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod support {
pub mod text_fixture;
}
use support::text_fixture::*;
use vectorcraft_affinity::{
Document, Error, Limits,
model::{Kind, Node},
read,
synth::{self, F, Method, tag},
};
type Field = (vectorcraft_affinity::stream::Tag, F);
/// A document of one spread holding `nodes`, plus these other archive entries.
fn file(nodes: Vec<F>, entries: &[(&str, &[u8])]) -> Vec<u8> {
let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
let mut next_id = 1000;
for (_, value) in &mut root {
shared_objects(value, &mut next_id);
}
let data = synth::stream(&root);
let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
synth::container(&all, None)
}
fn open(nodes: Vec<F>) -> Document {
read(&file(nodes, &[]), Limits::default()).unwrap()
}
fn all(nodes: &[Node]) -> Vec<&Node> {
let mut out = Vec::new();
for n in nodes {
out.push(n);
out.extend(all(&n.children));
}
out
}
fn warned(d: &Document, needle: &str) -> bool {
d.warnings.iter().any(|w| w.contains(needle))
}
fn story(text: &str) -> F {
F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
}
fn artistic(story: F) -> F {
F::Obj(
tag(b"TxtA"),
vec![
(tag(b"StSt"), story),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
],
)
}
/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
fn grid(cols: usize, rows: usize) -> Vec<Field> {
let pos = |n: usize| {
let v: Vec<f64> = (0..=n).map(|i| i as f64).collect();
F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
};
let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
}
fn table(frame: F) -> F {
F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
}
fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> {
all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
}
#[test]
fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
// 4096 × 4096 is within each dimension's limit but 16.7 million cells.
let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
let t = tables(&d);
assert_eq!(t.len(), 1);
assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
}
#[test]
fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
// One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
assert_eq!(tables(&d).len(), 4);
assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
}
#[test]
fn a_shared_story_is_read_within_the_characters_of_one_document() {
// A story of a million characters linked from twenty text layers: 16 million in all.
let text = "x".repeat(1 << 20);
let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
let chars: usize = all(&d.spreads[0].nodes)
.into_iter()
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::<usize>()) } else { None })
.sum();
assert_eq!(chars, 16 << 20);
assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
}
#[test]
fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
// 200 000 paragraphs, each with its own run and format.
let n = 200_000;
let text = "a\u{2029}".repeat(n);
let fmt =
|end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
let blk = F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), utf8(&text)),
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
],
);
let started = std::time::Instant::now();
let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
assert_eq!(t.runs.len(), n);
// (About half a second; quadratic matching took over half a minute on the same machine.)
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
}
/// A shape node of `size` px.
fn rect(size: f64) -> F {
F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
}
/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
fn embedded(children: Vec<F>) -> F {
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
if !children.is_empty() {
fields.push((tag(b"Chld"), F::Shared(children)));
}
F::Obj(tag(b"EmbN"), fields)
}
fn embedding(inner: &[u8], nodes: Vec<F>) -> Vec<u8> {
let mut entry = b"EmDc\0\0\0\0".to_vec();
entry.extend_from_slice(inner);
file(nodes, &[("edc/1", &entry)])
}
#[test]
fn an_embedded_documents_content_stays_with_its_node() {
// Read before the node's own children, which must not take it.
let inner = file(vec![rect(10.0), rect(20.0)], &[]);
let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
let node = &d.spreads[0].nodes[0];
assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
assert!(node.children.iter().all(|c| c.children.is_empty()));
}
#[test]
fn embedding_one_document_many_times_shares_one_budget() {
// 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
// a whole read may take (each embedding used to have an allowance of its own).
let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
// Embedded fewer times, it opens.
let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
}