From b12ee928f3e6cda717d5496422ce348f40bc6415 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 02:36:41 +0000 Subject: [PATCH] Security: document model generation budgets for live art, clipping runs, placed documents, graph masks, path reading and native entries CodeRabbit findings #58, #75, #82, #113, #159, #175, #187: - Live evaluation (expansion, envelope mapping and everything inside) makes at most 4M objects (`MAX_GENERATED`); deep expansion counts what each live object makes, and envelope image pieces count before they are cut. - Clipping runs copy at most 8M clip path elements together; past it runs are plain groups. - Exports read at most 256 placed documents; the rest show their previews. - Graph design and marker budgets count opacity-mask art (`Node::count_with_masks`). - Native files read at most 32M anchors (4M on wasm) of paths; the web build allows 1 GiB of entries after the JSON. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/clipnest.rs | 32 +++++++++++---- crates/doc/src/graph.rs | 2 +- crates/doc/src/live.rs | 68 ++++++++++++++++++++++++++++++- crates/doc/src/node.rs | 12 ++++++ crates/doc/src/placed_document.rs | 13 +++++- crates/engine/src/cmd/graph.rs | 4 +- crates/format/src/lib.rs | 12 ++++-- crates/geom/src/path.rs | 49 ++++++++++++++++++++++ 8 files changed, 172 insertions(+), 20 deletions(-) diff --git a/crates/doc/src/clipnest.rs b/crates/doc/src/clipnest.rs index f77f9e018..7d94e3b58 100644 --- a/crates/doc/src/clipnest.rs +++ b/crates/doc/src/clipnest.rs @@ -40,11 +40,18 @@ pub type Drawn = Vec<(Vec>, Node)>; /// The nodes of `items`, those under clips grouped into clipping groups and those in groups into /// groups (ids from `doc`), in order. pub fn nest(doc: &mut Document, items: Drawn) -> Vec> { - nest_at(doc, items, 0) + let mut left = MAX_CLIP_ELEMENTS; + nest_at(doc, items, 0, &mut left) } -/// [`nest`] for the clips at `depth` and deeper. -fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { +/// Most path elements the clipping paths [`nest`] makes may have together. Each run of objects +/// under a clip gets its own copy of the clip's path, so one large clip used again around many +/// short runs (an EMF restoring one saved clip between drawings) multiplied it. Past it, runs are +/// grouped without their clip. +pub const MAX_CLIP_ELEMENTS: usize = 8_000_000; + +/// [`nest`] for the clips at `depth` and deeper, with `left` of [`MAX_CLIP_ELEMENTS`]. +fn nest_at(doc: &mut Document, items: Drawn, depth: usize, left: &mut usize) -> Vec> { let mut out: Vec> = vec![]; let mut group: Drawn = vec![]; let mut clip: Option> = None; @@ -52,7 +59,7 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { let here = chain.get(depth).cloned(); if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) { if let Some(c) = clip.take() { - out.push(clip_group(doc, &c, std::mem::take(&mut group), depth)); + out.push(clip_group(doc, &c, std::mem::take(&mut group), depth, left)); } clip = here.clone(); } @@ -62,16 +69,23 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { } } if let Some(c) = clip { - out.push(clip_group(doc, &c, group, depth)); + out.push(clip_group(doc, &c, group, depth, left)); } out } /// A clipping group of `items`, clipped by `c` (their clip at `depth`), or their group when `c` is /// a plain group. -fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc { +fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize, left: &mut usize) -> Arc { let mut children = vec![]; - if let Some((bp, r)) = &c.region { + let region = c.region.as_ref().filter(|(bp, _)| match left.checked_sub(bp.elements().len()) { + Some(rest) => { + *left = rest; + true + } + None => false, + }); + if let Some((bp, r)) = region { let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(bp), Appearance::default()); if let NodeKind::Path { clipping, rule, .. } = &mut path.kind { *clipping = true; @@ -79,6 +93,6 @@ fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc GraphDesign::MAX_NODES { + if kept.iter().any(|k| k.name == name) || d.art.count_with_masks() > GraphDesign::MAX_NODES { continue; } kept.push(GraphDesign { name, art: std::sync::Arc::new(GraphDesign::plain_art(&d.art)) }); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index b434245cc..f84d0f865 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1614,6 +1614,14 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { /// edges (at most half a point) so neighbours overlap and no seams show. fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let k = w.cells.max(1); + // Two triangles a cell, three objects each; an image an inner envelope already cut into + // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image + // is left out. + if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![], clip: false }; + return g; + } let (pw, ph) = (im.width as f64, im.height as f64); let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); let mut pieces = Vec::with_capacity(2 * k * k); @@ -1791,6 +1799,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { /// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]). pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { + let _generating = Generating::enter(); match &n.kind { NodeKind::Blend { children, spec } => blend_expand(children, spec), NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), @@ -1809,6 +1818,7 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node { /// [`expanded_group`] with [`Hooks`]. pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { + let _generating = Generating::enter(); let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect(); let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); g.name = n.name.clone(); @@ -1832,9 +1842,21 @@ pub fn expand_deep(n: &Node, outline: Outliner) -> Node { expand_deep_hooks(n, outline.into()) } -/// [`expand_deep`] with [`Hooks`]. +/// [`expand_deep`] with [`Hooks`]. The objects live art makes here count against one +/// [`MAX_GENERATED`] ([`Generating`]): blends of blends kept their inner blends live, and each +/// copy expanded on its own, so step counts capped one by one multiplied. Past it, live art +/// expands to nothing. pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node { - let mut out = if is_live(n) { expanded_group_hooks(n, hooks) } else { n.clone() }; + let _generating = Generating::enter(); + let mut out = if is_live(n) { + let g = expanded_group_hooks(n, hooks); + if !Generating::charge(g.count()) { + return Node::new(n.id, NodeKind::Group { children: vec![], clip: false }); + } + g + } else { + n.clone() + }; if let Some(ch) = out.children_mut() { for c in ch.iter_mut() { if subtree_has_live(c) { @@ -1845,6 +1867,48 @@ pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node { out } +/// Most objects one evaluation of live art (an expansion, an envelope's mapping and everything +/// inside them) may make. +pub const MAX_GENERATED: usize = 4_000_000; + +std::thread_local! { + /// What is left of [`MAX_GENERATED`] for the evaluation running on this thread; `None` outside + /// one. + static GENERATED_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// The outermost live evaluation on this thread: it starts [`GENERATED_LEFT`] and ends it. +struct Generating(bool); + +impl Generating { + fn enter() -> Self { + let outermost = GENERATED_LEFT.get().is_none(); + if outermost { + GENERATED_LEFT.set(Some(MAX_GENERATED)); + } + Self(outermost) + } + + /// Spend `n` objects; `false` (and nothing left) when there aren't that many. + fn charge(n: usize) -> bool { + match GENERATED_LEFT.get() { + None => true, + Some(left) => { + GENERATED_LEFT.set(Some(left.saturating_sub(n))); + n <= left + } + } + } +} + +impl Drop for Generating { + fn drop(&mut self) { + if self.0 { + GENERATED_LEFT.set(None); + } + } +} + /// Does `n` or any descendant need live evaluation? pub fn subtree_has_live(n: &Node) -> bool { let mut any = false; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index f9a6a11fd..9eebb57f2 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1452,6 +1452,18 @@ impl Node { self.walk(&mut |_| n += 1); n } + /// [`Self::count`], with the art of the opacity masks in the subtree (and theirs): what copying + /// and transforming the subtree makes, as [`Self::walk`] doesn't go into masks. + pub fn count_with_masks(&self) -> usize { + let mut n = 0; + self.walk(&mut |c| { + n += 1; + if let Some(m) = &c.extras().mask { + n += m.art.count_with_masks(); + } + }); + n + } /// Fix this node's unplaced gradients to their fit on its current bounds (before a transform /// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed. pub fn pin_gradients(&mut self) { diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index a94edf42a..bb7c6e95f 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -525,6 +525,9 @@ fn rename(n: &mut Node, prefix: &str) { }); } +/// Most placed documents [`Document::with_placed_art`] reads for one export, nested ones included. +pub const MAX_PLACED_READS: usize = 256; + impl Document { /// This document ready to export its placed documents: their files read (the file read again /// when only the preview is stored, else the preview), and the resources their art uses added @@ -544,9 +547,15 @@ impl Document { } let mut d = self.clone(); let mut found: Vec> = vec![]; + // Placed documents are told apart by the names their art's resources get here, so one + // file placed in itself, or many times in a file placed many times, read again in every + // branch: past [`MAX_PLACED_READS`] reads, the rest show their previews. + let mut reads = 0usize; while let Some((p, depth)) = queue.pop() { let Some(blob) = d.images.get(&p.key).cloned() else { continue }; - let full = if depth < MAX_DEPTH { full_bytes(&p, &blob) } else { None }; + let read_full = depth < MAX_DEPTH && reads < MAX_PLACED_READS; + reads += usize::from(read_full); + let full = if read_full { full_bytes(&p, &blob) } else { None }; if full.and_then(|b| read(&p, &b)).is_none() { let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() }; if let Some(bytes) = preview { @@ -562,7 +571,7 @@ impl Document { .chain(e.doc.symbols.iter().map(|s| &s.art)) .chain(e.doc.graph_designs.iter().map(|g| &g.art)) .chain(e.doc.patterns.iter().flat_map(|p| &p.art)); - for root in inner { + for root in inner.filter(|_| reads < MAX_PLACED_READS) { walk_all(root, &mut |n| { if let NodeKind::PlacedDocument(q) = &n.kind { let key = format!("{prefix}{}", q.key); diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 3681aa261..cd8fa7b36 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -806,7 +806,7 @@ fn column_art( if length(rect) <= 1e-9 || !length(rect).is_finite() { return None; } - let cost = c.art.count(); + let cost = c.art.count_with_masks(); // A copy of the design moved by `a`, the totals left in it showing `value`; a copy fitted to `cell`; and a copy moved // by `a` and clipped to what it paints, cut at `at` (an x when `along_x`, else a y) with the side below `at` kept when // `keep_low` (two objects more). @@ -2076,7 +2076,7 @@ fn design(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("a document keeps at most {} graph designs", GraphDesign::MAX))); } let art = super::brushsym::selection_art(s, p)?.ok_or_else(|| bad(C, "select the art for the design"))?; - if art.count() > GraphDesign::MAX_NODES { + if art.count_with_masks() > GraphDesign::MAX_NODES { return Err(bad(C, format!("a design holds at most {} objects", GraphDesign::MAX_NODES))); } // A graph saved as a design is kept as the art it draws. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 832db19a6..f4bdeb42c 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -71,8 +71,10 @@ pub const MAX_JSON: u64 = 1 << 30; /// Most bytes one entry of the data after the JSON (an image, a placed document, the PDF, a /// profile) may have in a compressed file. pub const MAX_BLOB: u64 = 1 << 30; -/// Most bytes the entries after the JSON may have together in a compressed file. -pub const MAX_BLOBS: u64 = 4 << 30; +/// Most bytes the entries after the JSON may have together in a compressed file: 1 GiB in the web +/// build, whose 4 GiB address space also holds the JSON, the document and the app, so 4 GiB of +/// entries failed to allocate rather than being refused. +pub const MAX_BLOBS: u64 = if cfg!(target_arch = "wasm32") { 1 << 30 } else { 4 << 30 }; /// Most bytes a compressed file whose JSON isn't followed by the marker where VectorCraft puts it /// (hand-made or damaged files) may unpack to: it is unpacked whole. const MAX_UNPACKED: u64 = 512 << 20; @@ -522,8 +524,10 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { /// [`load`], with everything else the file carries. pub fn load_file(bytes: &[u8]) -> Result { let file = unpack::Unpacked::new(bytes)?; - // The JSON reader stops at 128 levels (a document's objects use three each). - let f: File = serde_json::from_slice(file.json()).map_err(|e| { + // The JSON reader stops at 128 levels (a document's objects use three each), and the paths + // read at `MAX_READ_ANCHORS` anchors together. + let read = || serde_json::from_slice::(file.json()); + let f: File = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| { let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() }; FormatError::NotVectorcraft(why) })?; diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index f7828fb80..4455c4ed9 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -180,9 +180,58 @@ fn is_collinear(p: Point, a: Point, b: Point) -> bool { cross.abs() <= 1e-6 * va.hypot().max(1.0) * vb.hypot().max(1.0) && va.dot(vb) < 0.0 } +/// Most anchors [`with_anchor_budget`] lets paths read together: 32M (about 1.5 GB), 4M in the web +/// build. +pub const MAX_READ_ANCHORS: usize = if cfg!(target_arch = "wasm32") { 4 << 20 } else { 32 << 20 }; + +std::thread_local! { + /// Anchors the paths being read on this thread may still have ([`with_anchor_budget`]). + static ANCHORS_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Run `f` (which reads paths with serde) letting the paths it reads have at most `max` anchors +/// together; reading more fails. A compressed file of repeated anchors stays small while its paths +/// take gigabytes once read, past what the web build can address. +pub fn with_anchor_budget(max: usize, f: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + ANCHORS_LEFT.set(self.0); + } + } + let _restore = Restore(ANCHORS_LEFT.replace(Some(max))); + f() +} + +/// A subpath's anchors read within [`with_anchor_budget`]. +fn anchors_within<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of anchors") + } + fn visit_seq>(self, mut a: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(anchor) = a.next_element::()? { + if let Some(left) = ANCHORS_LEFT.get() { + let left = left + .checked_sub(1) + .ok_or_else(|| serde::de::Error::custom(format!("the paths have more than {MAX_READ_ANCHORS} anchors")))?; + ANCHORS_LEFT.set(Some(left)); + } + out.push(anchor); + } + Ok(out) + } + } + d.deserialize_seq(V) +} + /// An open or closed run of anchors. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct SubPath { + #[serde(deserialize_with = "anchors_within")] pub anchors: Vec, #[serde(default)] pub closed: bool,