mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user