From eeae8c9136befe96281b4363ac3e054f0ea5592a Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:31:22 +0300 Subject: [PATCH] fix(format): read a native document within an object budget (#3587) Opening a native document now reads at most MAX_READ_NODES objects (4M, 512K on wasm32) and refuses the file past that with a message naming the budget. Paths already read within MAX_READ_ANCHORS and the web build's JSON cap is 256 MB, but the number of objects had no bound: a gzip'd document of empty groups costs about 270 bytes per object once read against some 50 in its JSON, so 10M groups in a 24 MB file took 2.7 GB to open. NodeWire's id is read through counted_id, so each object counts once as it is read. load_file reads through a private load_file_within so a test can exercise the budget without a 4M-object document. --- crates/doc/src/node.rs | 1 + crates/doc/src/node/tests.rs | 16 ++++++++++++++++ crates/doc/src/node/wire.rs | 37 ++++++++++++++++++++++++++++++++++++ crates/format/src/lib.rs | 33 +++++++++++++++++++++++++++++--- 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 2be25e0b1..b12c71cb6 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -32,6 +32,7 @@ pub use kind::{ImageObject, NodeKind}; pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; use wire::NodeWire; +pub use wire::{MAX_READ_NODES, with_node_budget}; fn yes() -> bool { true diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 3e7a399f2..dbc95771f 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -433,3 +433,19 @@ fn bad_kinds_are_errors() { assert!(serde_json::from_str::(bad).is_err(), "{bad}"); } } + +/// #3587: [`with_node_budget`] counts every object read, nested ones included, refuses the one past +/// it with a message naming the budget, and is gone once its reading is done. +#[test] +fn objects_are_read_within_the_node_budget() { + let group = Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![])), Arc::new(Node::group(NodeId(3), vec![]))]); + let json = serde_json::to_string(&group).unwrap(); + let read = || serde_json::from_str::(&json); + assert_eq!(with_node_budget(3, read).unwrap(), group, "three objects fit a budget of three"); + let why = with_node_budget(2, read).unwrap_err().to_string(); + assert!(why.contains("more than 2 objects"), "{why}"); + assert_eq!(read().unwrap(), group, "no budget outside a read"); + // A budget left by a read that failed doesn't carry over to the next one. + assert!(with_node_budget(1, read).is_err()); + assert!(with_node_budget(3, read).is_ok()); +} diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 0af451316..f46e10519 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -84,11 +84,48 @@ impl Serialize for Node { } } +/// Most objects [`with_node_budget`] lets a document read: 4M (about 1 GB of the smallest +/// objects), 512K in the web build. Paths have their own anchor budget +/// ([`vectorcraft_geom::path::MAX_READ_ANCHORS`]); this one bounds the objects themselves. +pub const MAX_READ_NODES: usize = if cfg!(target_arch = "wasm32") { 512 << 10 } else { 4 << 20 }; + +std::thread_local! { + /// Objects the document being read on this thread may still have, and the budget they come + /// from ([`with_node_budget`]). + static NODES_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Run `f` (which reads nodes with serde) letting it read at most `max` objects together; reading +/// more fails. A compressed file of repeated empty objects stays small while they take gigabytes +/// once read, about 270 bytes each against some 50 in the file (#3587). +pub fn with_node_budget(max: usize, f: impl FnOnce() -> T) -> T { + struct Restore(Option<(usize, usize)>); + impl Drop for Restore { + fn drop(&mut self) { + NODES_LEFT.set(self.0); + } + } + let _restore = Restore(NODES_LEFT.replace(Some((max, max)))); + f() +} + +/// A node's id, counting the node against [`with_node_budget`]. Every node has exactly one id, so +/// each counts once, and the id usually comes first, before the node's children are read. +fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let id = NodeId::deserialize(d)?; + if let Some((left, max)) = NODES_LEFT.get() { + let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?; + NODES_LEFT.set(Some((left, max))); + } + Ok(id) +} + /// How a [`Node`] is read: the flat field list of the file format. Fields move into the node /// (and its extras, allocated only when one is set) without a copy of what they own. #[derive(Deserialize)] #[serde(rename = "Node")] pub(super) struct NodeWire { + #[serde(deserialize_with = "counted_id")] id: NodeId, #[serde(default)] name: Option, diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 798fcf312..3081bd99f 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -552,10 +552,16 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { /// [`load`], with everything else the file carries. pub fn load_file(bytes: &[u8]) -> Result { + load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES) +} + +/// [`load_file`] reading at most `max_nodes` objects, so a test can exercise the object budget +/// without a document of [`vectorcraft_doc::node::MAX_READ_NODES`] objects. +fn load_file_within(bytes: &[u8], max_nodes: usize) -> Result { let file = unpack::Unpacked::new(bytes)?; - // 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()); + // The JSON reader stops at 128 levels (a document's objects use three each), the paths read + // at `MAX_READ_ANCHORS` anchors together, and the document at `MAX_READ_NODES` objects. + let read = || vectorcraft_doc::node::with_node_budget(max_nodes, || 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) @@ -776,6 +782,27 @@ mod tests { assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data()); } + /// #3587: objects are read within a budget. A compressed file of many small objects stays + /// small on disk while they take far more memory once read, so past the budget it is refused + /// with a message naming it, before the rest of the document is read. + #[test] + fn a_documents_objects_are_capped() { + assert_eq!(vectorcraft_doc::node::MAX_READ_NODES, if cfg!(target_arch = "wasm32") { 512 << 10 } else { 4 << 20 }); + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + for i in 0..200 { + let id = d.alloc_id(); + d.insert(Some(l), i, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + } + let n = d.node_count(); + let bytes = save_with(&d, &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap(); + assert!(is_compressed(&bytes)); + assert_eq!(load_file_within(&bytes, n).unwrap().doc.node_count(), n, "a document of exactly the budget opens"); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1) else { panic!("one object past the budget must be refused") }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + assert_eq!(load(&bytes).unwrap().node_count(), n, "the real budget opens ordinary documents"); + } + #[test] fn rejects_foreign_and_future() { assert!(load(b"{}").is_err());