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.
This commit is contained in:
Don Omondi
2026-10-29 11:07:37 +07:00
committed by bflatastic
parent 2b36d641c1
commit eeae8c9136
4 changed files with 84 additions and 3 deletions
+1
View File
@@ -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
+16
View File
@@ -433,3 +433,19 @@ fn bad_kinds_are_errors() {
assert!(serde_json::from_str::<NodeKind>(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::<Node>(&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());
}
+37
View File
@@ -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<Option<(usize, usize)>> = 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<T>(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<NodeId, D::Error> {
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<String>,
+30 -3
View File
@@ -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<NativeFile, FormatError> {
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<NativeFile, FormatError> {
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>(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>(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());