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 <noreply@anthropic.com>
This commit is contained in:
Dylan M. Taylor
2026-10-24 17:19:16 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 75dacb4fec
commit b12ee928f3
8 changed files with 172 additions and 20 deletions
+23 -9
View File
@@ -40,11 +40,18 @@ pub type Drawn = Vec<(Vec<Arc<Clip>>, 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<Arc<Node>> {
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<Arc<Node>> {
/// 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<Arc<Node>> {
let mut out: Vec<Arc<Node>> = vec![];
let mut group: Drawn = vec![];
let mut clip: Option<Arc<Clip>> = None;
@@ -52,7 +59,7 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
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<Arc<Node>> {
}
}
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<Node> {
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize, left: &mut usize) -> Arc<Node> {
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<N
}
children.push(Arc::new(path));
}
children.extend(nest_at(doc, items, depth + 1));
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() }))
children.extend(nest_at(doc, items, depth + 1, left));
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: region.is_some() }))
}
+1 -1
View File
@@ -127,7 +127,7 @@ impl crate::Document {
break;
}
let Some(name) = GraphDesign::clean_name(&d.name) else { continue };
if kept.iter().any(|k| k.name == name) || d.art.count() > 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)) });
+66 -2
View File
@@ -1614,6 +1614,14 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
/// 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<Node> {
/// [`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<Node> {
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<Option<usize>> = 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;
+12
View File
@@ -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) {
+11 -2
View File
@@ -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<Arc<ReadDocument>> = 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);
+2 -2
View File
@@ -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<Value> {
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.
+8 -4
View File
@@ -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<NativeFile, FormatError> {
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>(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)
})?;
+49
View File
@@ -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<Option<usize>> = 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<T>(max: usize, f: impl FnOnce() -> T) -> T {
struct Restore(Option<usize>);
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<Vec<Anchor>, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Vec<Anchor>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of anchors")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Vec<Anchor>, A::Error> {
let mut out = Vec::new();
while let Some(anchor) = a.next_element::<Anchor>()? {
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<Anchor>,
#[serde(default)]
pub closed: bool,