mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
75dacb4fec
commit
b12ee928f3
@@ -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() }))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user