//! Commands a filter plug-in calls with `vc_call` ([`vectorcraft_plugins::host`]): which commands a //! plug-in may never call ([`denied`]), the call itself ([`call`]), and the run around the calls //! ([`transaction`]). //! //! A filter whose manifest's `commandAllowlist` names commands runs on the session's thread. Each //! call it makes runs that command on the document the run is for, through [`Session::execute`] with //! its params checked as any request's are, and sees the selection the calls before it left. The //! calls and the filter's output are one undo step, named after the plug-in or its command; a run //! that fails, traps, runs out of budget or is canceled, or makes a call that fails, leaves every //! document, its selection and its history as they were. //! //! A plug-in never calls a command that reads or writes a file or folder, uses the clipboard, //! changes preferences, runs a script, an action or a plug-in (a plug-in command, or an effect a //! plug-in adds), opens, closes or switches documents, or undoes or redoes, whatever its manifest //! lists. A command whose params name a file or folder is refused unless [`NOT_FILES`] says that //! key names part of the document in it, and so is a call whose arguments carry such a key at any //! depth ([`names_file`]); a call runs with no file or folder readable or writable //! ([`execute_confined`]). Every other command is refused too unless //! [`reviewed::ALLOWED`] lists it, so a command added to the registry later is refused until //! someone decides. mod reviewed; use std::sync::Arc; use serde_json::Value; use vectorcraft_plugins::host::{CALL_COMMAND_FAILED, CALL_DENIED_COMMAND, CALL_DISABLED, CALL_UNKNOWN_COMMAND, CallFailure}; use super::CommandSpec; use crate::{EngineError, Result, Session}; /// Command ids a plug-in may never call, by their start: plug-ins, scripts and actions, files, /// documents and links, the clipboard, preferences, the view and the app, and the undo history. const DENIED_PREFIXES: &[&str] = &[ "plugin.", "script.", "action.", "actionSet.", "command.", "prefs.", "help.", "engine.", "file.", "document.", "addon.document.", "library.", "links.", "recovery.", "shortcutSet.", "clipboard.", "view.", "print.", "pdf.", "webExport.", "css.", "assets.", "importMatches.", "edit.undo", "edit.redo", "edit.clearHistory", "edit.copy", "edit.cut", "edit.paste", "edit.newDocument", "edit.colorSettings", "edit.dropSelection", "artboard.copy", "artboard.cut", "text.rescanFonts", "text.fontList", "text.spelling.", "colorTheme.", "magicWand.", "eyedropper.", "layer.pasteRemembersLayers", "type.systemLayout", "type.fractionalWidths", "appearance.setNewArtBasic", "appearance.newArt", "perspective.widget", "transparency.toggleNewMasks", ]; /// Words in a command id that name a file or the clipboard (any case): a command with one of them /// in its id may read or write a file a plug-in can't see. const FILE_WORDS: &[&str] = &[ "export", "import", "save", "load", "open", "file", "folder", "link", "librar", "preset", "recover", "package", "print", "profile", "clipboard", ]; /// Param keys that name a file or a folder. const FILE_KEYS: &[&str] = &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url"]; /// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of /// its art, a setting, an attribute it stores or a result field), not a file. pub(crate) const NOT_FILES: &[(&str, &str)] = &[ ("object.setLiveShape", "path"), ("path.reverse", "paths"), ("object.pathfinder.options", "paths"), ("object.maskImage", "path"), ("text.createInPath", "path"), ("imageTrace.make", "paths"), ("symbol.instance.setAppearance", "path"), ("symbol.instance.selectPart", "path"), ("object.objectsOnPath.attach", "path"), ("attributes.set", "url"), ("svgInteractivity.info", "files"), ("addon.cleanup.rejoin", "paths"), ]; /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], /// [`FILE_WORDS`])? fn denied_id(id: &str) -> bool { let lower = id.to_ascii_lowercase(); DENIED_PREFIXES.iter().any(|p| id.starts_with(p)) || FILE_WORDS.iter().any(|w| lower.contains(w)) } /// Why a plug-in may never call `spec`, or `None` when it may (when its manifest lists it): its id /// or a file key ([`denied_by_rule`]), [`reviewed::DENIED`], or no place in [`reviewed::ALLOWED`]. pub(crate) fn denied(spec: &CommandSpec) -> Option<&'static str> { if let Some(why) = denied_by_rule(spec) { return Some(why); } if reviewed::DENIED.binary_search(&spec.id).is_ok() { return Some("reviewed"); } if reviewed::ALLOWED.binary_search(&spec.id).is_err() { return Some("not reviewed"); } None } /// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a key in /// [`FILE_KEYS`] that [`NOT_FILES`] doesn't name for it), or `None`. fn denied_by_rule(spec: &CommandSpec) -> Option<&'static str> { let id = spec.id; if denied_id(id) { return Some("id"); } let mut keys = FILE_KEYS.iter().filter(|k| !NOT_FILES.contains(&(id, **k))); let in_schema = |k: &str| spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == k)); if keys.any(|k| names_key(spec.params, k) || in_schema(k)) { return Some("file key"); } None } /// The ids `m`'s `commandAllowlist` names that no command has (a later version may add them; a /// call of one fails), and those a plug-in may never call ([`denied`]). pub(crate) fn check_allowlist(m: &vectorcraft_plugins::Manifest) -> (Vec<&str>, Vec<&str>) { let (mut unknown, mut refused) = (Vec::new(), Vec::new()); for id in &m.command_allowlist { match super::find_command(id) { Some(spec) if denied(spec).is_some() => refused.push(id.as_str()), Some(_) => {} None if denied_id(id) => refused.push(id.as_str()), None => unknown.push(id.as_str()), } } (unknown, refused) } /// Does params text `text` name the key `key`: `key:` or `key?:`, not as the end of a longer word? pub(crate) fn names_key(text: &str, key: &str) -> bool { text.match_indices(key).any(|(at, _)| { let before = text[..at].chars().next_back(); let after = &text[at + key.len()..]; before.is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) && (after.starts_with(':') || after.starts_with("?:")) }) } /// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth /// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`] /// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep /// arguments can't overflow the thread's. fn names_file(id: &str, args: &Value) -> bool { let mut stack = vec![(args, true)]; while let Some((v, top)) = stack.pop() { match v { Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))), Value::Object(o) => { for (k, x) in o { let k = k.as_str(); if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) { return true; } stack.push((x, false)); } } _ => {} } } false } /// Param keys whose value names an effect or a command (`id` is `effect.apply`'s other name for /// `effect`). const PLUGIN_KEYS: &[&str] = &["effect", "effects", "id", "command", "commands"]; /// Does `v` name a plug-in anywhere: an effect or a command ([`PLUGIN_KEYS`]) whose id starts with /// `plugin.`, at any depth? A call that does is refused, so a plug-in never runs another, or /// itself. Other strings, such as text, may start with `plugin.`. fn names_plugin(v: &Value) -> bool { names_plugin_in(v, false) } /// [`names_plugin`] for `v`, the value of a key in [`PLUGIN_KEYS`] (or an item of one) when /// `named`. fn names_plugin_in(v: &Value, named: bool) -> bool { match v { Value::String(s) => named && s.starts_with(vectorcraft_plugins::PREFIX), Value::Array(a) => a.iter().any(|x| names_plugin_in(x, named)), Value::Object(o) => o.iter().any(|(k, x)| names_plugin_in(x, PLUGIN_KEYS.contains(&k.as_str()))), _ => false, } } /// Commits an edit in progress in the active document (not a preview's), as [`transaction`] does → /// whether that changed the document or its selection. A run that calls commands is prepared again /// then, so its input is the document its calls change. pub(crate) fn settle(s: &mut Session) -> Result { if s.previewing { return Ok(false); } let Some(before) = s.active().map(|d| (d.doc.clone(), d.selection.clone())) else { return Ok(false) }; super::edit::settle_interaction(s)?; Ok(s.active().is_none_or(|d| !Arc::ptr_eq(&before.0, &d.doc) || before.1 != d.selection)) } /// What a run that makes command calls keeps fixed: the document it runs in, the documents open, /// whether an interaction holds its changes (its own, or a preview's), and whether the plug-in may /// read and change the document. pub(crate) struct Calls { document: Option, open: Vec, interaction: bool, may_read: bool, may_write: bool, } impl Calls { /// The run's fixed state in `s` as it is now; `may_read` and `may_write` are whether it has /// `doc.read` and `doc.write`. pub(crate) fn of(s: &Session, may_read: bool, may_write: bool) -> Calls { let (document, open) = (s.active().map(|d| d.uid), s.docs.iter().map(|d| d.uid).collect()); Calls { document, open, interaction: s.in_interaction(), may_read, may_write } } /// Is `s` still as the run needs it: the same documents open, the same one active and the /// interaction holding the run's changes still open? fn kept(&self, s: &Session) -> bool { s.active().map(|d| d.uid) == self.document && s.docs.iter().map(|d| d.uid).eq(self.open.iter().copied()) && (!self.interaction || s.in_interaction()) } } /// Makes plug-in call `id` with `args` in `s` → its result, or why it didn't run or failed. A /// command a plug-in may never call ([`denied`]), one whose args name a plug-in, one that changes /// which documents are open or active or ends the run's edit, and one that changes the document for /// a plug-in without `doc.write` are refused, and so is every call of a plug-in without `doc.read`: /// a command's result describes the document. pub(crate) fn call(s: &mut Session, run: &Calls, id: &str, args: &Value) -> std::result::Result { let refused = || CallFailure { code: CALL_DENIED_COMMAND, why: EngineError::Other("a plug-in may not call this command".into()).to_string() }; if !run.may_read { return Err(refused()); } let Some(spec) = super::find_command(id) else { return Err(CallFailure { code: CALL_UNKNOWN_COMMAND, why: super::params::unknown_command(id).to_string() }); }; if denied(spec).is_some() || names_plugin(args) || names_file(id, args) { return Err(refused()); } let before = s.active().map(|d| d.doc.clone()); let r = execute_confined(s, id, args); let value = match r { Ok(v) => v, Err(e @ EngineError::Disabled(..)) => return Err(CallFailure { code: CALL_DISABLED, why: e.to_string() }), Err(e) => return Err(CallFailure { code: CALL_COMMAND_FAILED, why: e.to_string() }), }; let changed = before.is_some_and(|b| s.active().is_some_and(|d| !Arc::ptr_eq(&b, &d.doc))); if !run.kept(s) || (changed && !run.may_write) { return Err(refused()); } Ok(value) } /// Runs command `id` with `args` in `s` with no file or folder readable or writable: every path the /// command reaches through the checked file access ([`crate::file_access`]) is refused, whatever /// param carries it. fn execute_confined(s: &mut Session, id: &str, args: &Value) -> Result { crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || s.execute(id, args)) } /// Runs `f`, a plug-in run that makes command calls, as one undo step named `label` in the active /// document: the commands it calls and the output it applies change the document inside one /// interaction, which is kept when `f` succeeds. When `f` fails, every document goes back to how it /// was (its art, selection, interaction and history), and so does the session state the commands a /// plug-in may call change ([`Saved`]). During a preview the preview's own interaction holds the /// changes. A trace handed in for the next Image Trace is set aside while `f` runs, so a call /// traces anew, and handed back after; the next Image Trace still checks it was made of its inputs. pub(crate) fn transaction(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { if s.active().is_some() && !s.previewing { super::edit::settle_interaction(s)?; } let own = s.active().is_some() && !s.previewing && !s.in_interaction(); if own { s.begin_interaction(label)?; } let saved = Saved::of(s); let start = s.docs.clone(); let active = s.active; let trace = s.trace_hand_off.take(); let r = f(s); s.trace_hand_off = trace; match r { Ok(v) => { if own && let Some(st) = s.active_mut() && let Some(it) = st.interaction.take() { st.keep_interaction(it); } Ok(v) } Err(e) => { let open = std::mem::replace(&mut s.docs, start); for st in &mut s.docs { // Past the newest revision the document reached, so the restored one redraws. let newest = open.iter().filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision); st.revision = newest.max(st.revision).saturating_add(1); crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection); } s.active = active; if own && let Some(st) = s.active_mut() { st.interaction = None; } saved.restore(s); Err(e) } } } /// The session state outside the documents that a command a plug-in may call changes: the paint /// defaults and the colors it remembers, the drawing mode, the clipboard, the undo group, the /// preferences and menu checks, the selected gradient stop, mesh point and Appearance item, and the /// options a command keeps for its next run. [`transaction`] puts it back when a run fails. struct Saved { paint: crate::PaintDefaults, fill_active: bool, draw_mode: crate::DrawMode, draw_inside: Option, clipboard: crate::Clipboard, untitled_counter: u32, explicit_undo_group: Option>, prefs: crate::Prefs, menu: super::menucmds::MenuState, gradient_stop: Option<(usize, super::gradient::StopOwner)>, freeform_point: Option<(usize, super::gradient::StopOwner)>, active_appearance_item: Option, eyedropper_sample: Option>, last_solid: vectorcraft_color::Color, last_gradient: vectorcraft_color::GradientPaint, recent_colors: Vec, recent_urls: Vec, global_edit_options: super::globaledit::Options, envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, } impl Saved { fn of(s: &Session) -> Saved { Saved { paint: s.paint.clone(), fill_active: s.fill_active, draw_mode: s.draw_mode, draw_inside: s.draw_inside, clipboard: s.clipboard.clone(), untitled_counter: s.untitled_counter, explicit_undo_group: s.explicit_undo_group.clone(), prefs: s.prefs.clone(), menu: s.menu.clone(), gradient_stop: s.gradient_stop, freeform_point: s.freeform_point, active_appearance_item: s.active_appearance_item.clone(), eyedropper_sample: s.eyedropper_sample.clone(), last_solid: s.last_solid, last_gradient: s.last_gradient.clone(), recent_colors: s.recent_colors.clone(), recent_urls: s.recent_urls.clone(), global_edit_options: s.global_edit_options.clone(), envelope_defaults: s.envelope_defaults, } } fn restore(self, s: &mut Session) { s.paint = self.paint; s.fill_active = self.fill_active; s.draw_mode = self.draw_mode; s.draw_inside = self.draw_inside; s.clipboard = self.clipboard; s.untitled_counter = self.untitled_counter; s.explicit_undo_group = self.explicit_undo_group; s.prefs = self.prefs; s.menu = self.menu; s.gradient_stop = self.gradient_stop; s.freeform_point = self.freeform_point; s.active_appearance_item = self.active_appearance_item; s.eyedropper_sample = self.eyedropper_sample; s.last_solid = self.last_solid; s.last_gradient = self.last_gradient; s.recent_colors = self.recent_colors; s.recent_urls = self.recent_urls; s.global_edit_options = self.global_edit_options; s.envelope_defaults = self.envelope_defaults; s.pending_paint = None; } } #[cfg(test)] mod tests { use super::*; /// Every command in the registry is sorted: the ones a plug-in may call name no file, folder, /// clipboard, preference or script in their params text, and the known file, document, script /// and history commands are refused. #[test] fn every_command_is_sorted() { let specs = crate::cmd::command_specs(); for id in [ "document.save", "file.revert", "document.serialize", "links.update", "graphicStyle.libraries", "symbol.libraries", "brush.libraries", "text.findFontFiles", "plugin.run", "plugin.install", "command.batch", "edit.undo", "edit.redo", "edit.copy", "edit.paste", "prefs.set", ] { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; assert!(denied(spec).is_some(), "{id} is allowed"); } for id in ["select.all", "object.transform", "path.reverse", "symbol.instance.selectPart"] { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; assert_eq!(denied(spec), None, "{id} is refused"); } for (id, key) in NOT_FILES { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; assert!(names_key(spec.params, key), "{id} has no `{key}` param"); } let leaks: Vec<&str> = specs .iter() .filter(|c| denied(c).is_none()) .filter(|c| { let lower = c.id.to_ascii_lowercase(); FILE_WORDS.iter().any(|w| lower.contains(w)) || FILE_KEYS.iter().any(|k| names_key(c.params, k) && !NOT_FILES.contains(&(c.id, *k))) || names_key(c.params, "sourceDocument") }) .map(|c| c.id) .collect(); assert!(leaks.is_empty(), "allowed commands that name a file or another document: {leaks:?}"); let allowed = specs.iter().filter(|c| denied(c).is_none()).count(); assert!(allowed > 0 && allowed < specs.len()); } /// Every command no rule refuses is in exactly one of [`reviewed::ALLOWED`] and /// [`reviewed::DENIED`], and they name only such commands, in order: a command added to the /// registry fails here until someone puts it in one. #[test] fn every_command_is_reviewed() { for list in [reviewed::ALLOWED, reviewed::DENIED] { assert!(list.windows(2).all(|w| w[0] < w[1]), "a reviewed list is out of order or names a command twice"); } let (allowed, refused) = (|id: &str| reviewed::ALLOWED.binary_search(&id).is_ok(), |id: &str| reviewed::DENIED.binary_search(&id).is_ok()); let unreviewed: Vec<&str> = crate::cmd::command_specs().iter().filter(|c| denied_by_rule(c).is_none() && allowed(c.id) == refused(c.id)).map(|c| c.id).collect(); assert!(unreviewed.is_empty(), "commands in neither or both of reviewed::ALLOWED and reviewed::DENIED: {unreviewed:?}"); let stale: Vec<&str> = reviewed::ALLOWED .iter() .chain(reviewed::DENIED) .copied() .filter(|id| crate::cmd::find_command(id).is_none_or(|c| denied_by_rule(c).is_some())) .collect(); assert!(stale.is_empty(), "reviewed commands that no command has or a rule refuses: {stale:?}"); } /// Only an effect or a command naming a plug-in counts, at any depth; text that happens to /// start with `plugin.` doesn't. #[test] fn a_plugin_is_named_only_as_an_effect_or_a_command() { use serde_json::json; assert!(names_plugin(&json!({"effect": "plugin.test.fx"}))); assert!(names_plugin(&json!({"id": "plugin.test.fx"}))); assert!(names_plugin(&json!({"effects": ["builtin.blur", "plugin.test.fx"]}))); assert!(names_plugin(&json!({"items": [{"effect": "plugin.test.fx", "params": {}}]}))); assert!(names_plugin(&json!({"command": "plugin.test.run"}))); assert!(!names_plugin(&json!({"text": "plugin.example"}))); assert!(!names_plugin(&json!({"name": "plugin.style", "effect": "drop-shadow"}))); assert!(!names_plugin(&json!({"effect": {"name": "plugin.style"}}))); } /// A file or folder key counts at any depth, in objects and in arrays; a key [`NOT_FILES`] names /// for the command only at the top level; ordinary nested params don't count. #[test] fn a_file_key_counts_at_any_depth() { use serde_json::json; let texture = json!({"effect": "distort.glass", "params": {"textureImage": {"path": "/tmp/t.png"}}}); assert!(names_file("effect.apply", &texture)); assert!(names_file("effect.setParams", &texture)); assert!(names_file("effect.apply", &json!({"params": {"layers": [{"name": "a"}, {"folder": "/tmp"}]}}))); assert!(names_file("select.all", &json!([[[{"url": "file:///etc/hosts"}]]]))); assert!(!names_file("effect.apply", &json!({"effect": "distort.glass", "params": {"distortion": 5, "textureImage": "frosted"}}))); assert!(!names_file("object.move", &json!({"dx": 5, "dy": 0, "ids": [1, 2], "opts": {"copy": false}}))); // `path` is a shape's path in object.setLiveShape, but only as its own param. assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]}))); assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}}))); assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); // Deep nesting is walked without recursion. let mut deep = json!({"file": "/tmp/x"}); for _ in 0..10_000 { deep = Value::Array(vec![deep]); } assert!(names_file("select.all", &deep)); std::mem::forget(deep); } /// A call reads no file even when a param the checks above don't know carries its path: the /// command runs with no file readable. Run as an ordinary request, the same texture loads. #[cfg(not(target_arch = "wasm32"))] #[test] fn a_confined_call_reads_no_file() { use serde_json::json; let file = std::env::temp_dir().join(format!("vc-plugin-call-texture-{}.png", std::process::id())); let png = vectorcraft_trace::Raster::from_fn(8, 8, |x, y| if (x + y) % 2 == 0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }).encode_png(); std::fs::write(&file, png).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); let p = json!({"effect": "distort.glass", "params": {"textureImage": {"path": file.to_string_lossy()}}}); let e = execute_confined(&mut s, "effect.apply", &p).map(|_| ()).unwrap_err().to_string(); let read = s.execute("effect.apply", &p).map(|_| ()); let _ = std::fs::remove_file(&file); assert!(e.contains("read authority is absent"), "{e}"); read.unwrap(); } #[test] fn keys_are_found_as_whole_keys() { assert!(names_key("{path: a .wasm file}", "path")); assert!(names_key("{ids?: [id], path?: string}", "path")); assert!(!names_key("{subpath: 1}", "path")); assert!(!names_key("{x} on the path: the art", "the")); assert!(!names_key("{pathData: 1}", "path")); assert!(names_key("path: x", "path")); } }