From 66a01584aeb40cd2bec99a3a59f02f9fa01b1345 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 00:15:36 -0800 Subject: [PATCH] Plug-ins: inputs need a granted doc.read and stay within totals A filter manifest that lists `inputs` must now list `doc.read` in `permissions`, so installing it asks the user to grant them and the Additional Plug-ins Folder loads it only once granted. Left out, the manifest does not install. The pixels of all images in one filter input take at most 16 MiB of base64, and the runs and paragraphs of all its type at most 16 MiB of JSON. An image whose pixels do not fit what is left comes with `pixelsOmitted`, checked before it is decoded, and type that does not fit comes with `truncated`. The read-only objects are kept once and written into the input from there. A field a filter adds to a read-only object is named in the refusal with control and bidirectional characters escaped, cut to 64 characters. --- crates/engine/src/cmd/plugin.rs | 2 +- crates/engine/src/tests/plugins.rs | 16 +++- crates/engine/tests/plugin_fuzz.rs | 7 +- crates/plugins/src/manifest.rs | 24 +++-- crates/plugins/src/objects.rs | 136 ++++++++++++++++++++-------- crates/plugins/src/objects/tests.rs | 51 +++++++++++ docs/mcp.md | 8 +- docs/plugins.md | 19 ++-- docs/target-app-parity.md | 2 +- 9 files changed, 202 insertions(+), 63 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 316221488..f352bdac1 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -531,7 +531,7 @@ pub fn specs() -> Vec { "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?: [..] (default: 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 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) is also given the selected objects of those kinds to read: 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?: [..] (default: 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 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): 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 ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index ee67fe603..d80873ac8 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -663,16 +663,26 @@ fn a_filter_does_only_what_its_permissions_allow() { /// A filter whose manifest lists `inputs` is also given the selected type, read-only: an echo /// changes nothing, and an output that leaves the type out or changes it is refused with the /// document and undo history as they were, handed in from a run made elsewhere too. A filter -/// without `inputs` is not given the type and so never deletes it. +/// without `inputs` is not given the type and so never deletes it. Installing one that lists +/// `inputs` needs the user's grant. #[test] fn read_only_kind_field_and_omission_refuse_without_undo() { use cmd::plugin::FilterRun; let mut s = session(); let r = rect(&mut s, 10.0, "#ff0000"); let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap()); - let reads = |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"inputs":["type"]}}"#); + let reads = + |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"]}}"#); + let install_wat = |s: &mut Session, src: &str| { + let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})) + }; let echo = "test.engine.inputs.echo"; - let listed = install_wat(&mut s, &tpl::module(&reads(echo), tpl::ECHO, "", 1)); + let module = tpl::module(&reads(echo), tpl::ECHO, "", 1); + let data = vectorcraft_format::base64_encode(&wat::parse_str(&module).unwrap()); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read, doc.write"), "{e}"); + let listed = install_wat(&mut s, &module); assert_eq!(listed["inputs"], json!(["type"])); let p = |id: &str| json!({"id": id, "ids": [r.0, t.0]}); let before = s.doc().unwrap().doc.clone(); diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 5dc2e2a5e..089e6255e 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -365,7 +365,8 @@ proptest! { /// Manifests with hostile `inputs` (unknown, repeated and oddly cased kinds, long lists, junk of /// every type) with any `api`, kind and permissions parse to an error or to a filter at `api` 2 - /// or later asking for each kind once. One that installs and echoes its input on a document + /// or later asking for each kind once, whose `permissions` list `doc.read` so that installing + /// it asks for approval. One that installs and echoes its input on a document /// with type, images and symbols leaves the document as it was, and it still renders. #[test] fn hostile_manifest_inputs_never_crash( @@ -374,7 +375,7 @@ proptest! { long in prop::option::weighted(0.1, 4usize..300), inputs in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), api in prop_oneof![2 => Just(json!(2)), 1 => Just(json!(1)), 1 => Just(json!(3)), 1 => arb_junk()], - permissions in prop_oneof![Just(Value::Null), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))], + permissions in prop_oneof![Just(Value::Null), Just(json!([])), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))], ) { let mut m = json!({"id": "fuzz.inputs", "name": "Inputs", "kind": kind, "api": api}); if kind == "exporter" { @@ -396,6 +397,8 @@ proptest! { prop_assert_eq!(kinds.len(), man.inputs.len(), "{}", text); prop_assert!(kinds.iter().enumerate().all(|(i, k)| !kinds[..i].contains(k)), "{}", text); prop_assert!(kinds.is_empty() || (man.kind == vectorcraft_plugins::Kind::Filter && man.api >= 2), "{}", text); + let reads = man.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == "doc.read")); + prop_assert!(kinds.is_empty() || (man.asks_permissions() && reads), "{}", text); } let mut s = rich_session(); let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap(); diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 0016c533c..1c9bca8bc 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -284,8 +284,8 @@ pub struct Manifest { #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option>, /// The kinds of object a filter asks to be given besides paths and compound paths, by - /// [`InputKind::name`]: only a filter at `api` 2 or later lists them. Left out or empty, it is - /// given paths and compound paths alone. + /// [`InputKind::name`]: only a filter at `api` 2 or later whose `permissions` list `doc.read` + /// lists them. Left out or empty, it is given paths and compound paths alone. #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "input_names")] pub inputs: Vec, /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. @@ -377,7 +377,8 @@ impl Manifest { } /// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists - /// any, with `doc.read` among its permissions when it lists them. + /// any, with a `permissions` list that holds `doc.read`: installing it then asks the user to + /// grant them ([`Manifest::asks_permissions`]). fn validate_inputs(&self) -> Result<()> { if self.inputs.is_empty() { return Ok(()); @@ -397,7 +398,7 @@ impl Manifest { if self.api < INPUTS_API { return Err(Error::Manifest("`inputs` needs `api` 2 or later".into())); } - if self.permissions.as_ref().is_some_and(|p| !p.iter().any(|n| n == Permission::DocRead.name())) { + if !self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::DocRead.name())) { return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into())); } Ok(()) @@ -758,17 +759,20 @@ mod tests { } /// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once, - /// with `doc.read` when it lists permissions. Left out or empty, it asks for none and isn't - /// written back. Anything else is refused naming `inputs`. + /// with a `permissions` list holding `doc.read`, so installing it asks for approval. Left out + /// or empty, it asks for none and isn't written back. Anything else is refused naming `inputs`. #[test] fn a_filter_at_api_2_lists_the_inputs_it_reads() { - let m = - Manifest::parse(br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"inputs":["type","image","symbolInstance"]}"#) - .unwrap(); + let m = Manifest::parse( + br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type","image","symbolInstance"]}"#, + ) + .unwrap(); assert_eq!(m.input_kinds(), InputKind::ALL); + assert!(m.asks_permissions()); assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"])); let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap(); assert_eq!(some.input_kinds(), [InputKind::Image]); + assert!(some.asks_permissions()); for none in [ r#"{"id":"a","name":"x","kind":"filter"}"#, r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#, @@ -789,6 +793,8 @@ mod tests { (r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"), (r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"), (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["type"]}"#, "`inputs` needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":[],"inputs":["image"]}"#, "`inputs` needs the permission `doc.read`"), (r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"), (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"), ] { diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 9fa37ce54..e8db87319 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -11,7 +11,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, TextKind, TextObject, + Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes, + TextKind, TextObject, }; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; @@ -42,6 +43,12 @@ pub const MAX_INPUT_RUNS: usize = 4_096; /// Largest pixels a filter is given of one embedded image, in characters of base64: a larger /// image comes with `pixelsOmitted`. pub const MAX_INPUT_PIXELS: usize = 1 << 20; +/// Most pixels a filter is given over all the images of one input, in characters of base64: an +/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`. +pub const MAX_INPUT_PIXELS_TOTAL: usize = 16 << 20; +/// Most text a filter is given over all the type of one input, in bytes of the JSON of their runs +/// and paragraphs: type that doesn't fit what the type before it left comes with `truncated`. +pub const MAX_INPUT_TEXT_TOTAL: usize = 16 << 20; /// One object returned by a plug-in. `None` fields keep the object's value (new objects take the /// defaults: a path, non-zero rule, no fills or strokes, full opacity). @@ -175,23 +182,56 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { } /// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are -/// in it as [`encode_read_only`] gives them. Also returns those objects by id, which the filter must -/// return as they were ([`decode_output_with`]). +/// in it as [`encode_read_only`] gives them, within [`MAX_INPUT_PIXELS_TOTAL`] and +/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order. Also returns those objects by id, which the +/// filter must return as they were ([`decode_output_with`]). pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind]) -> (Vec, HashMap) { + encode_input_within(doc, ids, kinds, Allowance::FULL) +} + +/// What an input's images and type may still be given of its totals, as its objects are encoded in +/// order: characters of base64 for pixels, bytes of JSON for runs and paragraphs. +#[derive(Clone, Copy, Debug)] +struct Allowance { + pixels: usize, + text: usize, +} + +impl Allowance { + const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL }; +} + +/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map +/// it returns, and written into the input from there. +fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> (Vec, HashMap) { let mut read_only = HashMap::new(); - let objects: Vec = ids - .iter() - .filter_map(|id| doc.node(*id)) - .filter_map(|n| match input_kind(n).filter(|k| kinds.contains(k)) { - Some(_) => { - let o = encode_read_only(doc, n)?; - read_only.insert(n.id, o.clone()); - Some(o) - } - None => encode(n), - }) - .collect(); - (serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default(), read_only) + let symbols = doc.symbol_boxes(); + let mut out = b"{\"objects\":[".to_vec(); + let mut first = true; + for n in ids.iter().filter_map(|id| doc.node(*id)) { + let path; + let o: &Value = match input_kind(n).filter(|k| kinds.contains(k)) { + Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left) { + Some(o) => read_only.entry(n.id).insert_entry(o).into_mut(), + None => continue, + }, + None => match encode(n) { + Some(o) => { + path = o; + &path + } + None => continue, + }, + }; + if !std::mem::take(&mut first) { + out.push(b','); + } + if serde_json::to_writer(&mut out, o).is_err() { + return (Vec::new(), HashMap::new()); + } + } + out.extend_from_slice(b"]}"); + (out, read_only) } /// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). @@ -222,11 +262,20 @@ fn affine_json(xf: Affine) -> Value { /// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when /// the document has no such symbol, and `overrides: {}`. pub fn encode_read_only(doc: &Document, n: &Node) -> Option { - let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of(&[n.id], false))}); + let mut left = Allowance::FULL; + encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left) +} + +/// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the +/// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that +/// don't fit what is left are omitted (`pixelsOmitted`, `truncated`), and an image's size is +/// checked against it before anything is decoded. +fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Option { + let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); match &n.kind { NodeKind::Text(t) => { o["transform"] = affine_json(t.xf); - let (text, truncated) = text_json(t); + let (text, truncated) = text_json(t, &mut left.text); o["text"] = text; o["truncated"] = json!(truncated); } @@ -235,13 +284,15 @@ pub fn encode_read_only(doc: &Document, n: &Node) -> Option { let linked = im.link.is_some(); let blob = doc.image_blob(im); // A linked image's pixels are its file's: never given (nor its path). - let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height)); + let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height, MAX_INPUT_PIXELS.min(left.pixels))); o["pixelSize"] = json!([im.width, im.height]); o["colorMode"] = json!(blob.map_or("unknown", color_mode)); o["linked"] = json!(linked); o["pixelsOmitted"] = json!(pixels.is_none()); if let Some(p) = pixels { - o["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p)); + let b64 = craft_vectorcraft_format::base64::encode(&p); + left.pixels = left.pixels.saturating_sub(b64.len()); + o["pixelsBase64"] = json!(b64); } } NodeKind::SymbolInstance { symbol, xf } => { @@ -261,8 +312,9 @@ pub fn encode_read_only(doc: &Document, n: &Node) -> Option { } /// The text of type `t` as a filter reads it, and whether it was too long to give -/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`]). -fn text_json(t: &TextObject) -> (Value, bool) { +/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives +/// is taken from). +fn text_json(t: &TextObject, left: &mut usize) -> (Value, bool) { let kind = match t.kind { TextKind::Point => "point", TextKind::Area { .. } => "area", @@ -270,16 +322,21 @@ fn text_json(t: &TextObject) -> (Value, bool) { }; let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); let paragraphs = t.paragraph_count(); - let truncated = bytes > MAX_INPUT_TEXT || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; - let (runs, paras): (Vec, Vec) = if truncated { - (Vec::new(), Vec::new()) - } else { + // The text alone is never more bytes than its JSON, so type that can't fit isn't built. + let mut truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; + let (mut runs, mut paras): (Vec, Vec) = (Vec::new(), Vec::new()); + if !truncated { let value = |v: std::result::Result| v.unwrap_or(Value::Null); - ( - t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(), - (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(), - ) - }; + runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); + paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); + let size = runs.iter().chain(¶s).fold(0usize, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); + match left.checked_sub(size) { + Some(rest) => *left = rest, + None => { + (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + } + } + } (json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated) } @@ -300,11 +357,11 @@ fn color_mode(b: &ImageBlob) -> &'static str { if readable.is_some() { "rgb" } else { "unknown" } } -/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit -/// [`MAX_INPUT_PIXELS`] in base64; the size is checked from the header before anything is decoded. -fn rgba_pixels(b: &ImageBlob, w: u32, h: u32) -> Option> { +/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max` +/// characters of base64; the size is checked, and read from the header, before anything is decoded. +fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option> { let bytes = u64::from(w) * u64::from(h) * 4; - if bytes == 0 || bytes.div_ceil(3) * 4 > MAX_INPUT_PIXELS as u64 { + if bytes == 0 || bytes.div_ceil(3) * 4 > max as u64 { return None; } let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); @@ -629,7 +686,9 @@ fn decode_objects(v: &Value, read_only: &HashMap) -> Result Result<()> { if o.as_object().is_some_and(|m| m.len() == 1) { return Ok(()); @@ -647,7 +706,10 @@ fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> { }; match changed.or_else(|| first_difference(o, given, "")) { None => Ok(()), - Some(field) => Err(out_err(at, format!("read-only field '{field}' changed"))), + Some(field) => { + let field = crate::permissions::display_text(&field, crate::permissions::MAX_SHOWN_FIELD); + Err(out_err(at, format!("read-only field '{field}' changed"))) + } } } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 48b0a38b3..dddf62fbf 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -471,6 +471,57 @@ fn declared_symbol_instance_keeps_identity_and_transform() { assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true))); } +/// Pixels and type count against totals over the whole input, in order: an image whose pixels +/// don't fit what the images before it left comes with `pixelsOmitted` (its size checked before it +/// is decoded), and type whose runs and paragraphs don't fit comes with `truncated`. The read-only +/// objects returned are the ones in the input. +#[test] +fn input_totals_cover_images_and_type_in_order() { + let (mut d, _, layer, [text, embedded, ..]) = mixed(); + let copy = |d: &mut Document, id: NodeId| { + let mut n = d.node(id).unwrap().clone(); + n.id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, n).unwrap() + }; + let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded)); + let one = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + let size: usize = ["runs", "paragraphs"].iter().flat_map(|k| one["text"][k].as_array().unwrap()).map(|v| v.to_string().len()).sum(); + // A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't. + let ids = [text, text2, embedded, image2, image3]; + let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size }); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let objects = v["objects"].as_array().unwrap(); + assert_eq!(objects.len(), 5); + let flags: Vec<&Value> = objects.iter().map(|o| if o["type"] == "type" { &o["truncated"] } else { &o["pixelsOmitted"] }).collect(); + assert_eq!(flags, [&json!(false), &json!(true), &json!(false), &json!(false), &json!(true)]); + assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none()); + assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]); + for o in objects { + assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap()))); + } + // Within the full totals every one of them is given. + let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL); + let all = String::from_utf8(bytes).unwrap(); + assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}"); +} + +/// A field a plug-in adds to a read-only object is named in the refusal with its control and +/// direction characters escaped, and cut short. +#[test] +fn a_changed_field_is_named_escaped_and_short() { + let (d, _, layer, [text, ..]) = mixed(); + let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]); + let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type]); + let mut v: Value = serde_json::from_slice(&bytes).unwrap(); + let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap(); + let key = format!("\u{202E}{}", "k".repeat(500)); + v["objects"][at][key.as_str()] = json!(1); + let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string(); + let shown = e.split('\'').nth(1).unwrap_or_default(); + assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}"); + assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}"); +} + /// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by /// value), and they stay as they are, new objects listed after one going just above it. Any change, /// an object left out or listed twice, and a new object of such a kind are refused before anything diff --git a/docs/mcp.md b/docs/mcp.md index d2606e316..af66deab4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6087,9 +6087,11 @@ them again at startup (`plugin.remove` deletes them there): `plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` -(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) is also given the selected objects of those -kinds to read: its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an -error naming the object and the field, and the document and undo history are unchanged. A live-effect plug-in is an effect with +(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so +`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of +pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`): its output returns +each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the +field, and the document and undo history are unchanged. A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and `effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions diff --git a/docs/plugins.md b/docs/plugins.md index fb1c06a6e..05132fc02 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -148,7 +148,7 @@ capability model proposed for PhotoCraft plug-ins imports](#host-imports-api-level-2) and a filter's `inputs` ([reading type, images and symbols](#reading-type-images-and-symbols)). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. `inputs` is not a permission: the objects it asks for are read under `doc.read`, which a filter that lists - `permissions` and `inputs` must list. + `inputs` must list in `permissions`, so installing it asks the user to grant them. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed @@ -243,7 +243,7 @@ still returns `1`. - `inputs` (filters at `api` 2 only): the kinds of object besides paths and compound paths the filter reads, any of `"type"`, `"image"` and `"symbolInstance"`, each at most once (`"inputs": ["type", "image"]`). Left out or `[]`, the filter gets paths and compound paths alone, as before. An unknown, repeated or wrongly typed entry, `inputs` on - another kind, below `api` 2, or with `permissions` that leave out `doc.read`, is refused when the plug-in is + another kind, below `api` 2, or without a `permissions` list that holds `doc.read`, is refused when the plug-in is installed, and so is `api` 2 on hosts from before it. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): @@ -359,12 +359,16 @@ In this example the character and paragraph styles are cut short; a filter gets attributes as the native file stores them (`inline` when a run is an inline graphic), and `paragraphs` holds one paragraph style per paragraph (the paragraphs are split at `\n`). No font data or glyph outlines are given. Type whose runs hold more than 256 KiB of UTF-8 text, or more than 4,096 runs or paragraphs, comes with `truncated: - true` and empty `runs` and `paragraphs`. + true` and empty `runs` and `paragraphs`. The runs and paragraphs of all the type in one input take at most 16 MiB + of JSON: type whose runs and paragraphs don't fit what the type before it in paint order left comes with + `truncated: true` too. - An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or `"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443 pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and - `pixelsOmitted: false`. A larger one, and a linked image (`linked: true`), has `pixelsOmitted: true` and no pixels. - The host never reads a linked image's file for a filter and never gives its path. + `pixelsOmitted: false`. The pixels of all the images in one input take at most 16 MiB in base64. A larger image, one + whose pixels don't fit what the images before it in paint order left, and a linked image (`linked: true`) have + `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. The host never + reads a linked image's file for a filter and never gives its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the document has no symbol of that name. `overrides` is `{}`. @@ -372,8 +376,9 @@ These objects are read only. The output must list each of them exactly once, as was given (numbers compare by value, so `1` and `1.0` are the same). A changed or added field, one left out, an object listed twice or left out, and a new object with one of these types are refused, and the document and undo history stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only -object was left out`. New objects listed after one of them go just above it. Their text and pixels count towards the -input's half of the memory cap, and an echo of them towards the 64 MiB output cap. +object was left out`. The field is named with control and bidirectional characters escaped (`\u{202E}`), cut to 64 +characters. New objects listed after one of them go just above it. Their text and pixels count toward the input's +half of the memory cap, and an echo of them toward the 64 MiB output cap. ### Parameters diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dab0ea86e..812e777ed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1488,7 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs), images with their size, color mode and, when embedded and at most 1 MiB in base64, their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.