Merge pull request #6462 from dotCooCoo/feat/plugin-call-commands

Plug-ins: an API level 2 plug-in calls the commands its manifest lists
This commit is contained in:
BFlat
2026-11-11 04:37:37 +07:00
committed by GitHub
46 changed files with 2774 additions and 44 deletions
+1
View File
@@ -70,6 +70,7 @@ pub mod pdfcmds;
pub mod perspgrid;
pub mod place;
pub mod plugin;
pub(crate) mod plugin_call;
pub mod prefscmds;
pub mod print;
pub(crate) mod printadvanced;
+78 -7
View File
@@ -88,6 +88,9 @@ pub fn describe(p: &Plugin) -> Value {
if m.paint_output {
v["paintOutput"] = json!(true);
}
if !m.command_allowlist.is_empty() {
v["commandAllowlist"] = json!(m.command_allowlist);
}
if !m.commands.is_empty() {
let commands: Vec<Value> = m
.commands
@@ -258,6 +261,37 @@ impl FilterRun {
self.plugin.run(&self.input, &self.params).map_err(err)
}
/// Does the plug-in call commands (its manifest's `commandAllowlist`)? Then it runs only in the
/// session ([`FilterRun::run_with_calls`]).
pub fn calls(&self) -> bool {
!self.plugin.manifest().command_allowlist.is_empty()
}
/// Runs the plug-in in `s`, making the command calls it asks for on the active document
/// ([`super::plugin_call::call`]) → its output.
fn run_calling(&self, s: &mut Session) -> Result<Vec<u8>> {
let may = self.plugin.manifest().permissions();
let calls = super::plugin_call::Calls::of(s, may.contains(&Permission::DocRead), may.contains(&Permission::DocWrite));
let mut bridge = |id: &str, args: &Value| super::plugin_call::call(s, &calls, id, args);
self.plugin.run_calling(&self.input, &self.params, self.plugin.limits(), &mut bridge).0.map_err(err)
}
/// Runs a plug-in that calls commands and applies its output: the calls and the output are one
/// undo step, and a run that fails changes nothing ([`super::plugin_call::transaction`]).
fn run_with_calls(self, s: &mut Session) -> Result<Value> {
let label = self.label();
super::plugin_call::transaction(s, &label, |s| {
let output = self.run_calling(s)?;
self.finish(s, &output)
})
}
/// The name of its undo step: the plug-in's, or its command's.
fn label(&self) -> String {
let m = self.plugin.manifest();
self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label)
}
/// Hands in `output`, which [`FilterRun::run`] returned: the next `plugin.run` that makes this
/// same run (same plug-in, `ids` and parameters, the document unchanged) applies it instead of
/// running the plug-in. Any other run drops it.
@@ -277,9 +311,9 @@ impl FilterRun {
return Err(not_granted(&m.name, Permission::State));
}
let may_write = may.contains(&Permission::DocWrite);
let (inputs, parent) = (self.inputs, self.parent);
// A command's undo step is named after the command.
let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label);
let label = self.label();
let (inputs, parent) = (self.inputs, self.parent);
let ids = s.edit(&label, |d, sel| {
let before = (!may_write).then(|| d.layers.clone());
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
@@ -316,6 +350,12 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
// An output handed in is for this run only (the app hands it in just before), whatever it does.
let handed = s.plugin_output.take();
let r = FilterRun::prepare(s, p)?;
// One that calls commands runs here, where it can make them, on the document with any edit in
// progress committed.
if r.calls() {
let r = if super::plugin_call::settle(s)? { FilterRun::prepare(s, p)? } else { r };
return r.run_with_calls(s);
}
// Handed in for this very run (the app ran it in the background), it is what running the
// plug-in again would return.
let handed = handed.filter(|h| (h.serial, &h.input, &h.params) == (r.plugin.serial(), &r.input, &r.params));
@@ -463,6 +503,16 @@ pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value>
}
};
let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?;
if r.calls() {
let r = match super::plugin_call::settle(s)? {
true => {
let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
FilterRun::prepare_with(s, plugin, Some(command), id, p)?
}
false => r,
};
return r.run_with_calls(s).map_err(failed);
}
let output = r.run().map_err(&failed)?;
r.finish(s, &output).map_err(failed)
}
@@ -654,6 +704,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
"`grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app",
));
}
// A plug-in never calls a command no plug-in may call, whatever its manifest lists.
let (unknown, refused) = super::plugin_call::check_allowlist(plugin.manifest());
if let Some(id) = refused.first() {
let (name, id) = (
permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME),
permissions::display_text(id, permissions::MAX_SHOWN_FIELD),
);
return Err(bad(C, format!("“{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call")));
}
let unknown: Vec<String> = unknown.into_iter().map(str::to_string).collect();
// A heavy plug-in is installed once a person agrees.
if plugin.manifest().budget == Budget::Heavy && !allow_heavy {
let (minutes, times) = vectorcraft_plugins::heavy_terms();
@@ -726,7 +786,11 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
if changed {
s.store_disabled_plugins();
}
Ok(describe_installed(&registry::install(plugin)))
let mut v = describe_installed(&registry::install(plugin));
if !unknown.is_empty() {
v["unknownCommands"] = json!(unknown);
}
Ok(v)
}
/// `plugin.inspect`: what installing the module would install, without installing it, whether that
@@ -737,6 +801,13 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let mut v = describe(&plugin);
v["needsApproval"] = json!(!s.plugin_approved(&plugin));
v["needsReplace"] = json!(takes_over(s, &plugin));
let (unknown, refused) = super::plugin_call::check_allowlist(plugin.manifest());
if !unknown.is_empty() {
v["unknownCommands"] = json!(unknown);
}
if !refused.is_empty() {
v["refusedCommands"] = json!(refused);
}
v
})
}
@@ -971,7 +1042,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
always,
list
),
@@ -989,7 +1060,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, 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}",
"{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). A filter with a commandAllowlist runs the commands it names while it runs (vc_call, see docs/plugins.md): its calls and its output are one undo step, and a run that fails or makes a call that fails changes nothing → {plugin, ids}",
has_doc,
run
),
@@ -998,7 +1069,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Install Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session (a module whose commandAllowlist names a command no plug-in may call, such as one that reads or writes files, is refused) → its plugin.list entry, plus unknownCommands?: [id] (commandAllowlist ids no command has; a call of one fails)",
always,
install
),
@@ -1007,7 +1078,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Inspect Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions no person has granted this very module, its bytes included: it then needs a person's approval, see plugin.install) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions no person has granted this very module, its bytes included: it then needs a person's approval, see plugin.install) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true), unknownCommands?: [id] (commandAllowlist ids no command has) and refusedCommands?: [id] (commandAllowlist ids no plug-in may call: plugin.install refuses the module)",
always,
inspect
),
+577
View File
@@ -0,0 +1,577 @@
//! 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<bool> {
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<u64>,
open: Vec<u64>,
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<Value, CallFailure> {
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<Value> {
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<Value>) -> Result<Value> {
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<vectorcraft_doc::NodeId>,
clipboard: crate::Clipboard,
untitled_counter: u32,
explicit_undo_group: Option<Option<String>>,
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<super::appearance::ActiveItem>,
eyedropper_sample: Option<Arc<vectorcraft_doc::Node>>,
last_solid: vectorcraft_color::Color,
last_gradient: vectorcraft_color::GradientPaint,
recent_colors: Vec<vectorcraft_color::Color>,
recent_urls: Vec<String>,
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"));
}
}
@@ -0,0 +1,675 @@
//! The commands no rule in [`super::denied`] refuses, each placed in one list: those a plug-in may
//! call ([`ALLOWED`]) and those it may not ([`DENIED`]). A command in neither is refused, and the
//! test `every_command_is_reviewed` fails until it is placed.
/// Commands a plug-in may call when its manifest lists them, in order.
pub(super) const ALLOWED: &[&str] = &[
"addon.arrange.grid",
"addon.arrange.groupOverlapping",
"addon.arrange.randomizeOpacity",
"addon.arrange.replace",
"addon.arrange.size",
"addon.arrange.sort",
"addon.arrange.swap",
"addon.cleanup.alignInPlace",
"addon.cleanup.breakText",
"addon.cleanup.check",
"addon.cleanup.combineText",
"addon.cleanup.deepUngroup",
"addon.cleanup.fix",
"addon.cleanup.joinText",
"addon.cleanup.rejoin",
"addon.cleanup.removeBreaks",
"addon.cleanup.replaceFonts",
"addon.cleanup.resetTextTransform",
"addon.cleanup.show",
"addon.cleanup.simplify",
"addon.color.applyNearestSwatch",
"addon.color.fillToStroke",
"addon.color.filter",
"addon.color.nearestSwatch",
"addon.color.select",
"addon.color.strokeToFill",
"addon.color.swatchLegend",
"addon.contour.set",
"addon.pathtools.addPoints",
"addon.pathtools.close",
"addon.pathtools.corner",
"addon.pathtools.cornerRemove",
"addon.pathtools.cornerSet",
"addon.pathtools.crossings",
"addon.pathtools.divideDashes",
"addon.pathtools.equalize",
"addon.pathtools.erase",
"addon.pathtools.expandDashes",
"addon.pathtools.extend",
"addon.pathtools.inspect",
"addon.pathtools.mergeEdge",
"addon.pathtools.removeKeepShape",
"addon.pathtools.setAnchor",
"addon.pathtools.shape.convert",
"addon.pathtools.shape.create",
"addon.pathtools.shape.set",
"addon.pathtools.slideAnchor",
"addon.pathtools.smooth",
"addon.pen.append",
"addon.pen.close",
"addon.pen.closeJoin",
"addon.pen.removePoint",
"addon.pen.reshape",
"addon.pen.segment",
"addon.pen.togglePoint",
"addon.select.byArtboards",
"addon.shapes.box3d",
"addon.shapes.configure",
"addon.shapes.ellipse",
"addon.shapes.polygon",
"addon.shapes.rectangle",
"addon.shapes.spiral",
"addon.shapes.star",
"addon.type.releasePath",
"addon.type.textTweaker",
"appearance.addEffect",
"appearance.addFill",
"appearance.addStroke",
"appearance.clear",
"appearance.copyFrom",
"appearance.duplicateItem",
"appearance.moveItem",
"appearance.reduceToBasic",
"appearance.removeItem",
"appearance.setActiveItem",
"appearance.setItem",
"appearance.showAllHidden",
"appearance.targetContents",
"appearance.transfer",
"artboard.convertToArtboards",
"artboard.delete",
"artboard.deleteEmpty",
"artboard.duplicate",
"artboard.fitToArt",
"artboard.fitToSelection",
"artboard.move",
"artboard.new",
"artboard.rearrange",
"artboard.rename",
"artboard.reorder",
"artboard.setProps",
"artboard.switchOrientation",
"attributes.info",
"attributes.set",
"brush.apply",
"brush.delete",
"brush.duplicate",
"brush.freehand",
"brush.get",
"brush.list",
"brush.move",
"brush.new",
"brush.options",
"brush.remove",
"brush.selectionArt",
"brush.setCurrent",
"brush.unused",
"charStyle.apply",
"charStyle.delete",
"charStyle.duplicate",
"charStyle.edit",
"charStyle.list",
"charStyle.new",
"charStyle.redefine",
"charStyle.rename",
"charStyle.setAttrs",
"charStyle.unused",
"color.convert",
"color.gamutCheck",
"color.harmony",
"color.plates",
"dataset.capture",
"dataset.delete",
"dataset.list",
"dataset.new",
"dataset.next",
"dataset.prev",
"dataset.rename",
"dataset.select",
"dataset.set",
"dataset.update",
"dimension.applyToAll",
"dimension.create",
"dimension.update",
"edit.clear",
"edit.colors.adjustBalance",
"edit.colors.blendFrontToBack",
"edit.colors.blendHorizontally",
"edit.colors.blendVertically",
"edit.colors.invert",
"edit.colors.saturate",
"edit.colors.toCMYK",
"edit.colors.toGrayscale",
"edit.colors.toRGB",
"edit.duplicate",
"edit.findNext",
"edit.findReplace",
"effect.apply",
"effect.duplicate",
"effect.expandAppearance",
"effect.list",
"effect.move",
"effect.remove",
"effect.setParams",
"flattener.preview",
"gradient.selectStop",
"graph.column",
"graph.create",
"graph.design",
"graph.marker",
"graph.setData",
"graph.setType",
"graphicStyle.apply",
"graphicStyle.delete",
"graphicStyle.duplicate",
"graphicStyle.list",
"graphicStyle.merge",
"graphicStyle.move",
"graphicStyle.new",
"graphicStyle.redefine",
"graphicStyle.rename",
"graphicStyle.sortByName",
"graphicStyle.unused",
"guide.add",
"guide.list",
"guide.move",
"guide.remove",
"guide.select",
"guide.setLayer",
"image.info",
"imageTrace.expand",
"imageTrace.make",
"imageTrace.makeAndExpand",
"imageTrace.release",
"imageTrace.setView",
"layer.clippingMask.toggle",
"layer.collectInNew",
"layer.delete",
"layer.duplicate",
"layer.flatten",
"layer.hideOthers",
"layer.highlight",
"layer.locate",
"layer.lockOthers",
"layer.merge",
"layer.move",
"layer.new",
"layer.newSublayer",
"layer.outlineOthers",
"layer.previewAll",
"layer.releaseToLayers",
"layer.releaseToLayersBuild",
"layer.reverse",
"layer.selectAll",
"layer.setCurrent",
"layer.setProps",
"layer.showAll",
"layer.target",
"layer.template",
"layer.unlockAll",
"livePaint.expand",
"livePaint.fill",
"livePaint.gapOptions",
"livePaint.info",
"livePaint.make",
"livePaint.merge",
"livePaint.release",
"livePaint.strokeEdge",
"node.move",
"object.align",
"object.arrange.bringForward",
"object.arrange.bringToFront",
"object.arrange.sendBackward",
"object.arrange.sendToBack",
"object.arrange.sendToCurrentLayer",
"object.blend.expand",
"object.blend.info",
"object.blend.make",
"object.blend.release",
"object.blend.replaceSpine",
"object.blend.reverseFrontToBack",
"object.blend.reverseSpine",
"object.blend.spine.addAnchor",
"object.blend.spine.convertAnchor",
"object.blend.spine.moveAnchor",
"object.blend.spine.removeAnchor",
"object.blend.spine.reshapeSegment",
"object.clippingMask.editContents",
"object.clippingMask.editMask",
"object.clippingMask.make",
"object.clippingMask.release",
"object.compoundPath.make",
"object.compoundPath.release",
"object.compoundShape.expand",
"object.compoundShape.make",
"object.compoundShape.release",
"object.compoundShape.setMode",
"object.convertDocumentColorMode",
"object.createObjectMosaic",
"object.createTrimMarks",
"object.cropImage",
"object.distort",
"object.distribute",
"object.distributeSpacing",
"object.envelope.editContents",
"object.envelope.expand",
"object.envelope.info",
"object.envelope.makeWithMesh",
"object.envelope.makeWithTopObject",
"object.envelope.makeWithWarp",
"object.envelope.options",
"object.envelope.release",
"object.envelope.resetWithMesh",
"object.envelope.resetWithWarp",
"object.envelope.setMeshPoint",
"object.exitIsolation",
"object.expand",
"object.expand.info",
"object.expandBrush",
"object.expandShape",
"object.flattenTransparency",
"object.group",
"object.hide",
"object.hide.above",
"object.hide.otherLayers",
"object.hide.unselected",
"object.intertwine.arrange",
"object.intertwine.edit",
"object.intertwine.make",
"object.intertwine.release",
"object.isolate",
"object.lock",
"object.lock.above",
"object.lock.otherLayers",
"object.lock.unselected",
"object.makePixelPerfect",
"object.maskImage",
"object.mesh.addLine",
"object.mesh.create",
"object.mesh.deletePoint",
"object.mesh.expand",
"object.mesh.movePoint",
"object.mesh.movePoints",
"object.mesh.setPointColor",
"object.move",
"object.nudge",
"object.objectsOnPath.attach",
"object.objectsOnPath.expand",
"object.objectsOnPath.options",
"object.path.addAnchorPoints",
"object.path.cleanUp",
"object.path.divideObjectsBelow",
"object.path.offsetPath",
"object.path.outlineStroke",
"object.path.simplify",
"object.path.smooth",
"object.path.splitIntoGrid",
"object.pathfinder.crop",
"object.pathfinder.divide",
"object.pathfinder.exclude",
"object.pathfinder.intersect",
"object.pathfinder.merge",
"object.pathfinder.minusBack",
"object.pathfinder.minusFront",
"object.pathfinder.outline",
"object.pathfinder.trap",
"object.pathfinder.trim",
"object.pathfinder.unite",
"object.pattern.cancel",
"object.pattern.done",
"object.pattern.edit",
"object.pattern.make",
"object.puppetWarp",
"object.puppetWarp.pins",
"object.rasterize",
"object.reflect",
"object.repeat.expand",
"object.repeat.grid",
"object.repeat.mirror",
"object.repeat.options",
"object.repeat.radial",
"object.repeat.release",
"object.resetBoundingBox",
"object.rotate",
"object.scale",
"object.setBounds",
"object.setLiveShape",
"object.setPixelAligned",
"object.setProps",
"object.shape.convertToShape",
"object.shear",
"object.showAll",
"object.slice.clipToArtboard",
"object.slice.combine",
"object.slice.create",
"object.slice.delete",
"object.slice.deleteAll",
"object.slice.divide",
"object.slice.duplicate",
"object.slice.fromGuides",
"object.slice.fromSelection",
"object.slice.make",
"object.slice.move",
"object.slice.options",
"object.slice.release",
"object.slice.select",
"object.slice.setRect",
"object.textWrap.make",
"object.textWrap.options",
"object.textWrap.release",
"object.transform",
"object.transformAgain",
"object.transformEach",
"object.ungroup",
"object.unlockAll",
"object.vectorHalftone",
"paint.complement",
"paint.default",
"paint.editGradient",
"paint.freeform.addLine",
"paint.freeform.addPoint",
"paint.freeform.deletePoint",
"paint.freeform.get",
"paint.freeform.selectPoint",
"paint.freeform.setPoint",
"paint.freeform.splitLine",
"paint.invert",
"paint.lastColor",
"paint.lastGradient",
"paint.none",
"paint.proxies",
"paint.recent",
"paint.sampleColor",
"paint.setFill",
"paint.setGradientGeom",
"paint.setStroke",
"paint.swap",
"paint.toggleActive",
"paraStyle.apply",
"paraStyle.delete",
"paraStyle.duplicate",
"paraStyle.list",
"paraStyle.new",
"paraStyle.redefine",
"paraStyle.rename",
"paraStyle.setAttrs",
"paraStyle.unused",
"path.appendAnchor",
"path.average",
"path.averageJoin",
"path.blob",
"path.close",
"path.convertAnchor",
"path.convertAnchors",
"path.create",
"path.curvature",
"path.curvatureEdit",
"path.cutAtAnchors",
"path.deleteAnchors",
"path.eraseRegion",
"path.eraseSegments",
"path.freehand",
"path.insertAnchor",
"path.join",
"path.joinScrub",
"path.knife",
"path.lineCut",
"path.mirrorCut",
"path.moveAnchor",
"path.moveAnchors",
"path.rectCut",
"path.removeAnchor",
"path.removeAnchors",
"path.reshape",
"path.reshapeSegment",
"path.reverse",
"path.setAnchors",
"path.setFillRule",
"path.setHandle",
"path.smoothRegion",
"path.split",
"pattern.delete",
"pattern.list",
"pattern.options",
"pattern.transform",
"perspective.attach",
"perspective.draw",
"perspective.editText",
"perspective.grid.define",
"perspective.grid.get",
"perspective.grid.lock",
"perspective.grid.lockStation",
"perspective.grid.rulers",
"perspective.grid.set",
"perspective.grid.show",
"perspective.grid.snap",
"perspective.move",
"perspective.nudge",
"perspective.plane.matchObject",
"perspective.plane.move",
"perspective.plane.set",
"perspective.release",
"perspective.transform",
"recolor.apply",
"recolor.colors",
"recolor.randomize",
"recolor.reduce",
"select.add",
"select.all",
"select.allOnArtboard",
"select.anchors",
"select.anchorsMany",
"select.color",
"select.font",
"select.globalEdit",
"select.globalEditExclude",
"select.globalEditOptions",
"select.inverse",
"select.key",
"select.magicWand",
"select.nextAbove",
"select.nextBelow",
"select.none",
"select.object.allOnSameLayers",
"select.object.areaText",
"select.object.bristleBrushStrokes",
"select.object.brushStrokes",
"select.object.clippingMasks",
"select.object.directionHandles",
"select.object.pointText",
"select.object.slices",
"select.object.strayPoints",
"select.object.textObjects",
"select.recall",
"select.reselect",
"select.same.appearance",
"select.same.appearanceAttribute",
"select.same.blendingMode",
"select.same.fillAndStroke",
"select.same.fillColor",
"select.same.fontFamily",
"select.same.fontFamilyStyle",
"select.same.fontFamilyStyleSize",
"select.same.fontSize",
"select.same.graphicStyle",
"select.same.opacity",
"select.same.shapeType",
"select.same.strokeColor",
"select.same.strokeWeight",
"select.same.symbolInstance",
"select.same.textFillAndStrokeColor",
"select.same.textFillColor",
"select.same.textStrokeColor",
"select.segments",
"select.set",
"select.toggle",
"select.update",
"shape.arc",
"shape.ellipse",
"shape.flare",
"shape.line",
"shape.polarGrid",
"shape.polygon",
"shape.rectangle",
"shape.rectangularGrid",
"shape.spiral",
"shape.star",
"shapeBuilder.merge",
"shapeBuilder.regions",
"shaper.expand",
"shaper.release",
"shaper.releaseSource",
"shaper.scribble",
"shaper.select",
"slice.list",
"stroke.set",
"stroke.setAdvanced",
"stroke.weightDown",
"stroke.weightUp",
"stroke.widthPoint.copy",
"stroke.widthPoint.remove",
"stroke.widthPoint.set",
"svgFilter.apply",
"svgFilter.define",
"svgFilter.delete",
"svgFilter.list",
"svgInteractivity.clearEvents",
"svgInteractivity.info",
"svgInteractivity.removeEvent",
"svgInteractivity.setEvent",
"swatch.addUsedColors",
"swatch.delete",
"swatch.duplicate",
"swatch.edit",
"swatch.editGroup",
"swatch.list",
"swatch.merge",
"swatch.move",
"swatch.new",
"swatch.newGroup",
"swatch.resetDefaults",
"swatch.setSpot",
"swatch.sortByKind",
"swatch.sortByName",
"swatch.spotOptions",
"swatch.ungroup",
"swatch.unused",
"symbol.adjust",
"symbol.delete",
"symbol.duplicate",
"symbol.edit",
"symbol.exitEditing",
"symbol.instance.reset",
"symbol.instance.resetTransform",
"symbol.instance.selectPart",
"symbol.instance.setAppearance",
"symbol.list",
"symbol.new",
"symbol.options",
"symbol.place",
"symbol.replace",
"symbol.selectInstances",
"symbol.setCurrent",
"symbol.spray",
"symbol.unused",
"symbol.update",
"text.areaOptions",
"text.convertListToText",
"text.create",
"text.createInPath",
"text.discardEmpty",
"text.editRange",
"text.fitHeadline",
"text.fonts",
"text.getRange",
"text.glyphAlternates",
"text.insertInline",
"text.missingFonts",
"text.replaceFont",
"text.reshapeArea",
"text.reshapePath",
"text.setFormat",
"text.setGlyphAlternate",
"text.setPathHandle",
"text.setRangeStyle",
"text.setStyle",
"text.setText",
"text.startList",
"text.tabs.clear",
"text.tabs.get",
"text.tabs.set",
"text.thread.add",
"text.thread.break",
"text.thread.create",
"text.thread.info",
"text.thread.releaseSelection",
"text.thread.remove",
"text.touchType",
"transparency.disableOpacityMask",
"transparency.editOpacityMask",
"transparency.enableOpacityMask",
"transparency.info",
"transparency.makeOpacityMask",
"transparency.opacityMaskInfo",
"transparency.releaseOpacityMask",
"transparency.set",
"transparency.setOpacityMask",
"transparency.stopEditingOpacityMask",
"transparency.togglePageIsolatedBlending",
"transparency.togglePageKnockoutGroup",
"transparency.viewOpacityMask",
"type.changeCase",
"type.compositeFonts.list",
"type.convertToAreaType",
"type.convertToPointType",
"type.createOutlines",
"type.fillPlaceholder",
"type.fitHeadline",
"type.insert",
"type.opticalMarginAlignment",
"type.orientation.horizontal",
"type.orientation.vertical",
"type.pathOptions",
"type.resetHorizontalScale",
"type.resetTracking",
"type.ruby",
"type.scaleTextAttributes",
"type.size.decrease",
"type.size.decreaseByFive",
"type.size.increase",
"type.size.increaseByFive",
"type.smartPunctuation",
"type.step",
"variable.bind",
"variable.define",
"variable.delete",
"variable.highlight",
"variable.list",
"variable.lock",
"variable.makeGraphDynamic",
"variable.makeImageDynamic",
"variable.makeTextDynamic",
"variable.makeVisibilityDynamic",
"variable.rename",
"variable.unbind",
];
/// Commands a plug-in may not call although no rule refuses them, in order: they change the
/// preferences, a tool's options, a drag's state or the font names the whole app knows.
pub(super) const DENIED: &[&str] = &[
"addon.cleanup.splitText",
"addon.pathtools.movePointsTogether",
"addon.pen.nextSegment",
"graphicStyle.setOptions",
"object.blend.options",
"object.liquify",
"object.pathfinder.options",
"type.compositeFonts.set",
];
+6
View File
@@ -85,6 +85,12 @@ impl AutomationRoots {
Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? })))
}
/// Roots that grant no access of either kind: inside [`confine`] with them, every path a
/// command reads or writes is refused.
pub(crate) fn none() -> Arc<Self> {
Arc::new(Self { read: Vec::new(), write: Vec::new() })
}
/// May the file or folder at `path` be read (or written)? It must lie inside one of the roots
/// of that kind. `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
+31
View File
@@ -3584,6 +3584,37 @@ fn a_trace_run_hands_its_trace_to_the_command() {
assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}");
}
/// A plug-in run that calls Image Trace and then fails leaves a trace handed in for the command in
/// place, as it leaves the document.
#[test]
fn a_failed_plugin_run_keeps_a_handed_in_trace() {
use vectorcraft_plugins::wat as tpl;
let (id, cmd, args) = ("test.engine.calltrace", "imageTrace.make", r#"{"params":{"ignoreWhite":true}}"#);
let mut s = session();
s.allow_plugin_grants_from_requests();
image_doc(&mut s);
let p: Value = serde_json::from_str(args).unwrap();
let run = cmd::TraceRun::prepare(&s, cmd, &p).unwrap();
run.run(&|_, _| true).unwrap().hand_in(&mut s);
let grant = json!(["doc.read", "doc.write", "commands"]);
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{grant},"commandAllowlist":["{cmd}"]}}"#);
let imports = format!(
r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32)))
(data (i32.const 0x2000) "{}") (data (i32.const 0x2100) "{}")"#,
tpl::string(cmd),
tpl::string(args)
);
let first =
format!("(drop (call $vc_call (i32.const 0x2000) (i32.const {}) (i32.const 0x2100) (i32.const {}))) unreachable", cmd.len(), args.len());
let wasm = wat::parse_str(tpl::with_imports(&manifest, &imports, &first, "{}")).unwrap();
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true})).unwrap();
assert!(s.execute("plugin.run", &json!({"id": id, "ids": []})).is_err());
assert!(s.trace_hand_off.is_some(), "the trace is still handed in");
assert!(s.execute(cmd, &p).is_ok());
assert!(s.trace_hand_off.is_none(), "taken");
s.execute("plugin.remove", &json!({"id": id})).unwrap();
}
#[test]
fn image_trace_ignore_background_keeps_the_color_inside_the_art() {
// A black field, a light panel on it and a black ring on the panel.
+26
View File
@@ -3407,3 +3407,29 @@ fn another_module_under_an_id_with_settings_needs_replace() {
install(&mut s, json!({})).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A filter that calls commands runs on the document with the edit in progress kept first: a move
/// still being previewed is its own undo step, and the filter is given the moved rectangle, which
/// its empty output then removes.
#[test]
fn a_filter_that_calls_commands_is_given_the_edit_in_progress() {
let id = "test.engine.callsettle";
let mut s = session();
let grant = json!(["doc.read", "doc.write", "commands"]);
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{grant},"commandAllowlist":["select.all"]}}"#);
let wasm = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true}));
let paths = |s: &Session| s.doc().unwrap().doc.layers.first().and_then(|l| l.children()).map_or(0, |c| c.len());
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}));
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
run(&mut s, "plugin.run", json!({"id": id}));
assert!(!s.in_interaction());
assert_eq!(paths(&s), 0, "the filter was given the moved rectangle and removed it");
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, [format!("Test {id}").as_str(), "Move"]);
run(&mut s, "edit.undo", json!({}));
let x = run(&mut s, "document.inspect", json!({}))["layers"][0]["children"][0]["bounds"]["x"].clone();
assert_eq!(x, json!(17.0), "undoing the filter brings back the moved rectangle");
run(&mut s, "plugin.remove", json!({"id": id}));
}