mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -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:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user