Plug-ins: a filter's read-only objects stay in its input and failed decodes count

A filter run kept a second copy of every read-only object as JSON values.
The run now keeps where each object is in the input, and checks an output
entry against it by parsing that one object, unless the entry is `{"id": …}`
alone.

An image that reaches the decoder now counts toward the 16 MiB pixel total
even when decoding fails, so broken images use up the same total as images
that decode.

The plugin.run text, docs/mcp.md and the progress line state the paint
order and that rule, and docs/plugins.md states that a CMYK image's pixels
are sRGB. New tests cover the per-image 1 MiB, the 16 MiB pixel total and
the 16 MiB text total at and one past their values, memory-budget failures
before the plug-in starts, images that are not decoded, CMYK images
included, and leading per run.
This commit is contained in:
dotCooCoo
2026-11-08 20:54:02 -08:00
parent 6b978fea4f
commit 0e1aa5cc2e
7 changed files with 365 additions and 74 deletions
+5 -6
View File
@@ -41,7 +41,6 @@
//! images or symbol instances of those kinds, to read only: its output must return each of them
//! unchanged ([`objects::decode_output_with`]).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use serde_json::{Map, Value, json};
@@ -134,9 +133,9 @@ pub struct FilterRun {
params: Vec<u8>,
/// The objects the plug-in is given, and the container new objects go in.
inputs: Vec<NodeId>,
/// The type, images and symbol instances among them (a filter that asks for them), as given:
/// its output must return each one unchanged.
read_only: HashMap<NodeId, Value>,
/// The type, images and symbol instances among them (a filter that asks for them): its output
/// must return each one as `input` gives it. These are their places in `input`.
read_only: objects::ReadOnlySpans,
parent: Option<NodeId>,
/// The document it was made from, and that document's revision then.
document: (u64, u64),
@@ -270,7 +269,7 @@ impl FilterRun {
fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> {
let m = self.plugin.manifest();
let may = m.permissions();
let out = objects::decode_output_with(output, &self.read_only).map_err(err)?;
let out = objects::decode_output_with(output, &self.input, &self.read_only).map_err(err)?;
if out.document_data.is_some() && !may.contains(&Permission::State) {
return Err(not_granted(&m.name, Permission::State));
}
@@ -908,7 +907,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Plug-in",
[],
None,
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, in paint order from the bottom, with at most 16 MiB of pixels and 16 MiB of type runs per run (an object that doesn't fit what the objects before it left comes with pixelsOmitted or truncated; an image that reaches the decoder counts toward the pixels even when decoding fails); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
has_doc,
run
),
+63
View File
@@ -1603,6 +1603,69 @@ fn read_only_kind_field_and_omission_refuse_without_undo() {
assert!(e.contains("`inputs` needs `api` 2"), "{e}");
}
/// A run past its memory budget fails before the plug-in starts, with the document and undo
/// history as they were: a read-only object whose other fields don't fit, a path and a compound
/// path that don't fit, and parameters past the input's half of the cap, with objects selected or
/// with none. The module has one page of memory, the most it may have, so a run that started would
/// report that the plug-in ran out of memory instead.
#[test]
fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, Node, NodeKind};
use vectorcraft_geom::FillRule;
use vectorcraft_plugins::{Limits, Plugin, registry};
let id = "test.engine.inputs.budget";
let params: Vec<String> = (0..9).map(|i| format!(r#""p{i}":{{"type":"string","maxLength":4096}}"#)).collect();
let manifest = format!(
r#"{{"id":"{id}","name":"Budget","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"],"params":{{{}}}}}"#,
params.join(",")
);
let module = wat::parse_str(tpl::module(&manifest, "unreachable", "", 1)).unwrap();
// One page of memory: the input and the parameters share 32 KiB.
registry::install(Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap());
let mut s = session();
let small = rect(&mut s, 10.0, "#ff0000");
let long = rect(&mut s, 30.0, "#00ff00");
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap());
let mut compound = NodeId(0);
s.edit("Set up", |d, _| {
d.node_mut(t).unwrap().name = Some("n".repeat(40_000));
let points: Vec<Point> = (0..4_000).map(|i| Point::new(f64::from(i), 0.0)).collect();
let outline = PathData::single(SubPath::polyline(&points, false));
if let NodeKind::Path { path, .. } = &mut d.node_mut(long).unwrap().kind {
*path = outline.clone();
}
let member = Node::path(d.alloc_id(), outline, Appearance::default());
let n = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![Arc::new(member)], rule: FillRule::NonZero });
let layer = d.layers[0].id;
compound = d.insert(Some(layer), usize::MAX, n).unwrap();
Ok(())
})
.unwrap();
let padding: Value = (0..9).map(|i| (format!("p{i}"), json!("x".repeat(4096)))).collect::<serde_json::Map<_, _>>().into();
let too_large = "memory budget (the selection is too large to hand over)";
let named = format!("memory budget (object {} needs", t.0);
let with_padding = |ids: Vec<u64>| {
let mut p = padding.clone();
p["id"] = json!(id);
p["ids"] = json!(ids);
p
};
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
for (p, says) in [
(json!({"id": id, "ids": [t.0]}), named.as_str()),
(json!({"id": id, "ids": [long.0]}), too_large),
(json!({"id": id, "ids": [compound.0]}), too_large),
(with_padding(vec![small.0]), too_large),
(with_padding(vec![]), too_large),
] {
let e = s.execute("plugin.run", &p).unwrap_err().to_string();
assert!(e.contains(says), "{says}: {e}");
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before, "{says}");
}
assert!(registry::remove(id));
}
/// What the user granted is kept in the plug-in store with the module: the next session installs
/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is
/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder