From f921a6923d2a4b22f338ecedfe4d119c6cc39604 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:16:28 -0800 Subject: [PATCH] Plug-ins: an API level 2 plug-in calls the commands its manifest lists A filter at plug-in API level 2 with the new `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call runs through Session::execute on the run's document and sees what earlier calls changed; the calls and the filter's output are one undo step, and a run that fails, is cancelled or makes a failed call leaves the documents, their history and the session state the commands change as they were. Commands that touch files, the clipboard, preferences, the view, scripts, actions, plug-ins, open documents or the undo history are never callable, and every other command must be listed as allowed in cmd/plugin_call/reviewed.rs, so a new command is refused until placed. Calls are capped at 1,000 a run, 8 MiB a call and 64 MiB a run, and each costs instructions from the run's budget. plugin.install refuses a manifest that lists a refused command and reports unknown ids; plugin.inspect lists both, and the approval dialog lists the commands. Refs #2186 --- crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/plugin.rs | 75 +- crates/engine/src/cmd/plugin_call.rs | 450 ++++++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 674 ++++++++++++++++++ crates/engine/tests/plugin_call.rs | 177 +++++ crates/plugins/src/host.rs | 236 +++++- crates/plugins/src/lib.rs | 3 +- crates/plugins/src/manifest.rs | 56 +- crates/plugins/src/permissions.rs | 22 +- crates/plugins/src/runtime.rs | 97 ++- crates/plugins/tests/call.rs | 351 +++++++++ crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 14 + crates/ui-egui/src/i18n/cs.tsv | 14 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 14 + crates/ui-egui/src/i18n/el.tsv | 14 + crates/ui-egui/src/i18n/es.tsv | 14 + crates/ui-egui/src/i18n/fi.tsv | 14 + crates/ui-egui/src/i18n/fr.tsv | 14 + crates/ui-egui/src/i18n/hi.tsv | 14 + crates/ui-egui/src/i18n/hu.tsv | 14 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 14 + crates/ui-egui/src/i18n/ja.tsv | 14 + crates/ui-egui/src/i18n/ka.tsv | 14 + crates/ui-egui/src/i18n/ko.tsv | 14 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 14 + crates/ui-egui/src/i18n/pt-br.tsv | 14 + crates/ui-egui/src/i18n/ru.tsv | 14 + crates/ui-egui/src/i18n/sv.tsv | 14 + crates/ui-egui/src/i18n/tr.tsv | 14 + crates/ui-egui/src/i18n/uk.tsv | 14 + crates/ui-egui/src/i18n/vi.tsv | 14 + crates/ui-egui/src/i18n/zh-hans.tsv | 14 + crates/ui-egui/src/i18n/zh-hant.tsv | 14 + crates/ui-egui/src/io/open.rs | 15 +- docs/mcp.md | 8 +- docs/plugins.md | 57 +- docs/target-app-parity.md | 1 + 43 files changed, 2492 insertions(+), 45 deletions(-) create mode 100644 crates/engine/src/cmd/plugin_call.rs create mode 100644 crates/engine/src/cmd/plugin_call/reviewed.rs create mode 100644 crates/engine/tests/plugin_call.rs create mode 100644 crates/plugins/tests/call.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 7c1c9eea4..c76492f45 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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; diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 1cd137bdd..d5cfa9166 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -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 = m .commands @@ -258,6 +261,36 @@ 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> { + let calls = super::plugin_call::Calls::of(s, self.plugin.manifest().permissions().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 { + 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 +310,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 +349,10 @@ fn run(s: &mut Session, p: &Value) -> Result { // 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. + if r.calls() { + 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 +500,9 @@ pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result } }; let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?; + if r.calls() { + 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 +694,16 @@ fn install(s: &mut Session, p: &Value) -> Result { "`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 = 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 +776,11 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - Ok(describe_installed(®istry::install(plugin))) + let mut v = describe_installed(®istry::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 +791,13 @@ fn inspect(s: &mut Session, p: &Value) -> Result { 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 +1032,7 @@ pub fn specs() -> Vec { "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.), format? and extensions? (an exporter's or importer's format id, plugin., 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.., 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.), format? and extensions? (an exporter's or importer's format id, plugin., 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.., 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 +1050,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?: [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 +1059,7 @@ pub fn specs() -> Vec { "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 +1068,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs new file mode 100644 index 000000000..2e1cc689e --- /dev/null +++ b/crates/engine/src/cmd/plugin_call.rs @@ -0,0 +1,450 @@ +//! 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 cancelled, 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. 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 `v` name a plug-in anywhere: a string that starts with `plugin.` (a plug-in's effect, a +/// command of one)? A call that does is refused, so a plug-in never runs another, or itself. +fn names_plugin(v: &Value) -> bool { + match v { + Value::String(s) => s.starts_with(vectorcraft_plugins::PREFIX), + Value::Array(a) => a.iter().any(names_plugin), + Value::Object(o) => o.values().any(names_plugin), + _ => false, + } +} + +/// 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 +/// change the document. +pub(crate) struct Calls { + document: Option, + open: Vec, + interaction: bool, + may_write: bool, +} + +impl Calls { + /// The run's fixed state in `s` as it is now; `may_write` is whether it has `doc.write`. + pub(crate) fn of(s: &Session, may_write: bool) -> Calls { + Calls { document: s.active().map(|d| d.uid), open: s.docs.iter().map(|d| d.uid).collect(), interaction: s.in_interaction(), 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. +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() }; + 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) { + return Err(refused()); + } + let before = s.active().map(|d| d.doc.clone()); + let r = s.execute(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 `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. +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; + match f(s) { + 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:?}"); + } + + #[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")); + } +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs new file mode 100644 index 000000000..0e382e8c5 --- /dev/null +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -0,0 +1,674 @@ +//! 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.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", +]; diff --git a/crates/engine/tests/plugin_call.rs b/crates/engine/tests/plugin_call.rs new file mode 100644 index 000000000..00fca06c4 --- /dev/null +++ b/crates/engine/tests/plugin_call.rs @@ -0,0 +1,177 @@ +//! A filter plug-in whose manifest's `commandAllowlist` names commands calls them with `vc_call`: +//! each call sees what the calls before it left, the calls and the filter's output are one undo +//! step, and a run that fails leaves the document as it was. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +/// The command imports and the strings the plug-ins pass: the ids "select.all" (0x2000, 10 bytes), +/// "object.move" (0x2010, 11), "no.such.cmd" (0x2020, 11), "effect.apply" (0x2030, 12) and +/// "select.globalEditOptions" (0x2040, 24), and the arguments `{"dx":5,"dy":0}` (0x2100, 15), +/// `{"dx":"x"}` (0x2200, 10), `{"effect":"plugin.test.call.fx"}` (0x2300, 32) and `{"size":true}` +/// (0x2400, 13). +const CALLS: &str = r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_call_result" (func $vc_call_result (param i32 i32) (result i32))) + (data (i32.const 0x2000) "select.all") + (data (i32.const 0x2010) "object.move") + (data (i32.const 0x2020) "no.such.cmd") + (data (i32.const 0x2030) "effect.apply") + (data (i32.const 0x2040) "select.globalEditOptions") + (data (i32.const 0x2100) "{\22dx\22:5,\22dy\22:0}") + (data (i32.const 0x2200) "{\22dx\22:\22x\22}") + (data (i32.const 0x2300) "{\22effect\22:\22plugin.test.call.fx\22}") + (data (i32.const 0x2400) "{\22size\22:true}")"#; + +/// Selects everything. +const SELECT_ALL: &str = "(drop (call $vc_call (i32.const 0x2000) (i32.const 10) (i32.const 0) (i32.const 0)))"; +/// Moves the selection 5 to the right. +const MOVE: &str = "(drop (call $vc_call (i32.const 0x2010) (i32.const 11) (i32.const 0x2100) (i32.const 15)))"; + +const EMPTY: &str = r#"{"objects":[]}"#; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// Installs filter `id` with `permissions` (a JSON list) and `allowlist` (a JSON list), whose +/// `vc_run` runs `first`, then returns `output` → what `plugin.install` returned, or why it refused. +fn install(s: &mut Session, id: &str, permissions: &str, allowlist: &str, first: &str, output: &str) -> Result { + let manifest = + format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{permissions},"commandAllowlist":{allowlist}}}"#); + let wasm = wat::parse_str(tpl::with_imports(&manifest, CALLS, first, output)).unwrap(); + let grant: Value = serde_json::from_str(permissions).unwrap(); + let data = vectorcraft_format::base64_encode(&wasm); + s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant, "replace": true})).map_err(|e| e.to_string()) +} + +/// A filter `id` that may read and change the document and call `select.all` and `object.move`. +fn install_mover(s: &mut Session, id: &str, first: &str, output: &str) { + install(s, id, r#"["doc.read","doc.write","commands"]"#, r#"["select.all","object.move"]"#, first, output).unwrap(); +} + +/// A document with two rectangles and nothing selected. +fn document() -> Session { + let mut s = Session::new(); + s.allow_plugin_grants_from_requests(); + run(&mut s, "file.new", json!({"width": 400, "height": 200})); + run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})); + run(&mut s, "shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})); + run(&mut s, "select.none", json!({})); + s +} + +/// The active document's layers, its undo and redo steps and how many objects are selected. +fn state(s: &mut Session) -> (Value, usize, usize, usize) { + let content = run(s, "document.inspect", json!({}))["layers"].take(); + let d = s.doc().unwrap(); + (content, d.history.undo.len(), d.history.redo.len(), d.selection.len()) +} + +/// Runs filter `id` on nothing, so only its calls change the document. +fn run_plugin(s: &mut Session, id: &str) -> Result { + s.execute("plugin.run", &json!({"id": id, "ids": []})).map_err(|e| e.to_string()) +} + +#[test] +fn calls_see_each_others_changes_and_make_one_undo_step() { + let mut s = document(); + let before = state(&mut s); + install_mover(&mut s, "test.call.mover", &[SELECT_ALL, MOVE].concat(), EMPTY); + run_plugin(&mut s, "test.call.mover").unwrap(); + let after = state(&mut s); + // `object.move` moved what `select.all` selected: both rectangles. + let xs: Vec<&Value> = after.0[0]["children"].as_array().unwrap().iter().map(|c| &c["bounds"]["x"]).collect(); + assert_eq!(xs, [&json!(105.0), &json!(15.0)]); + assert_eq!(after.1, before.1 + 1, "one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().map(|u| u.label.as_str()), Some("Test test.call.mover")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(state(&mut s).0, before.0); + run(&mut s, "edit.redo", json!({})); + assert_eq!(state(&mut s).0, after.0); + // Without `select.all` nothing is selected, so `object.move` has nothing to move. + run(&mut s, "select.none", json!({})); + install_mover(&mut s, "test.call.mover", MOVE, EMPTY); + assert!(run_plugin(&mut s, "test.call.mover").is_err()); + run(&mut s, "plugin.remove", json!({"id": "test.call.mover"})); +} + +#[test] +fn a_run_that_fails_after_its_calls_changes_nothing() { + let mut s = document(); + let before = state(&mut s); + // A trap, an output that isn't JSON, and a command that fails, each after the move. + let bad_move = "(drop (call $vc_call (i32.const 0x2010) (i32.const 11) (i32.const 0x2200) (i32.const 10)))"; + for (first, output) in + [([SELECT_ALL, MOVE, "unreachable"].concat(), EMPTY), ([SELECT_ALL, MOVE].concat(), "nope"), ([SELECT_ALL, MOVE, bad_move].concat(), EMPTY)] + { + install_mover(&mut s, "test.call.fails", &first, output); + assert!(run_plugin(&mut s, "test.call.fails").is_err(), "{first} {output}"); + assert_eq!(state(&mut s), before, "{first} {output}"); + } + run(&mut s, "plugin.remove", json!({"id": "test.call.fails"})); + // Session state a call changed outside the document goes back too: Global Edit's options. + let options = "(drop (call $vc_call (i32.const 0x2040) (i32.const 24) (i32.const 0x2400) (i32.const 13))) unreachable"; + let allowlist = r#"["select.globalEditOptions"]"#; + install(&mut s, "test.call.options", r#"["doc.read","commands"]"#, allowlist, options, EMPTY).unwrap(); + assert!(run_plugin(&mut s, "test.call.options").is_err()); + assert_eq!(run(&mut s, "select.globalEditOptions", json!({}))["size"], json!(false)); + run(&mut s, "plugin.remove", json!({"id": "test.call.options"})); +} + +/// A command that changes state a failed run can't put back (the preferences, a tool's options, a +/// drag's state) is refused at install, by name. +#[test] +fn install_refuses_reviewed_denials() { + let mut s = document(); + for refused in ["object.liquify", "object.pathfinder.options", "addon.pen.nextSegment"] { + let allowlist = format!(r#"["select.all","{refused}"]"#); + let e = install(&mut s, "test.call.reviewed", r#"["doc.read","commands"]"#, &allowlist, "", EMPTY).unwrap_err(); + assert!(e.contains(refused) && e.contains("may not call"), "{e}"); + } +} + +#[test] +fn install_refuses_a_command_a_plugin_may_not_call_and_reports_unknown_ones() { + let mut s = document(); + let first = "(drop (call $vc_call (i32.const 0x2020) (i32.const 11) (i32.const 0) (i32.const 0)))"; + for refused in ["document.save", "edit.undo", "plugin.run", "script.run"] { + let allowlist = format!(r#"["select.all","{refused}"]"#); + let e = install(&mut s, "test.call.refused", r#"["doc.read","commands"]"#, &allowlist, "", EMPTY).unwrap_err(); + assert!(e.contains(refused) && e.contains("may not call"), "{e}"); + } + let r = install(&mut s, "test.call.unknown", r#"["doc.read","commands"]"#, r#"["select.all","no.such.cmd"]"#, first, EMPTY).unwrap(); + assert_eq!(r["unknownCommands"], json!(["no.such.cmd"])); + let e = run_plugin(&mut s, "test.call.unknown").unwrap_err(); + assert!(e.contains("no.such.cmd"), "{e}"); + run(&mut s, "plugin.remove", json!({"id": "test.call.unknown"})); +} + +#[test] +fn a_call_that_names_a_plugin_is_refused() { + let mut s = document(); + let before = state(&mut s); + let first = format!("{SELECT_ALL} (drop (call $vc_call (i32.const 0x2030) (i32.const 12) (i32.const 0x2300) (i32.const 32)))"); + install(&mut s, "test.call.fx", r#"["doc.read","doc.write","commands"]"#, r#"["select.all","effect.apply"]"#, &first, EMPTY).unwrap(); + let e = run_plugin(&mut s, "test.call.fx").unwrap_err(); + assert!(e.contains("may not call"), "{e}"); + assert_eq!(state(&mut s), before); + run(&mut s, "plugin.remove", json!({"id": "test.call.fx"})); +} + +#[test] +fn without_doc_write_a_call_that_edits_is_refused() { + let mut s = document(); + let before = state(&mut s); + let allowlist = r#"["select.all","object.move"]"#; + install(&mut s, "test.call.reader", r#"["doc.read","commands"]"#, allowlist, &[SELECT_ALL, MOVE].concat(), EMPTY).unwrap(); + let e = run_plugin(&mut s, "test.call.reader").unwrap_err(); + assert!(e.contains("may not call"), "{e}"); + assert_eq!(state(&mut s), before); + // Selecting changes no art, so it runs. + install(&mut s, "test.call.reader", r#"["doc.read","commands"]"#, allowlist, SELECT_ALL, EMPTY).unwrap(); + run_plugin(&mut s, "test.call.reader").unwrap(); + run(&mut s, "plugin.remove", json!({"id": "test.call.reader"})); +} diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index f8235a4fb..1b0250399 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -1,9 +1,10 @@ //! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on: //! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit //! asked it to stop, and `vc_log` writes a log line (the `progress` permission); `vc_settings_*` -//! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]). -//! A module may import one only when its manifest gives `api` 2 and lists the import's permission, -//! and the host checks each import when the module is loaded ([`check_imports`], [`check_granted`]): +//! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]); +//! `vc_call` runs a command its manifest's `commandAllowlist` names and `vc_call_result` reads that +//! command's result (`commands`, through the [`Bridge`] the run is given). A module may import one +//! only when its manifest gives `api` 2 and lists the import's permission, and the host checks each import when the module is loaded ([`check_imports`], [`check_granted`]): //! one it doesn't offer, one with another signature and one the manifest doesn't allow are refused //! by name. //! @@ -13,8 +14,9 @@ //! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], //! [`MAX_LOG_READ`]) are capped. Keys and values must lie inside the module's memory, keys must be //! [`crate::storage::valid_name`]s, values are capped, and so are each run's storage calls -//! ([`MAX_STORAGE_CALLS`]) and the bytes they move ([`MAX_STORAGE_BYTES`]). Nothing a module passes -//! can panic the host. +//! ([`MAX_STORAGE_CALLS`]) and the bytes they move ([`MAX_STORAGE_BYTES`]). Command calls are capped +//! per call ([`MAX_CALL_DATA`]) and per run ([`MAX_CALLS`], [`MAX_CALL_BYTES`]). Nothing a module +//! passes can panic the host. use std::sync::Arc; @@ -95,6 +97,71 @@ pub const MAX_STORAGE_CALLS: usize = 10_000; /// pass it returns [`STORAGE_BUDGET`]. pub const MAX_STORAGE_BYTES: usize = 1 << 30; +/// What `vc_call` returns: the command ran, and `vc_call_result` reads its result. +pub const CALL_OK: i32 = 0; +/// What `vc_call` or `vc_call_result` returns: the id, the arguments or the buffer doesn't lie +/// inside the module's memory. +pub const CALL_OUTSIDE_MEMORY: i32 = -1; +/// What `vc_call` returns: the id or the arguments are not UTF-8. +pub const CALL_BAD_UTF8: i32 = -2; +/// What `vc_call` returns: the arguments are not a JSON object. +pub const CALL_BAD_JSON: i32 = -3; +/// What `vc_call` returns: the manifest's `commandAllowlist` doesn't list the command. +pub const CALL_NOT_ALLOWED: i32 = -4; +/// What `vc_call` returns: no command has the id. +pub const CALL_UNKNOWN_COMMAND: i32 = -5; +/// What `vc_call` returns: a plug-in may not call the command, whatever its manifest lists. +pub const CALL_DENIED_COMMAND: i32 = -6; +/// What `vc_call` returns: the command can't run now (no document, nothing selected). +pub const CALL_DISABLED: i32 = -7; +/// What `vc_call` returns: the command ran and failed. +pub const CALL_COMMAND_FAILED: i32 = -8; +/// What `vc_call` returns: the arguments or the result are larger than [`MAX_CALL_DATA`], or the +/// run made [`MAX_CALLS`] calls or moved [`MAX_CALL_BYTES`] with them. +pub const CALL_LIMIT: i32 = -9; +/// What `vc_call_result` returns: no call has a result yet. +pub const CALL_NO_RESULT: i32 = -10; +/// What `vc_call` returns: nothing can be called now (while the module is being loaded, or in a +/// run that isn't a filter's run on a document). +pub const CALL_NO_CONTEXT: i32 = -11; + +/// Most `vc_call` calls one run makes. +pub const MAX_CALLS: usize = 1_000; + +/// Largest arguments or result of one `vc_call`, in bytes. +pub const MAX_CALL_DATA: usize = 8 << 20; + +/// Most bytes of arguments and results one run's `vc_call` calls move, all together. +pub const MAX_CALL_BYTES: usize = 64 << 20; + +/// Instructions each `vc_call` costs the run, besides one for every 64 bytes of its arguments and +/// result: [`MAX_CALLS`] calls cost a fifth of a filter's usual budget. +pub const CALL_FUEL: u64 = 10_000; + +/// Why a command a plug-in called with `vc_call` didn't run, or failed: the code `vc_call` returns +/// and the reason, which the run's error gives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CallFailure { + pub code: i32, + pub why: String, +} + +/// What runs the commands a plug-in calls: a command id and its arguments (a JSON object) → the +/// command's result, or why it didn't run. +pub type Bridge<'a> = dyn FnMut(&str, &serde_json::Value) -> std::result::Result + 'a; + +/// What `vc_call` pauses the run with, for the host to make the call ([`Run::pending`]). +#[derive(Debug)] +pub(crate) struct CallPending; + +impl std::fmt::Display for CallPending { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("vc_call") + } +} + +impl wasmi::errors::HostError for CallPending {} + /// One host import: its name, its type and the permission it needs. pub struct Import { pub name: &'static str, @@ -134,7 +201,17 @@ const fn storage_import(name: &'static str, params: &'static [ValType], signatur /// A storage call that fails returns [`STORAGE_NOT_FOUND`], [`STORAGE_OUTSIDE_MEMORY`], /// [`STORAGE_BAD_NAME`], [`STORAGE_FULL`], [`STORAGE_UNAVAILABLE`], [`STORAGE_FAILED`] or /// [`STORAGE_BUDGET`]. -pub const IMPORTS: [Import; 9] = [ +/// +/// With `commands`, for a filter whose manifest's `commandAllowlist` names the command: +/// +/// - `vc_call(id, id_len, args, args_len) -> i32`: runs the command with the UTF-8 id of `id_len` +/// bytes at `id` and the arguments at `args` (a UTF-8 JSON object; no bytes: `{}`) on the document +/// the run is for → [`CALL_OK`] or why it didn't run or failed. A failed call fails the run, and +/// nothing the run did stays; +/// - `vc_call_result(out, out_cap) -> i32`: the length of the last successful call's result (JSON), +/// copied to `out` when it fits in `out_cap` (nothing is copied otherwise), or +/// [`CALL_NO_RESULT`]. +pub const IMPORTS: [Import; 11] = [ Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], @@ -156,6 +233,8 @@ pub const IMPORTS: [Import; 9] = [ storage_import("vc_cache_read", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), storage_import("vc_cache_write", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), storage_import("vc_cache_clear", &[I32, I32], "(param i32 i32) (result i32)", Permission::Cache), + storage_import("vc_call", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Commands), + storage_import("vc_call_result", &[I32, I32], "(param i32 i32) (result i32)", Permission::Commands), ]; /// Which of a plug-in's stores a storage import reads or writes. @@ -291,6 +370,18 @@ pub(crate) struct Run { storage: Option>, storage_calls: usize, storage_bytes: usize, + /// The commands `vc_call` may run (the manifest's `commandAllowlist`), when the run has + /// something to run them ([`Run::with_calls`]); `None` otherwise. + allowed: Option>, + /// How many `vc_call` calls the run made, and the bytes of arguments and results they moved. + calls: usize, + call_bytes: usize, + /// The call `vc_call` paused the run for: its command id, its arguments and their size in bytes. + pub(crate) pending: Option<(String, serde_json::Value, usize)>, + /// The last successful call's result, as JSON text. + pub(crate) call_result: Option>, + /// The run's first failed call, which fails the run. + pub(crate) call_failure: Option, pub(crate) report: Report, } @@ -309,6 +400,12 @@ impl Run { storage: None, storage_calls: 0, storage_bytes: 0, + allowed: None, + calls: 0, + call_bytes: 0, + pending: None, + call_result: None, + call_failure: None, report: Report::default(), } } @@ -319,6 +416,114 @@ impl Run { self } + /// The run, with `vc_call` running the commands in `allowed`. + pub(crate) fn with_calls(mut self, allowed: Vec) -> Self { + self.allowed = Some(allowed); + self + } + + /// Records the run's first failed call, `e`, and returns `code`. + fn fail(&mut self, code: i32, e: Error) -> i32 { + self.call_failure.get_or_insert(e); + code + } + + /// `vc_call`: checks the call to the command at `id` with the arguments at `args` in `memory` + /// and leaves it in [`Run::pending`] for the host to make, or → the code for why it can't be + /// made, which fails the run. The size of the arguments is checked before they are read. + fn call_request(&mut self, memory: &[u8], id: (usize, usize), args: (usize, usize)) -> std::result::Result<(), i32> { + self.call_result = None; + if self.allowed.is_none() { + return Err(CALL_NO_CONTEXT); + } + let shown = || { + id.0.checked_add(id.1) + .filter(|_| id.1 <= crate::manifest::MAX_ALLOWED_ID) + .and_then(|end| memory.get(id.0..end)) + .map_or_else(|| "?".to_string(), |b| permissions::display_text(&String::from_utf8_lossy(b), permissions::MAX_SHOWN_FIELD)) + }; + if args.1 > MAX_CALL_DATA { + let (shown, len) = (shown(), args.1); + return Err(self.fail(CALL_LIMIT, Error::Limit(format!("command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA})")))); + } + if self.calls >= MAX_CALLS || self.call_bytes.saturating_add(args.1) > MAX_CALL_BYTES { + return Err(self.fail(CALL_LIMIT, Error::Limit(format!("command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run)")))); + } + self.calls += 1; + self.call_bytes += args.1; + let slice = |(at, len): (usize, usize)| at.checked_add(len).and_then(|end| memory.get(at..end)); + let (Some(id_bytes), Some(arg_bytes)) = (slice(id).filter(|_| id.1 <= crate::manifest::MAX_ALLOWED_ID), slice(args)) else { + if id.1 > crate::manifest::MAX_ALLOWED_ID { + let shown = shown(); + return Err(self.fail(CALL_NOT_ALLOWED, Error::Abi(format!("vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it")))); + } + let shown = shown(); + return Err( + self.fail(CALL_OUTSIDE_MEMORY, Error::Abi(format!("vc_call `{shown}`: the id or the arguments lie outside the module's memory"))) + ); + }; + let (Ok(name), Ok(text)) = (std::str::from_utf8(id_bytes), std::str::from_utf8(arg_bytes)) else { + let shown = shown(); + return Err(self.fail(CALL_BAD_UTF8, Error::Abi(format!("vc_call `{shown}`: the id or the arguments are not UTF-8")))); + }; + if !self.allowed.as_ref().is_some_and(|allowed| allowed.iter().any(|a| a == name)) { + let shown = shown(); + return Err(self.fail(CALL_NOT_ALLOWED, Error::Abi(format!("vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it")))); + } + let parsed = if text.is_empty() { Ok(serde_json::Value::Object(serde_json::Map::new())) } else { serde_json::from_str(text) }; + match parsed { + Ok(v @ serde_json::Value::Object(_)) => { + self.pending = Some((name.to_string(), v, args.1)); + Ok(()) + } + _ => { + let shown = shown(); + Err(self.fail(CALL_BAD_JSON, Error::Abi(format!("vc_call `{shown}`: the arguments are not a JSON object")))) + } + } + } + + /// Takes the host's answer to the call in [`Run::pending`], to command `id`: keeps its result for + /// `vc_call_result`, or records the failure → what `vc_call` returns. + pub(crate) fn call_answered(&mut self, id: &str, answer: std::result::Result) -> i32 { + let shown = permissions::display_text(id, permissions::MAX_SHOWN_FIELD); + let value = match answer { + Ok(value) => value, + Err(f) => { + let why = f.why; + return self.fail(f.code, Error::Failed(format!("vc_call `{shown}`: {why}"))); + } + }; + let text = serde_json::to_vec(&value).unwrap_or_default(); + let len = text.len(); + if len > MAX_CALL_DATA { + return self.fail(CALL_LIMIT, Error::Limit(format!("command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA})"))); + } + if self.call_bytes.saturating_add(len) > MAX_CALL_BYTES { + return self.fail(CALL_LIMIT, Error::Limit(format!("command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run)"))); + } + self.call_bytes += len; + self.call_result = Some(text); + CALL_OK + } + + /// `vc_call_result`: copies the last successful call's result into the `out_cap` bytes at `out` + /// when it fits → its length, or [`CALL_NO_RESULT`] or [`CALL_OUTSIDE_MEMORY`]. Neither fails + /// the run: a plug-in asks again with a larger buffer. + fn call_result(&self, memory: &mut [u8], out: (usize, usize)) -> i32 { + let Some(result) = &self.call_result else { return CALL_NO_RESULT }; + if out.1 > 0 && out.0.checked_add(out.1).is_none_or(|end| end > memory.len()) { + return CALL_OUTSIDE_MEMORY; + } + if result.len() <= out.1 + && let Some(dest) = memory.get_mut(out.0..out.0 + result.len()) + { + dest.copy_from_slice(result); + } + // At most MAX_CALL_DATA, which an i32 holds. + i32::try_from(result.len()).unwrap_or(CALL_LIMIT) + } + /// Makes one storage call with `call`, on the plug-in's storage and a [`Meter`] holding the /// run's storage bytes left, and counts the call and the bytes it read and wrote → the code for /// a run that has used up its calls or has no storage, else what `call` returns. @@ -534,6 +739,25 @@ pub(crate) fn linker(engine: &Engine, granted: &[Permission]) -> Linker { host.run.storage_remove(area, data, (offset(key), offset(key_len))) }); } + if granted.contains(&Permission::Commands) { + // A call that can be made pauses the run: the host makes it with what the run was given to + // make calls with ([`crate::Plugin::run_calling`]), and resumes the run with the code. + let _ = linker.func_wrap( + MODULE, + "vc_call", + |mut caller: Caller<'_, Host>, id: i32, id_len: i32, args: i32, args_len: i32| -> std::result::Result { + let Some((data, host)) = memory_and_host(&mut caller) else { return Ok(CALL_OUTSIDE_MEMORY) }; + match host.run.call_request(data, (offset(id), offset(id_len)), (offset(args), offset(args_len))) { + Ok(()) => Err(wasmi::Error::host(CallPending)), + Err(code) => Ok(code), + } + }, + ); + let _ = linker.func_wrap(MODULE, "vc_call_result", |mut caller: Caller<'_, Host>, out: i32, out_cap: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return CALL_OUTSIDE_MEMORY }; + host.run.call_result(data, (offset(out), offset(out_cap))) + }); + } linker } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index e3f1f76de..b4faf543d 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -5,7 +5,8 @@ //! //! - **no host imports but those of API level 2** ([`host`]: progress, Cancel and log lines with //! the `progress` permission, and settings and a cache of its own with the `settings` and `cache` -//! permissions, [`storage`]): no WASI, file system, network, clock or randomness. A module that +//! permissions, [`storage`], and the commands its `commandAllowlist` names with the `commands` +//! permission): no WASI, file system, network, clock or randomness. A module that //! imports anything else is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is //! also enforced (native builds), so infinite loops end with an error. A filter may ask for a diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index d548e6444..a16bd8a4f 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -25,6 +25,12 @@ pub const MAX_COMMANDS: usize = 32; /// Longest command id, in characters. pub const MAX_COMMAND_ID: usize = 32; +/// Most commands a manifest's `commandAllowlist` names. +pub const MAX_ALLOWED_COMMANDS: usize = 256; + +/// Longest command id a manifest's `commandAllowlist` names, in bytes. +pub const MAX_ALLOWED_ID: usize = 128; + /// What a plug-in is. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -337,6 +343,11 @@ pub struct Manifest { /// The menu commands it adds, in menu order (a filter or a live effect only). #[serde(default, deserialize_with = "ordered_commands", skip_serializing_if = "Vec::is_empty")] pub commands: Vec, + /// The VectorCraft commands a run may call with `vc_call` (exact command ids, such as + /// `object.transform`): only a filter with the usual budget whose `permissions` list `commands` + /// lists any ([`Manifest::validate`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command_allowlist: Vec, } impl Manifest { @@ -371,6 +382,7 @@ impl Manifest { budget: Budget::Light, paint_output: false, commands: Vec::new(), + command_allowlist: Vec::new(), } } @@ -427,7 +439,40 @@ impl Manifest { } } self.validate_params()?; - self.validate_commands() + self.validate_commands()?; + self.validate_allowlist() + } + + /// `commandAllowlist` lists at most [`MAX_ALLOWED_COMMANDS`] distinct command ids + /// ([`valid_allowed_id`]), and only a filter with the usual budget lists any: a heavy one runs in + /// the background, away from the document its commands would change. A list needs the + /// permission `commands`, and `commands` needs a list. + fn validate_allowlist(&self) -> Result<()> { + let listed = self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::Commands.name())); + if self.command_allowlist.is_empty() == listed { + return Err(Error::Manifest("`commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist`".into())); + } + if self.command_allowlist.is_empty() { + return Ok(()); + } + if self.kind != Kind::Filter || self.budget == Budget::Heavy { + return Err(Error::Manifest("only a filter with the usual budget declares `commandAllowlist`".into())); + } + if self.command_allowlist.len() > MAX_ALLOWED_COMMANDS { + return Err(Error::Manifest(format!("at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist`"))); + } + for (i, id) in self.command_allowlist.iter().enumerate() { + if !valid_allowed_id(id) || self.command_allowlist.get(..i).is_some_and(|before| before.contains(id)) { + let shown = permissions::display_text(id, permissions::MAX_SHOWN_FIELD); + return Err(Error::Manifest(format!("`commandAllowlist` names `{shown}`, which is not a command id or is listed twice"))); + } + } + Ok(()) + } + + /// May a run call command `id` ([`Manifest::command_allowlist`])? + pub fn allows_call(&self, id: &str) -> bool { + self.command_allowlist.iter().any(|a| a == id) } /// Checks each parameter and its dialog layout. @@ -810,6 +855,15 @@ fn params_as_object(p: &[(String, Param)], s: S) -> std::r m.end() } +/// A command id as a `commandAllowlist` names it: 1 to [`MAX_ALLOWED_ID`] bytes from +/// `A-Za-z0-9._-`, starting with a letter, with no empty part between dots. +pub fn valid_allowed_id(id: &str) -> bool { + (1..=MAX_ALLOWED_ID).contains(&id.len()) + && id.bytes().next().is_some_and(|b| b.is_ascii_alphabetic()) + && id.split('.').all(|part| !part.is_empty()) + && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + /// `[A-Za-z0-9._-]{1,64}`. pub fn valid_id(id: &str) -> bool { (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 778b19cee..7380edd56 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -5,9 +5,9 @@ //! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in //! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without //! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of -//! that kind could do before manifests listed them. `progress`, `settings` and `cache`, which let a -//! plug-in use the host imports of API level 2 ([`crate::host`]), are never implied: a manifest -//! lists them. +//! that kind could do before manifests listed them. `progress`, `settings`, `cache` and `commands`, +//! which let a plug-in use the host imports of API level 2 ([`crate::host`]), are never implied: a +//! manifest lists them. use serde::{Deserialize, Serialize}; @@ -19,7 +19,7 @@ use crate::{Error, Kind, Result}; pub const API_LEVEL: u32 = 2; /// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`, -/// `settings` and `cache` permissions they need. +/// `settings`, `cache` and `commands` permissions they need. pub const PROGRESS_API: u32 = 2; /// The API level that adds a filter's `inputs` ([`crate::InputKind`]). @@ -70,10 +70,14 @@ pub enum Permission { /// Keep a cache of its own, which the user can clear ([`crate::storage`]). #[serde(rename = "cache")] Cache, + /// Run the VectorCraft commands its manifest's `commandAllowlist` names, with `vc_call` + /// ([`crate::host::IMPORTS`]): a filter only. + #[serde(rename = "commands")] + Commands, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 9] = [ +pub const ALL: [Permission; 10] = [ Permission::DocRead, Permission::DocWrite, Permission::State, @@ -83,11 +87,13 @@ pub const ALL: [Permission; 9] = [ Permission::Progress, Permission::Settings, Permission::Cache, + Permission::Commands, ]; /// The permissions of the host imports, which any kind may list and none implies; each needs plug-in -/// API level [`PROGRESS_API`]. -pub const HOST: [Permission; 3] = [Permission::Progress, Permission::Settings, Permission::Cache]; +/// API level [`PROGRESS_API`]. Only a filter with a `commandAllowlist` may list `commands` +/// ([`crate::manifest::Manifest::command_allowlist`]). +pub const HOST: [Permission; 4] = [Permission::Progress, Permission::Settings, Permission::Cache, Permission::Commands]; impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. @@ -102,6 +108,7 @@ impl Permission { Permission::Progress => "progress", Permission::Settings => "settings", Permission::Cache => "cache", + Permission::Commands => "commands", } } @@ -122,6 +129,7 @@ impl Permission { Permission::Progress => "Show its progress, see Cancel and write log lines", Permission::Settings => "Keep settings of its own", Permission::Cache => "Keep a cache of its own, which you can clear", + Permission::Commands => "Run these commands", } } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index e6278fc99..019aae8ae 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -1,11 +1,11 @@ //! The wasmi host: loading, instantiating and calling a plug-in under resource limits. use wasmi::{ - Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Memory, Module, Store, StoreLimitsBuilder, TypedFunc, TypedResumableCall, WasmParams, - WasmResults, + Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Memory, Module, Store, StoreLimitsBuilder, TypedFunc, TypedResumableCall, Val, + WasmParams, WasmResults, }; -use crate::host::{self, Deadline, Host, Report, Run}; +use crate::host::{self, Bridge, Deadline, Host, Report, Run}; use crate::manifest::{MAX_MANIFEST_BYTES, Manifest}; use crate::permissions::Permission; use crate::{Error, Kind, Result}; @@ -132,6 +132,18 @@ impl Live { } } +/// Takes `cost` instructions from a run: from the budget still to hand out (`left`), then from what +/// the store holds. A run that can't pay ends with its instruction budget. +fn charge(live: &mut Live, left: &mut u64, cost: u64) -> Result<()> { + let held = live.store.get_fuel().map_err(wasm_err)?; + if held.saturating_add(*left) < cost { + return Err(Error::Limit("instruction budget".into())); + } + let from_left = cost.min(*left); + *left -= from_left; + live.store.set_fuel(held - (cost - from_left)).map_err(wasm_err) +} + fn wasm_err(e: wasmi::Error) -> Error { if e.as_trap_code() == Some(wasmi::TrapCode::OutOfFuel) { Error::Limit("instruction budget".into()) } else { Error::Trap(e.to_string()) } } @@ -257,6 +269,22 @@ impl Plugin { /// slice size, keeping that fuel, and a run ends with the instruction budget only when the /// store's fuel and the rest of the budget together can't pay for the next instruction. fn call(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { + self.call_with(live, f, params, fuel, deadline, None) + } + + /// [`Plugin::call`], making each command call a `vc_call` pauses it for with `bridge` + /// ([`host::Run::pending`]) and resuming it with the code. Each call costs [`host::CALL_FUEL`] + /// instructions and one for every 64 bytes of its arguments and result; the deadline and the + /// caller's cancellation are checked before and after it. + fn call_with( + &self, + live: &mut Live, + f: &TypedFunc, + params: P, + fuel: u64, + deadline: &Deadline, + mut bridge: Option<&mut Bridge<'_>>, + ) -> Result { let mut left = fuel; let give = left.min(FUEL_SLICE); left -= give; @@ -265,7 +293,22 @@ impl Plugin { loop { match call { TypedResumableCall::Finished(r) => return Ok(r), - TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())), + TypedResumableCall::HostTrap(inv) => { + let pending = live.store.data_mut().run.pending.take(); + let (Some((id, args, size)), Some(bridge)) = (pending, bridge.as_deref_mut()) else { + return Err(Error::Trap("unexpected host trap".into())); + }; + charge(live, &mut left, host::CALL_FUEL.saturating_add(size as u64 / 64))?; + deadline.check()?; + vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; + let answer = bridge(&id, &args); + let code = live.store.data_mut().run.call_answered(&id, answer); + let returned = live.store.data().run.call_result.as_ref().map_or(0, Vec::len); + charge(live, &mut left, returned as u64 / 64)?; + deadline.check()?; + vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; + call = inv.resume(&mut live.store, &[Val::I32(code)]).map_err(wasm_err)?; + } TypedResumableCall::OutOfFuel(inv) => { let need = inv.required_fuel(); let held = live.store.get_fuel().map_err(wasm_err)?; @@ -302,30 +345,60 @@ impl Plugin { /// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's /// doesn't) and `vc_cancelled` sees the scope's Cancel. pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result>, Report) { + self.run_guarded(input, params, limits, None) + } + + /// [`Plugin::run_reporting`] for a filter whose manifest's `commandAllowlist` names commands: + /// each `vc_call` of one of them is made with `bridge`, on the caller's thread, while the run + /// waits. A failed call fails the run, also when the plug-in goes on and returns an output; the + /// error names the command. + pub fn run_calling(&self, input: &[u8], params: &[u8], limits: &Limits, bridge: &mut Bridge<'_>) -> (Result>, Report) { + self.run_guarded(input, params, limits, Some(bridge)) + } + + fn run_guarded(&self, input: &[u8], params: &[u8], limits: &Limits, bridge: Option<&mut Bridge<'_>>) -> (Result>, Report) { let mut report = Report::default(); if let Err(e) = crate::safe_mode::check() { return (Err(e), report); } // wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must // still not take the document down (native builds; wasm aborts on panic anyway). - let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report))); + let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report, bridge))); (run.map_err(|_| Error::Trap("the runtime panicked".into())).and_then(|r| r), report) } - fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result> { + fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report, bridge: Option<&mut Bridge<'_>>) -> Result> { let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2); total.ok_or_else(|| Error::Limit(SELECTION_TOO_LARGE.into()))?; let deadline = Deadline::new(limits.wall_time_ms); let m = &self.manifest; - let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id)); + let mut run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id)); + if bridge.is_some() { + run = run.with_calls(m.command_allowlist.clone()); + } let mut live = self.instantiate(run, &m.permissions(), report)?; - let out = self.run_live(&mut live, input, params, limits, &deadline); - *report = std::mem::take(&mut live.store.data_mut().run.report); - out + let out = self.run_live(&mut live, input, params, limits, &deadline, bridge); + let state = &mut live.store.data_mut().run; + *report = std::mem::take(&mut state.report); + // A failed call fails the run, whatever the plug-in did after it, unless the run was + // cancelled. + match (state.call_failure.take(), out) { + (_, Err(Error::Cancelled)) => Err(Error::Cancelled), + (Some(e), _) => Err(e), + (None, out) => out, + } } /// Hands `input` and `params` to the instance `live` and runs `vc_run` → the output JSON bytes. - fn run_live(&self, live: &mut Live, input: &[u8], params: &[u8], limits: &Limits, deadline: &Deadline) -> Result> { + fn run_live( + &self, + live: &mut Live, + input: &[u8], + params: &[u8], + limits: &Limits, + deadline: &Deadline, + bridge: Option<&mut Bridge<'_>>, + ) -> Result> { let alloc: TypedFunc = live.func("vc_alloc")?; let run: TypedFunc = live.func("vc_run")?; let place = |live: &mut Live, bytes: &[u8]| -> Result { @@ -341,7 +414,7 @@ impl Plugin { let iptr = place(live, input)?; let pptr = place(live, params)?; let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte)); - let packed = self.call(live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, deadline)?; + let packed = self.call_with(live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, deadline, bridge)?; // A cancelled run, or one past its time budget, changes nothing, also when the plug-in saw // `vc_cancelled` and returned. vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; diff --git a/crates/plugins/tests/call.rs b/crates/plugins/tests/call.rs new file mode 100644 index 000000000..71f4ca8d1 --- /dev/null +++ b/crates/plugins/tests/call.rs @@ -0,0 +1,351 @@ +//! `vc_call` and `vc_call_result`: a filter of API level 2 that lists `commands` calls the commands +//! its manifest's `commandAllowlist` names, through what the host gives the run to make them with. +//! Every misuse is a code the plug-in sees, and fails the run. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use vectorcraft_plugins::host::{ + CALL_BAD_JSON, CALL_BAD_UTF8, CALL_COMMAND_FAILED, CALL_LIMIT, CALL_NO_CONTEXT, CALL_NO_RESULT, CALL_NOT_ALLOWED, CALL_OUTSIDE_MEMORY, + CallFailure, MAX_CALL_DATA, MAX_CALLS, +}; +use vectorcraft_plugins::{Error, Limits, Manifest, Plugin, wat as tpl}; + +const INPUT: &str = r#"{"objects":[]}"#; + +/// The command imports, as `$vc_call` and `$vc_call_result`, and the strings the tests pass: the id +/// "select.all" at 0x2000, "object.delete" at 0x2010, the arguments `{"a":1}` at 0x2100, the byte +/// 0xff at 0x2200 and `[1]` at 0x2300. 0x3000 is free for a result. +const CALLS: &str = r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_call_result" (func $vc_call_result (param i32 i32) (result i32))) + (data (i32.const 0x2000) "select.all") + (data (i32.const 0x2010) "object.delete") + (data (i32.const 0x2100) "{\22a\22:1}") + (data (i32.const 0x2200) "\ff") + (data (i32.const 0x2300) "[1]")"#; + +/// A filter `id` that may call `select.all`. +fn manifest(id: &str) -> String { + format!( + r#"{{"id":"{id}","name":"Caller","kind":"filter","api":2,"permissions":["doc.read","doc.write","commands"],"commandAllowlist":["select.all"]}}"# + ) +} + +/// `vc_call` of the id at `id` (`len` bytes) with the arguments at `args` (`args_len` bytes). +fn call(id: &str, len: &str, args: &str, args_len: &str) -> String { + format!("(call $vc_call (i32.const {id}) (i32.const {len}) (i32.const {args}) (i32.const {args_len}))") +} + +/// `select.all` with `{"a":1}`. +fn select_all() -> String { + call("0x2000", "10", "0x2100", "7") +} + +/// Instructions that trap unless `expr` is `want`. +fn expect(expr: &str, want: i32) -> String { + format!("(if (i32.ne {expr} (i32.const {want})) (then unreachable))") +} + +/// A filter that runs `first`, then returns the input. +fn calling(id: &str, first: &str) -> Plugin { + let src = tpl::with_imports(&manifest(id), CALLS, first, INPUT); + Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap() +} + +/// Runs `p` with a bridge that answers every call with `answer` → the run's result and the calls it +/// got. +fn run_with(p: &Plugin, answer: impl Fn(&str) -> Result) -> (vectorcraft_plugins::Result>, Vec<(String, Value)>) { + let mut seen = Vec::new(); + let mut bridge = |id: &str, args: &Value| { + seen.push((id.to_string(), args.clone())); + answer(id) + }; + let (out, _) = p.run_calling(INPUT.as_bytes(), b"{}", p.limits(), &mut bridge); + (out, seen) +} + +#[test] +fn a_call_runs_its_command_and_the_result_is_read_back() { + // The result `{"n":1}` is 7 bytes: a 2-byte buffer gets its length and nothing copied, then a + // larger one gets it, which the run returns as its output. + let run = format!( + "{} {} (if (i32.ne (i32.load8_u (i32.const 0x3000)) (i32.const 0)) (then unreachable)) {} (i64.or (i64.shl (i64.const 7) (i64.const 32)) (i64.const 0x3000))", + expect(&select_all(), 0), + expect("(call $vc_call_result (i32.const 0x3000) (i32.const 2))", 7), + expect("(call $vc_call_result (i32.const 0x3000) (i32.const 64))", 7), + ); + let src = tpl::module(&manifest("test.call.result"), &run, CALLS, 1); + let p = Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap(); + let (out, seen) = run_with(&p, |_| Ok(json!({"n": 1}))); + assert_eq!(out.unwrap(), br#"{"n":1}"#); + assert_eq!(seen, [("select.all".to_string(), json!({"a": 1}))]); + // No bytes of arguments are `{}`; a result buffer outside memory is a code, not a failure. + let p = calling( + "test.call.empty", + &[ + expect("(call $vc_call_result (i32.const 0x3000) (i32.const 64))", CALL_NO_RESULT), + expect(&call("0x2000", "10", "0", "0"), 0), + expect("(call $vc_call_result (i32.const -16) (i32.const 64))", CALL_OUTSIDE_MEMORY), + ] + .concat(), + ); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + assert_eq!(out.unwrap(), INPUT.as_bytes()); + assert_eq!(seen, [("select.all".to_string(), json!({}))]); +} + +/// Each misuse returns its code, and the run fails with an error that names the call, even when the +/// plug-in goes on and returns an output. The host makes no call for any of them. +#[test] +fn a_call_the_host_refuses_fails_the_run_even_when_the_plugin_goes_on() { + let cases = [ + (call("0x2010", "13", "0", "0"), CALL_NOT_ALLOWED, "vc_call `object.delete`: the manifest's `commandAllowlist` doesn't list it"), + (call("0x2000", "10", "0x2200", "1"), CALL_BAD_UTF8, "vc_call `select.all`: the id or the arguments are not UTF-8"), + (call("0x2000", "10", "0x2300", "3"), CALL_BAD_JSON, "vc_call `select.all`: the arguments are not a JSON object"), + (call("0x2000", "10", "0x2100", "3"), CALL_BAD_JSON, "the arguments are not a JSON object"), + (call("0x2000", "10", "-16", "7"), CALL_OUTSIDE_MEMORY, "vc_call `select.all`: the id or the arguments lie outside the module's memory"), + (call("-4", "10", "0", "0"), CALL_OUTSIDE_MEMORY, "vc_call `?`: the id or the arguments lie outside the module's memory"), + (call("0x2000", "200", "0", "0"), CALL_NOT_ALLOWED, "doesn't list it"), + ]; + for (i, (first, code, says)) in cases.into_iter().enumerate() { + let p = calling(&format!("test.call.refused{i}"), &expect(&first, code)); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + let e = out.expect_err(says).to_string(); + assert!(e.contains(says), "{first}: {e}"); + assert!(seen.is_empty(), "{first}"); + } +} + +/// Arguments longer than [`MAX_CALL_DATA`] are refused from their length alone: these 100 MiB start +/// outside memory, and the code is still the size limit. +#[test] +fn oversized_arguments_are_refused_before_they_are_read() { + let p = calling("test.call.huge", &expect(&call("0x2000", "10", "-16", &(100 << 20).to_string()), CALL_LIMIT)); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + let e = out.unwrap_err(); + assert!(matches!(&e, Error::Limit(why) if why.contains(&format!("104857600 bytes, limit {MAX_CALL_DATA}"))), "{e}"); + assert!(seen.is_empty()); +} + +#[test] +fn a_failed_command_fails_the_run_with_its_reason() { + let p = calling("test.call.failed", &expect(&select_all(), CALL_COMMAND_FAILED)); + let (out, seen) = run_with(&p, |_| Err(CallFailure { code: CALL_COMMAND_FAILED, why: "nothing is selected".into() })); + assert_eq!(out, Err(Error::Failed("vc_call `select.all`: nothing is selected".into()))); + assert_eq!(seen.len(), 1); + // Also when the plug-in traps after it: the call's failure is the one reported. + let p = calling("test.call.failed.trap", &format!("(drop {}) unreachable", select_all())); + let (out, _) = run_with(&p, |_| Err(CallFailure { code: CALL_COMMAND_FAILED, why: "nothing is selected".into() })); + assert_eq!(out, Err(Error::Failed("vc_call `select.all`: nothing is selected".into()))); +} + +/// A result larger than [`MAX_CALL_DATA`] fails the run, and so do more than [`MAX_CALLS`] calls. +#[test] +fn results_and_calls_are_capped() { + let p = calling("test.call.big", &expect(&select_all(), CALL_LIMIT)); + let (out, _) = run_with(&p, |_| Ok(Value::String("x".repeat(MAX_CALL_DATA)))); + assert!(matches!(out, Err(Error::Limit(ref why)) if why.starts_with("command call size (vc_call `select.all`")), "{out:?}"); + // A result of exactly MAX_CALL_DATA bytes (the string and its two quotes) is kept. + let p = calling("test.call.edge", &expect(&select_all(), 0)); + let (out, _) = run_with(&p, |_| Ok(Value::String("x".repeat(MAX_CALL_DATA - 2)))); + assert_eq!(out.unwrap(), INPUT.as_bytes()); + let many = format!( + "(local $i i32) (loop $l {} (local.set $i (i32.add (local.get $i) (i32.const 1))) (br_if $l (i32.lt_u (local.get $i) (i32.const {MAX_CALLS})))) {}", + expect(&select_all(), 0), + expect(&select_all(), CALL_LIMIT) + ); + let p = calling("test.call.many", &many); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + assert!(matches!(out, Err(Error::Limit(ref why)) if why.starts_with("command calls (")), "{out:?}"); + assert_eq!(seen.len(), MAX_CALLS); +} + +/// Each call costs instructions, so a budget too small for the calls ends the run: 256,000 (the +/// base and the input's share) pays for 25 calls of 10,000 and the loop around them. +#[test] +fn calls_cost_instructions() { + let many = format!("(loop $l {} (br $l))", expect(&select_all(), 0)); + let src = tpl::with_imports(&manifest("test.call.fuel"), CALLS, &many, INPUT); + let p = Plugin::load(&wat::parse_str(&src).unwrap(), Limits { fuel_base: 200_000, ..Limits::default() }).unwrap(); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + assert_eq!(out, Err(Error::Limit("instruction budget".into()))); + assert_eq!(seen.len(), 25); +} + +/// Cancel stops the run at the next call, and what it did is the caller's to undo. +#[test] +fn cancel_stops_a_run_at_its_next_call() { + let p = calling("test.call.cancel", &[expect(&select_all(), 0), expect(&select_all(), 0)].concat()); + let token = vectorcraft_doc::cancel::CancelToken::new(); + let mut calls = 0; + let mut bridge = |_: &str, _: &Value| { + calls += 1; + token.cancel(); + Ok(Value::Null) + }; + let (out, _) = vectorcraft_doc::cancel::scope(&token, None, || p.run_calling(INPUT.as_bytes(), b"{}", p.limits(), &mut bridge)); + assert_eq!(out, Err(Error::Cancelled)); + assert_eq!(calls, 1); +} + +/// Without a bridge (a run that isn't a filter's run on a document), and while the module is being +/// loaded, `vc_call` returns [`CALL_NO_CONTEXT`] and the run goes on. +#[test] +fn without_a_bridge_a_call_returns_no_context() { + let start = format!("(func $start {}) (start $start)", expect(&select_all(), CALL_NO_CONTEXT)); + let src = tpl::with_imports(&manifest("test.call.none"), &format!("{CALLS} {start}"), &expect(&select_all(), CALL_NO_CONTEXT), INPUT); + let p = Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); +} + +/// A module that imports `vc_call` or `vc_call_result` needs the permission `commands`, which needs +/// `api` 2 and a `commandAllowlist`; only a filter with the usual budget lists one. +#[test] +fn the_call_imports_need_the_commands_permission() { + let import = r#"(import "env" "vc_call_result" (func (param i32 i32) (result i32)))"#; + let without = r#"{"id":"test.call.perm","name":"C","kind":"filter","api":2,"permissions":["doc.read"]}"#; + let e = Plugin::load(&wat::parse_str(tpl::with_imports(without, import, "", INPUT)).unwrap(), Limits::default()).unwrap_err().to_string(); + assert!( + e.contains("imports `env.vc_call_result`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `commands`"), + "{e}" + ); + for (manifest, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","permissions":["commands"],"commandAllowlist":["select.all"]}"#, "needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"]}"#, "`commands` needs a `commandAllowlist`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"commandAllowlist":["select.all"]}"#, "`commandAllowlist` needs the permission `commands`"), + ( + r#"{"id":"a","name":"x","kind":"effect","api":2,"permissions":["doc.read","effect.render","commands"],"commandAllowlist":["select.all"]}"#, + "only a filter with the usual budget", + ), + ( + r#"{"id":"a","name":"x","kind":"filter","api":2,"budget":"heavy","permissions":["commands"],"commandAllowlist":["select.all"]}"#, + "only a filter with the usual budget", + ), + ( + r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["select.all","select.all"]}"#, + "names `select.all`", + ), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["select..all"]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["1select"]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":[""]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["select/all"]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["select\\all"]}"#, "not a command id"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["../select.all"]}"#, "not a command id"), + ( + r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":[]}"#, + "`commands` needs a `commandAllowlist`", + ), + ] { + let e = Manifest::parse(manifest.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{manifest}: {e}"); + } + let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":["{}"]}}"#, "a".repeat(129)); + assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("not a command id")); + let ids: Vec = (0..257).map(|i| format!("\"c{i}\"")).collect(); + let many = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"permissions":["commands"],"commandAllowlist":[{}]}}"#, ids.join(",")); + assert!(Manifest::parse(many.as_bytes()).unwrap_err().to_string().contains("at most 256 commands")); + let m = Manifest::parse(manifest("test.call.ok").as_bytes()).unwrap(); + assert!(m.allows_call("select.all") && !m.allows_call("select.none")); + assert_eq!(serde_json::to_value(&m).unwrap()["commandAllowlist"], json!(["select.all"])); +} + +/// A small xorshift generator, so the cases are the same on every run. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + + /// Up to `max` random bytes. + fn bytes(&mut self, max: u64) -> Vec { + (0..self.below(max + 1)).map(|_| self.next() as u8).collect() + } + + /// A random JSON value nested at most `depth` deep. + fn json(&mut self, depth: u32) -> Value { + match self.below(if depth == 0 { 4 } else { 6 }) { + 0 => Value::Null, + 1 => Value::Bool(self.below(2) == 0), + 2 => json!((self.next() as i64) >> self.below(64)), + 3 => Value::String(String::from_utf8_lossy(&self.bytes(12)).into_owned()), + 4 => Value::Array((0..self.below(4)).map(|_| self.json(depth - 1)).collect()), + _ => Value::Object((0..self.below(4)).map(|i| (format!("k{i}"), self.json(depth - 1))).collect()), + } + } +} + +/// Any bytes as a call's id and arguments: random bytes, JSON values of every kind, cut-off and +/// corrupted JSON, ids with path characters or a NUL, and arguments of exactly [`MAX_CALL_DATA`] +/// bytes and one byte more. The host makes the call only for a listed id with a JSON object of at +/// most [`MAX_CALL_DATA`] bytes; every other call gets a code and fails the run with an error, and +/// none panics. The plug-in calls with its parameters as the id and its input as the arguments. +#[test] +fn any_bytes_as_a_call_get_a_code() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + let ids: [&[u8]; 7] = [b"select.all", b"select.al", b"select.all\0", b"../select.all", b"select/all", b"\xff\xfe", b""]; + let mut cases: Vec<(Vec, Vec)> = Vec::new(); + for i in 0..600 { + let args = match i % 4 { + 0 => rng.bytes(48), + 1 => serde_json::to_vec(&rng.json(3)).unwrap(), + 2 => { + let mut v = serde_json::to_vec(&rng.json(3)).unwrap(); + v.truncate(rng.below(v.len() as u64 + 1) as usize); + v + } + _ => { + let mut v = serde_json::to_vec(&json!({"a": rng.json(3)})).unwrap(); + let at = rng.below(v.len() as u64) as usize; + v[at] = rng.next() as u8; + v + } + }; + let id = if rng.below(3) == 0 { rng.bytes(16) } else { ids[rng.below(ids.len() as u64) as usize].to_vec() }; + cases.push((args, id)); + } + for len in [MAX_CALL_DATA, MAX_CALL_DATA + 1] { + let mut v = br#"{"a":""#.to_vec(); + v.resize(len - 2, b'x'); + v.extend_from_slice(br#""}"#); + cases.push((v, b"select.all".to_vec())); + } + let p = calling("test.call.fuzz", "(drop (call $vc_call (local.get $pp) (local.get $pl) (local.get $in) (local.get $il)))"); + let mut made = 0; + for (args, id) in &cases { + let mut seen = Vec::new(); + let mut bridge = |_: &str, a: &Value| { + seen.push(a.clone()); + Ok(Value::Null) + }; + let (out, _) = p.run_calling(args, id, p.limits(), &mut bridge); + let object = if args.len() > MAX_CALL_DATA { + None + } else if args.is_empty() { + Some(json!({})) + } else { + serde_json::from_slice::(args).ok().filter(Value::is_object) + }; + let shown = String::from_utf8_lossy(&args[..args.len().min(64)]).into_owned(); + match object.filter(|_| id.as_slice() == b"select.all") { + Some(v) => { + assert_eq!(out.as_deref().ok(), Some(INPUT.as_bytes()), "{shown}"); + assert_eq!(seen, [v], "{shown}"); + made += 1; + } + None => { + assert!(seen.is_empty(), "{shown}"); + let e = out.expect_err(&shown); + assert!(matches!(e, Error::Abi(_) | Error::Limit(_)), "{shown}: {e}"); + } + } + } + // Both kinds of case came up: calls made and calls refused. + assert!(made >= 5 && made < cases.len() - 5, "{made} of {}", cases.len()); +} diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 7783e205f..c5bdfea5c 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -660,6 +660,7 @@ impl PluginIntField { budget: Default::default(), paint_output: false, commands: Vec::new(), + command_allowlist: Vec::new(), }; let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO }; fn number_rect(shape: &egui::Shape, text: &str) -> Option { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e1121be66..cbd976ec5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5795,6 +5795,7 @@ axis Y Axis Y ос Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника Keep settings of its own Съхраняване на собствени настройки Keep a cache of its own, which you can clear Съхраняване на собствен кеш, който можете да изчистите + Run these commands on the document: {ids} Изпълняване на тези команди върху документа: {ids} Clear Plug-in Cache Изчистване на кеша на добавката # Actions panel: action sets (#3712). New Set… Нов набор… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d4749aba4..526db6ac9 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5817,6 +5817,7 @@ axis Y Axis Y osa Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika Keep settings of its own Čuvati vlastite postavke Keep a cache of its own, which you can clear Čuvati vlastitu predmemoriju koju možete očistiti + Run these commands on the document: {ids} Izvršavati ove naredbe na dokumentu: {ids} Clear Plug-in Cache Očisti predmemoriju dodatka @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `{permission}` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 91169aaa4..ad051f833 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5974,6 +5974,20 @@ digits Malayalam Malaiàlam Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre Keep settings of its own Desar una configuració pròpia Keep a cache of its own, which you can clear Desar una memòria cau pròpia, que podeu buidar + Run these commands on the document: {ids} Executar aquestes ordres al document: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call «{name}» inclou `{id}` a `commandAllowlist`, que un complement no pot cridar +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: el `commandAllowlist` del manifest no la inclou +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: l’ID o els arguments són fora de la memòria del mòdul +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: l’ID o els arguments no són UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: els arguments no són un objecte JSON +@msg only a filter with the usual budget declares `commandAllowlist` només un filtre amb el pressupost habitual declara `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) crides d’ordres ({MAX_CALLS} crides i {MAX_CALL_BYTES} bytes per execució) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) mida d’una crida d’ordre (vc_call `{shown}`: {len} bytes, límit {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` com a màxim {MAX_ALLOWED_COMMANDS} ordres a `commandAllowlist` +@msg a plug-in may not call this command un complement no pot cridar aquesta ordre +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` necessita el permís `commands`, i `commands` necessita un `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` inclou `{shown}`, que no és un ID d’ordre o hi apareix dues vegades Clear Plug-in Cache Buida la memòria cau del complement @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `{permission}` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 91d86612c..2ac45da34 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5963,6 +5963,20 @@ axis Y Axis osa Y Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu Keep settings of its own Uchovávat vlastní nastavení Keep a cache of its own, which you can clear Uchovávat vlastní mezipaměť, kterou můžete vymazat + Run these commands on the document: {ids} Spouštět v dokumentu tyto příkazy: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}“ uvádí v `commandAllowlist` příkaz `{id}`, který zásuvný modul nesmí volat +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: `commandAllowlist` manifestu ho neuvádí +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID nebo argumenty leží mimo paměť modulu +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID nebo argumenty nejsou v UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumenty nejsou objekt JSON +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` deklaruje pouze filtr s obvyklým rozpočtem +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) volání příkazů ({MAX_CALLS} volání a {MAX_CALL_BYTES} bajtů na běh) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) velikost volání příkazu (vc_call `{shown}`: {len} bajtů, limit {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` nejvýše {MAX_ALLOWED_COMMANDS} příkazů v `commandAllowlist` +@msg a plug-in may not call this command zásuvný modul nesmí volat tento příkaz +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` potřebuje oprávnění `commands` a `commands` potřebuje `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` uvádí `{shown}`, což není ID příkazu nebo je uvedeno dvakrát Clear Plug-in Cache Vymazat mezipaměť zásuvného modulu @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `{permission}` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6348e68a4..af6f21349 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5810,6 +5810,7 @@ axis Y Axis Y-akse Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker Keep settings of its own Gemme sine egne indstillinger Keep a cache of its own, which you can clear Gemme sin egen cache, som du kan rydde + Run these commands on the document: {ids} Køre disse kommandoer på dokumentet: {ids} Clear Plug-in Cache Ryd plug-in-cache # Actions panel: action sets (#3712). New Set… Nyt sæt… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0ffe6eeb9..c4c41af36 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5546,6 +5546,20 @@ axis Y Axis Y-Achse Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben Keep settings of its own Eigene Einstellungen speichern Keep a cache of its own, which you can clear Einen eigenen Cache anlegen, den Sie leeren können + Run these commands on the document: {ids} Diese Befehle im Dokument ausführen: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}“ führt `{id}` in `commandAllowlist` auf, den ein Plug-in nicht aufrufen darf +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: die `commandAllowlist` des Manifests führt ihn nicht auf +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: die ID oder die Argumente liegen außerhalb des Speichers des Moduls +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: die ID oder die Argumente sind kein UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: die Argumente sind kein JSON-Objekt +@msg only a filter with the usual budget declares `commandAllowlist` nur ein Filter mit dem üblichen Budget deklariert `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) Befehlsaufrufe ({MAX_CALLS} Aufrufe und {MAX_CALL_BYTES} Byte pro Lauf) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) Größe eines Befehlsaufrufs (vc_call `{shown}`: {len} Byte, Grenze {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` höchstens {MAX_ALLOWED_COMMANDS} Befehle in `commandAllowlist` +@msg a plug-in may not call this command ein Plug-in darf diesen Befehl nicht aufrufen +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` braucht die Berechtigung `commands`, und `commands` braucht eine `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` nennt `{shown}`, das keine Befehls-ID ist oder doppelt aufgeführt ist Clear Plug-in Cache Plug-in-Cache leeren @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `{permission}` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an @msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6dc7a40ea..81d5a6f99 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5914,6 +5914,20 @@ axis Y Axis Άξονας Y Show its progress, see Cancel and write log lines Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής Keep settings of its own Διατήρηση δικών του ρυθμίσεων Keep a cache of its own, which you can clear Διατήρηση δικής του κρυφής μνήμης, την οποία μπορείτε να εκκαθαρίσετε + Run these commands on the document: {ids} Εκτέλεση αυτών των εντολών στο έγγραφο: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call το «{name}» αναφέρει το `{id}` στο `commandAllowlist`, το οποίο ένα πρόσθετο δεν επιτρέπεται να καλέσει +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: το `commandAllowlist` του μανιφέστου δεν την αναφέρει +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: το αναγνωριστικό ή τα ορίσματα βρίσκονται εκτός της μνήμης της λειτουργικής μονάδας +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: το αναγνωριστικό ή τα ορίσματα δεν είναι UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: τα ορίσματα δεν είναι αντικείμενο JSON +@msg only a filter with the usual budget declares `commandAllowlist` μόνο ένα φίλτρο με τον συνήθη προϋπολογισμό δηλώνει `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) κλήσεις εντολών ({MAX_CALLS} κλήσεις και {MAX_CALL_BYTES} byte ανά εκτέλεση) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) μέγεθος κλήσης εντολής (vc_call `{shown}`: {len} byte, όριο {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` το πολύ {MAX_ALLOWED_COMMANDS} εντολές στο `commandAllowlist` +@msg a plug-in may not call this command ένα πρόσθετο δεν επιτρέπεται να καλέσει αυτή την εντολή +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` το `commandAllowlist` χρειάζεται το δικαίωμα `commands`, και το `commands` χρειάζεται ένα `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice το `commandAllowlist` αναφέρει το `{shown}`, που δεν είναι αναγνωριστικό εντολής ή αναφέρεται δύο φορές Clear Plug-in Cache Εκκαθάριση κρυφής μνήμης προσθέτου @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `{permission}` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {signature} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0f3947277..24abaf093 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5905,6 +5905,20 @@ axis Y Axis Eje Y Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro Keep settings of its own Guardar una configuración propia Keep a cache of its own, which you can clear Guardar una caché propia, que usted puede vaciar + Run these commands on the document: {ids} Ejecutar estos comandos en el documento: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” incluye `{id}` en `commandAllowlist`, que un plugin no puede llamar +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: el `commandAllowlist` del manifiesto no lo incluye +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: el ID o los argumentos están fuera de la memoria del módulo +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: el ID o los argumentos no son UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: los argumentos no son un objeto JSON +@msg only a filter with the usual budget declares `commandAllowlist` solo un filtro con el presupuesto habitual declara `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) llamadas a comandos ({MAX_CALLS} llamadas y {MAX_CALL_BYTES} bytes por ejecución) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) tamaño de una llamada a comando (vc_call `{shown}`: {len} bytes, límite {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` como máximo {MAX_ALLOWED_COMMANDS} comandos en `commandAllowlist` +@msg a plug-in may not call this command un plugin no puede llamar a este comando +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` necesita el permiso `commands`, y `commands` necesita un `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` incluye `{shown}`, que no es un ID de comando o aparece dos veces Clear Plug-in Cache Vaciar caché del plugin @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `{permission}` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {signature} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e2ab48874..aba9a6281 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5993,6 +5993,20 @@ axis Y Axis Y-akseli Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä Keep settings of its own Säilyttää omat asetuksensa Keep a cache of its own, which you can clear Säilyttää oman välimuistin, jonka voit tyhjentää + Run these commands on the document: {ids} Suorittaa nämä komennot asiakirjassa: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call ”{name}” luettelee `commandAllowlist`-luettelossa komennon `{id}`, jota laajennus ei saa kutsua +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: manifestin `commandAllowlist` ei luettele sitä +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: tunnus tai argumentit ovat moduulin muistin ulkopuolella +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: tunnus tai argumentit eivät ole UTF-8:aa +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumentit eivät ole JSON-objekti +@msg only a filter with the usual budget declares `commandAllowlist` vain tavallisen budjetin suodatin voi määrittää `commandAllowlist`-luettelon +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) komentokutsut ({MAX_CALLS} kutsua ja {MAX_CALL_BYTES} tavua ajoa kohden) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) komentokutsun koko (vc_call `{shown}`: {len} tavua, raja {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` enintään {MAX_ALLOWED_COMMANDS} komentoa `commandAllowlist`-luettelossa +@msg a plug-in may not call this command laajennus ei saa kutsua tätä komentoa +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` tarvitsee käyttöoikeuden `commands`, ja `commands` tarvitsee `commandAllowlist`-luettelon +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` mainitsee kohteen `{shown}`, joka ei ole komennon tunnus tai on luettelossa kahdesti Clear Plug-in Cache Tyhjennä laajennuksen välimuisti @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `{permission}` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {signature} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c2c333b6b..a50119609 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5958,6 +5958,20 @@ axis Y Axis Axe Y Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal Keep settings of its own Conserver ses propres réglages Keep a cache of its own, which you can clear Conserver son propre cache, que vous pouvez vider + Run these commands on the document: {ids} Exécuter ces commandes sur le document : {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call « {name} » indique `{id}` dans `commandAllowlist`, qu’un module externe ne peut pas appeler +@msg vc_call `{shown}`: {why} vc_call `{shown}` : {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}` : le `commandAllowlist` du manifeste ne l’indique pas +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}` : l’ID ou les arguments se trouvent hors de la mémoire du module +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}` : l’ID ou les arguments ne sont pas en UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}` : les arguments ne sont pas un objet JSON +@msg only a filter with the usual budget declares `commandAllowlist` seul un filtre au budget habituel déclare `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) appels de commandes ({MAX_CALLS} appels et {MAX_CALL_BYTES} octets par exécution) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) taille d’un appel de commande (vc_call `{shown}` : {len} octets, limite {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` au plus {MAX_ALLOWED_COMMANDS} commandes dans `commandAllowlist` +@msg a plug-in may not call this command un module externe ne peut pas appeler cette commande +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` a besoin de l’autorisation `commands`, et `commands` a besoin d’un `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` indique `{shown}`, qui n’est pas un ID de commande ou figure deux fois Clear Plug-in Cache Vider le cache du module externe @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `{permission}` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {signature} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index d39ab857f..035b975f5 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5275,6 +5275,20 @@ antiAlias Sharp शार्प Show its progress, see Cancel and write log lines अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना Keep settings of its own अपनी सेटिंग्स रखना Keep a cache of its own, which you can clear अपना कैश रखना, जिसे आप साफ़ कर सकते हैं + Run these commands on the document: {ids} दस्तावेज़ पर ये कमांड चलाना: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” `commandAllowlist` में `{id}` सूचीबद्ध करता है, जिसे कोई प्लग-इन कॉल नहीं कर सकता +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: मैनिफ़ेस्ट की `commandAllowlist` में यह सूचीबद्ध नहीं है +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: आईडी या आर्ग्युमेंट मॉड्यूल की मेमोरी के बाहर हैं +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: आईडी या आर्ग्युमेंट UTF-8 नहीं हैं +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: आर्ग्युमेंट JSON ऑब्जेक्ट नहीं हैं +@msg only a filter with the usual budget declares `commandAllowlist` केवल सामान्य बजट वाला फ़िल्टर `commandAllowlist` घोषित करता है +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) कमांड कॉल (हर रन में {MAX_CALLS} कॉल और {MAX_CALL_BYTES} बाइट) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) कमांड कॉल का आकार (vc_call `{shown}`: {len} बाइट, सीमा {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` में अधिकतम {MAX_ALLOWED_COMMANDS} कमांड +@msg a plug-in may not call this command कोई प्लग-इन इस कमांड को कॉल नहीं कर सकता +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` को `commands` अनुमति चाहिए, और `commands` को `commandAllowlist` चाहिए +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` में `{shown}` है, जो कमांड आईडी नहीं है या दो बार सूचीबद्ध है Clear Plug-in Cache प्लग-इन कैश साफ़ करें @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `{permission}` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें @msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {signature} है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2b70f11db..4e656598b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5946,6 +5946,20 @@ axis Y Axis Y tengely Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat Keep settings of its own Saját beállításokat tárolhat Keep a cache of its own, which you can clear Saját gyorsítótárat tárolhat, amelyet Ön kiüríthet + Run these commands on the document: {ids} Ezeket a parancsokat futtathatja a dokumentumon: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call a(z) „{name}” a(z) `{id}` parancsot sorolja fel a `commandAllowlist` listában, amelyet bővítmény nem hívhat meg +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: a jegyzék `commandAllowlist` listája nem sorolja fel +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: az azonosító vagy az argumentumok a modul memóriáján kívül esnek +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: az azonosító vagy az argumentumok nem UTF-8 kódolásúak +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: az argumentumok nem JSON-objektumot alkotnak +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` listát csak a szokásos keretű szűrő adhat meg +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) parancshívások (futásonként {MAX_CALLS} hívás és {MAX_CALL_BYTES} bájt) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) parancshívás mérete (vc_call `{shown}`: {len} bájt, korlát {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` legfeljebb {MAX_ALLOWED_COMMANDS} parancs a `commandAllowlist` listában +@msg a plug-in may not call this command bővítmény nem hívhatja meg ezt a parancsot +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` a `commandAllowlist` listához a `commands` engedély szükséges, a `commands` engedélyhez pedig `commandAllowlist` lista +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice a `commandAllowlist` a(z) `{shown}` elemet nevezi meg, amely nem parancsazonosító, vagy kétszer szerepel Clear Plug-in Cache Bővítmény gyorsítótárának kiürítése @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a(z) `{permission}` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket @msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {signature} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ed0ce6d4e..130bbcd95 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5810,6 +5810,7 @@ axis Y Axis Sumbu Y Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log Keep settings of its own Menyimpan pengaturannya sendiri Keep a cache of its own, which you can clear Menyimpan cache-nya sendiri, yang dapat Anda kosongkan + Run these commands on the document: {ids} Menjalankan perintah ini pada dokumen: {ids} Clear Plug-in Cache Kosongkan cache plug-in # Actions panel: action sets (#3712). New Set… Set baru… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 975800f1f..24c43d695 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5580,6 +5580,20 @@ axis Y Axis Asse Y Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro Keep settings of its own Conservare impostazioni proprie Keep a cache of its own, which you can clear Conservare una cache propria, che è possibile svuotare + Run these commands on the document: {ids} Eseguire questi comandi sul documento: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” elenca `{id}` in `commandAllowlist`, che un plug-in non può chiamare +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: il `commandAllowlist` del manifesto non lo elenca +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: l’ID o gli argomenti si trovano fuori dalla memoria del modulo +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: l’ID o gli argomenti non sono UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: gli argomenti non sono un oggetto JSON +@msg only a filter with the usual budget declares `commandAllowlist` solo un filtro con il budget consueto dichiara `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) chiamate di comandi ({MAX_CALLS} chiamate e {MAX_CALL_BYTES} byte per esecuzione) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) dimensione di una chiamata di comando (vc_call `{shown}`: {len} byte, limite {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` al massimo {MAX_ALLOWED_COMMANDS} comandi in `commandAllowlist` +@msg a plug-in may not call this command un plug-in non può chiamare questo comando +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` richiede l’autorizzazione `commands`, e `commands` richiede un `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` elenca `{shown}`, che non è un ID di comando o è elencato due volte Clear Plug-in Cache Svuota cache del plug-in @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `{permission}` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {signature} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4e0df1a47..9493a75fd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5645,6 +5645,20 @@ axis Y Axis Y 軸 Show its progress, see Cancel and write log lines 進行状況の表示、キャンセルの検知、ログ行の書き込み Keep settings of its own 独自の設定の保持 Keep a cache of its own, which you can clear 消去できる独自のキャッシュの保持 + Run these commands on the document: {ids} ドキュメントでのこれらのコマンドの実行: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call 「{name}」は `commandAllowlist` にプラグインが呼び出せない `{id}` を挙げています +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: マニフェストの `commandAllowlist` にありません +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID または引数がモジュールのメモリの外にあります +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID または引数が UTF-8 ではありません +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: 引数が JSON オブジェクトではありません +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` を宣言できるのは通常の予算のフィルターだけです +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) コマンド呼び出し (1 回の実行につき {MAX_CALLS} 回、{MAX_CALL_BYTES} バイト) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) コマンド呼び出しのサイズ (vc_call `{shown}`: {len} バイト、上限 {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` のコマンドは最大 {MAX_ALLOWED_COMMANDS} 個です +@msg a plug-in may not call this command プラグインはこのコマンドを呼び出せません +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` には権限 `commands` が必要で、`commands` には `commandAllowlist` が必要です +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` の `{shown}` はコマンド ID ではないか、2 回挙げられています Clear Plug-in Cache プラグインのキャッシュを消去 @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `{permission}` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください @msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {signature} です diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0ff9696f7..e373a2f9b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5886,6 +5886,20 @@ axis Y Axis Y ღერძი Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა Keep settings of its own საკუთარი პარამეტრების შენახვა Keep a cache of its own, which you can clear საკუთარი ქეშის შენახვა, რომლის გასუფთავებაც შეგიძლიათ + Run these commands on the document: {ids} ამ ბრძანებების გაშვება დოკუმენტზე: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}“ `commandAllowlist`-ში ასახელებს `{id}`-ს, რომლის გამოძახებაც პლაგინს არ შეუძლია +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: მანიფესტის `commandAllowlist` მას არ ასახელებს +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID ან არგუმენტები მოდულის მეხსიერების გარეთაა +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID ან არგუმენტები UTF-8 არ არის +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: არგუმენტები JSON ობიექტი არ არის +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist`-ის გამოცხადება მხოლოდ ჩვეულებრივი ბიუჯეტის ფილტრს შეუძლია +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) ბრძანებების გამოძახებები ({MAX_CALLS} გამოძახება და {MAX_CALL_BYTES} ბაიტი ერთ გაშვებაზე) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) ბრძანების გამოძახების ზომა (vc_call `{shown}`: {len} ბაიტი, ლიმიტი {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` მაქსიმუმ {MAX_ALLOWED_COMMANDS} ბრძანება `commandAllowlist`-ში +@msg a plug-in may not call this command პლაგინს ამ ბრძანების გამოძახება არ შეუძლია +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist`-ს სჭირდება ნებართვა `commands`, ხოლო `commands`-ს სჭირდება `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` ასახელებს `{shown}`-ს, რომელიც ბრძანების ID არ არის ან ორჯერაა ჩამოთვლილი Clear Plug-in Cache პლაგინის ქეშის გასუფთავება @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `{permission}` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b68b4b308..2ce63f7c1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5541,6 +5541,20 @@ axis Y Axis Y축 Show its progress, see Cancel and write log lines 진행률 표시, 취소 감지 및 로그 줄 쓰기 Keep settings of its own 자체 설정 보관 Keep a cache of its own, which you can clear 지울 수 있는 자체 캐시 보관 + Run these commands on the document: {ids} 문서에서 다음 명령 실행: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}”의 `commandAllowlist`에 플러그인이 호출할 수 없는 `{id}`가 있습니다 +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: 매니페스트의 `commandAllowlist`에 없습니다 +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID 또는 인수가 모듈의 메모리 밖에 있습니다 +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID 또는 인수가 UTF-8이 아닙니다 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: 인수가 JSON 객체가 아닙니다 +@msg only a filter with the usual budget declares `commandAllowlist` 보통 예산의 필터만 `commandAllowlist`를 선언할 수 있습니다 +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) 명령 호출(실행당 {MAX_CALLS}회, {MAX_CALL_BYTES}바이트) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) 명령 호출 크기(vc_call `{shown}`: {len}바이트, 제한 {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist`의 명령은 최대 {MAX_ALLOWED_COMMANDS}개입니다 +@msg a plug-in may not call this command 플러그인은 이 명령을 호출할 수 없습니다 +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist`에는 `commands` 권한이 필요하고, `commands`에는 `commandAllowlist`가 필요합니다 +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist`의 `{shown}`은(는) 명령 ID가 아니거나 두 번 나열되었습니다 Clear Plug-in Cache 플러그인 캐시 지우기 @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 @msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {signature}입니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 005972780..71d3ab515 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5861,6 +5861,7 @@ axis Y Axis Y-as Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels schrijven Keep settings of its own Eigen instellingen bewaren Keep a cache of its own, which you can clear Een eigen cache bewaren, die u kunt legen + Run these commands on the document: {ids} Deze opdrachten op het document uitvoeren: {ids} Clear Plug-in Cache Plug-incache legen # Actions panel: action sets (#3712). New Set… Nieuwe set… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index feb3d8308..01383ba57 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5817,6 +5817,20 @@ axis Y Axis Oś Y Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika Keep settings of its own Przechowywać własne ustawienia Keep a cache of its own, which you can clear Przechowywać własną pamięć podręczną, którą możesz wyczyścić + Run these commands on the document: {ids} Uruchamiać w dokumencie te polecenia: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}” wymienia w `commandAllowlist` polecenie `{id}`, którego wtyczka nie może wywołać +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: `commandAllowlist` manifestu go nie wymienia +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID lub argumenty leżą poza pamięcią modułu +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID lub argumenty nie są w UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumenty nie są obiektem JSON +@msg only a filter with the usual budget declares `commandAllowlist` tylko filtr ze zwykłym budżetem deklaruje `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) wywołania poleceń ({MAX_CALLS} wywołań i {MAX_CALL_BYTES} bajtów na uruchomienie) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) rozmiar wywołania polecenia (vc_call `{shown}`: {len} bajtów, limit {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` najwyżej {MAX_ALLOWED_COMMANDS} poleceń w `commandAllowlist` +@msg a plug-in may not call this command wtyczka nie może wywołać tego polecenia +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` potrzebuje uprawnienia `commands`, a `commands` potrzebuje `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` wymienia `{shown}`, które nie jest ID polecenia lub występuje dwa razy Clear Plug-in Cache Wyczyść pamięć podręczną wtyczki @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `{permission}` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {signature} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e0e83aaec..a9868d612 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5878,6 +5878,20 @@ axis Y Axis Eixo Y Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log Keep settings of its own Guardar configurações próprias Keep a cache of its own, which you can clear Guardar um cache próprio, que você pode limpar + Run these commands on the document: {ids} Executar estes comandos no documento: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” lista `{id}` em `commandAllowlist`, que um plug-in não pode chamar +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: o `commandAllowlist` do manifesto não o lista +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: o ID ou os argumentos estão fora da memória do módulo +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: o ID ou os argumentos não são UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: os argumentos não são um objeto JSON +@msg only a filter with the usual budget declares `commandAllowlist` somente um filtro com o orçamento habitual declara `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) chamadas de comandos ({MAX_CALLS} chamadas e {MAX_CALL_BYTES} bytes por execução) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) tamanho de uma chamada de comando (vc_call `{shown}`: {len} bytes, limite {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` no máximo {MAX_ALLOWED_COMMANDS} comandos em `commandAllowlist` +@msg a plug-in may not call this command um plug-in não pode chamar este comando +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` precisa da permissão `commands`, e `commands` precisa de um `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` lista `{shown}`, que não é um ID de comando ou aparece duas vezes Clear Plug-in Cache Limpar cache do plug-in @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `{permission}` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {signature} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7ccd9c605..3084bbf08 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5574,6 +5574,20 @@ axis Y Axis Ось Y Show its progress, see Cancel and write log lines Показывать свой ход выполнения, замечать отмену и записывать строки журнала Keep settings of its own Хранить собственные настройки Keep a cache of its own, which you can clear Хранить собственный кэш, который можно очистить + Run these commands on the document: {ids} Выполнять в документе эти команды: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call «{name}» указывает в `commandAllowlist` команду `{id}`, которую модуль вызывать не может +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: в `commandAllowlist` манифеста её нет +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID или аргументы лежат вне памяти модуля +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID или аргументы не в UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: аргументы не являются объектом JSON +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` может объявлять только фильтр с обычным бюджетом +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) вызовы команд ({MAX_CALLS} вызовов и {MAX_CALL_BYTES} байт за запуск) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) размер вызова команды (vc_call `{shown}`: {len} байт, предел {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` не более {MAX_ALLOWED_COMMANDS} команд в `commandAllowlist` +@msg a plug-in may not call this command модуль не может вызывать эту команду +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` для `commandAllowlist` нужно разрешение `commands`, а для `commands` нужен `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` указывает `{shown}`, который не является ID команды или указан дважды Clear Plug-in Cache Очистить кэш модуля @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `{permission}` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {signature} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a732ff6c0..d94ffd16c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5986,6 +5986,20 @@ axis Y Axis Y-axel Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader Keep settings of its own Spara egna inställningar Keep a cache of its own, which you can clear Spara en egen cache, som du kan tömma + Run these commands on the document: {ids} Köra dessa kommandon på dokumentet: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call ”{name}” anger `{id}` i `commandAllowlist`, som ett plugin inte får anropa +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: manifestets `commandAllowlist` anger det inte +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: id:t eller argumenten ligger utanför modulens minne +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: id:t eller argumenten är inte UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumenten är inte ett JSON-objekt +@msg only a filter with the usual budget declares `commandAllowlist` bara ett filter med den vanliga budgeten anger `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) kommandoanrop ({MAX_CALLS} anrop och {MAX_CALL_BYTES} byte per körning) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) storlek på ett kommandoanrop (vc_call `{shown}`: {len} byte, gräns {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` högst {MAX_ALLOWED_COMMANDS} kommandon i `commandAllowlist` +@msg a plug-in may not call this command ett plugin får inte anropa det här kommandot +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` behöver behörigheten `commands`, och `commands` behöver en `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` anger `{shown}`, som inte är ett kommando-id eller finns två gånger Clear Plug-in Cache Töm pluginets cache @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `{permission}` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {signature} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dad8615f5..7a68530a4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5919,6 +5919,20 @@ axis Y Axis Y ekseni Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma Keep settings of its own Kendi ayarlarını saklama Keep a cache of its own, which you can clear Temizleyebileceğiniz kendi önbelleğini saklama + Run these commands on the document: {ids} Belgede şu komutları çalıştırma: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}”, `commandAllowlist` içinde bir eklentinin çağıramayacağı `{id}` komutunu listeliyor +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: bildirimin `commandAllowlist` listesinde yok +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: kimlik veya bağımsız değişkenler modülün belleğinin dışında +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: kimlik veya bağımsız değişkenler UTF-8 değil +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: bağımsız değişkenler bir JSON nesnesi değil +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` yalnızca olağan bütçeli bir filtrede bildirilebilir +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) komut çağrıları (çalıştırma başına {MAX_CALLS} çağrı ve {MAX_CALL_BYTES} bayt) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) komut çağrısı boyutu (vc_call `{shown}`: {len} bayt, sınır {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` içinde en fazla {MAX_ALLOWED_COMMANDS} komut +@msg a plug-in may not call this command bir eklenti bu komutu çağıramaz +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` için `commands` izni, `commands` için de bir `commandAllowlist` gerekir +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist`, komut kimliği olmayan veya iki kez listelenen `{shown}` öğesini adlandırıyor Clear Plug-in Cache Eklenti Önbelleğini Temizle @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin @msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {signature} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3fcc681b5..a7d62de80 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5560,6 +5560,20 @@ axis Y Axis Вісь Y Show its progress, see Cancel and write log lines Показувати свій поступ, помічати скасування і записувати рядки журналу Keep settings of its own Зберігати власні налаштування Keep a cache of its own, which you can clear Зберігати власний кеш, який можна очистити + Run these commands on the document: {ids} Виконувати в документі ці команди: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call «{name}» вказує в `commandAllowlist` команду `{id}`, яку модуль викликати не може +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: у `commandAllowlist` маніфесту її немає +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID або аргументи лежать поза пам’яттю модуля +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID або аргументи не в UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: аргументи не є об’єктом JSON +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist` може оголошувати лише фільтр зі звичайним бюджетом +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) виклики команд ({MAX_CALLS} викликів і {MAX_CALL_BYTES} байтів за запуск) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) розмір виклику команди (vc_call `{shown}`: {len} байтів, межа {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` щонайбільше {MAX_ALLOWED_COMMANDS} команд у `commandAllowlist` +@msg a plug-in may not call this command модуль не може викликати цю команду +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` для `commandAllowlist` потрібен дозвіл `commands`, а для `commands` потрібен `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` вказує `{shown}`, що не є ID команди або вказане двічі Clear Plug-in Cache Очистити кеш модуля @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `{permission}` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {signature} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5151b4a57..c7030dc86 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5561,6 +5561,20 @@ axis Y Axis Trục Y Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký Keep settings of its own Lưu cài đặt riêng Keep a cache of its own, which you can clear Lưu bộ nhớ đệm riêng mà bạn có thể xóa + Run these commands on the document: {ids} Chạy các lệnh này trên tài liệu: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}” liệt kê `{id}` trong `commandAllowlist`, lệnh mà plug-in không được gọi +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: `commandAllowlist` của manifest không liệt kê lệnh này +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID hoặc đối số nằm ngoài bộ nhớ của mô-đun +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID hoặc đối số không phải UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: đối số không phải là đối tượng JSON +@msg only a filter with the usual budget declares `commandAllowlist` chỉ bộ lọc có ngân sách thông thường mới khai báo `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) lệnh gọi lệnh ({MAX_CALLS} lần gọi và {MAX_CALL_BYTES} byte mỗi lần chạy) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) kích thước lệnh gọi lệnh (vc_call `{shown}`: {len} byte, giới hạn {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` tối đa {MAX_ALLOWED_COMMANDS} lệnh trong `commandAllowlist` +@msg a plug-in may not call this command plug-in không được gọi lệnh này +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` cần quyền `commands`, và `commands` cần một `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` nêu `{shown}`, không phải ID lệnh hoặc được liệt kê hai lần Clear Plug-in Cache Xóa bộ nhớ đệm của plug-in @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `{permission}` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {signature} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e527dbb1f..9897f0cf7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5801,6 +5801,20 @@ axis Y Axis Y 轴 Show its progress, see Cancel and write log lines 显示进度、检测取消请求并写入日志行 Keep settings of its own 保存自己的设置 Keep a cache of its own, which you can clear 保存自己的缓存,您可以清除它 + Run these commands on the document: {ids} 在文档上运行这些命令:{ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call “{name}”在 `commandAllowlist` 中列出了增效工具不能调用的 `{id}` +@msg vc_call `{shown}`: {why} vc_call `{shown}`:{why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`:清单的 `commandAllowlist` 未列出它 +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`:ID 或参数位于模块内存之外 +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`:ID 或参数不是 UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`:参数不是 JSON 对象 +@msg only a filter with the usual budget declares `commandAllowlist` 只有使用通常预算的滤镜才能声明 `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) 命令调用(每次运行 {MAX_CALLS} 次调用和 {MAX_CALL_BYTES} 字节) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) 命令调用大小(vc_call `{shown}`:{len} 字节,上限 {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` 中最多 {MAX_ALLOWED_COMMANDS} 个命令 +@msg a plug-in may not call this command 增效工具不能调用此命令 +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` 需要权限 `commands`,而 `commands` 需要 `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` 列出的 `{shown}` 不是命令 ID,或列出了两次 Clear Plug-in Cache 清除增效工具缓存 @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `{permission}` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {signature} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 16e6898bf..c1ec9dac2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5598,6 +5598,20 @@ axis Y Axis Y 軸 Show its progress, see Cancel and write log lines 顯示進度、偵測取消要求並寫入記錄行 Keep settings of its own 保存自己的設定 Keep a cache of its own, which you can clear 保存自己的快取,您可以清除它 + Run these commands on the document: {ids} 在文件上執行這些指令:{ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call 「{name}」在 `commandAllowlist` 中列出了增效模組不能呼叫的 `{id}` +@msg vc_call `{shown}`: {why} vc_call `{shown}`:{why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`:資訊清單的 `commandAllowlist` 未列出它 +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`:ID 或引數位於模組記憶體之外 +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`:ID 或引數不是 UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`:引數不是 JSON 物件 +@msg only a filter with the usual budget declares `commandAllowlist` 只有使用一般預算的濾鏡才能宣告 `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) 指令呼叫(每次執行 {MAX_CALLS} 次呼叫和 {MAX_CALL_BYTES} 位元組) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) 指令呼叫大小(vc_call `{shown}`:{len} 位元組,上限 {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` `commandAllowlist` 中最多 {MAX_ALLOWED_COMMANDS} 個指令 +@msg a plug-in may not call this command 增效模組不能呼叫此指令 +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` 需要權限 `commands`,而 `commands` 需要 `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` 列出的 `{shown}` 不是指令 ID,或列出了兩次 Clear Plug-in Cache 清除增效模組快取 @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `{permission}` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {signature} diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index a75171ae3..50f13e695 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -172,10 +172,13 @@ pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result String { +/// What permission `name` lets a plug-in do, in plain words in the UI language, with the ids of +/// the `commands` it may call for `commands`; a name the app doesn't know is shown as it is. +fn permission_words(name: &str, commands: &[String]) -> String { let words = match permissions::Permission::parse(name) { + Some(permissions::Permission::Commands) => { + return crate::i18n::fmt(tl!("Run these commands on the document: {ids}"), &[("ids", &commands.join(", "))]); + } Some(permissions::Permission::DocRead) => tl!("Read the objects it is given"), Some(permissions::Permission::DocWrite) => tl!("Change, add and delete objects in the document"), Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), @@ -244,7 +247,11 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result = info["commandAllowlist"] + .as_array() + .map(|ids| ids.iter().filter_map(Value::as_str).map(|id| permissions::display_text(id, permissions::MAX_SHOWN_FIELD)).collect()) + .unwrap_or_default(); + lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p, &commands)))); paragraphs.push(lines.join("\n")); install["grant"] = Value::Array(asked.clone()); } diff --git a/docs/mcp.md b/docs/mcp.md index a9b1f1cd8..c31523ad7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6915,7 +6915,13 @@ fit makes `plugin.run` return a memory-budget error naming the object and its si memory-budget error `the selection is too large to hand over`, and more than 100,000 of these objects make it return an object-budget error. 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 +field, and the document and undo history are unchanged. A filter at plug-in API level 2 whose manifest lists the +`commands` permission and a `commandAllowlist` (both in its `plugin.list` entry) runs the commands that list names +while it runs, through `vc_call`: the calls and its output are one undo step, and a run that fails or makes a call that +fails leaves the documents, selections and history as they were. A command that reads or writes files, uses the +clipboard, changes preferences or the view, runs a script, an action or a plug-in, switches documents or undoes is +never called; `plugin.install` refuses a manifest that lists one, and `plugin.inspect` lists such ids as +`refusedCommands` and ids no command has as `unknownCommands` (`plugin.install` lists those too). 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. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api` and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them diff --git a/docs/plugins.md b/docs/plugins.md index 890183c7f..c93e3266a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -27,11 +27,11 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, api, paintOutput?, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, commandAllowlist?, size, source, api, paintOutput?, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `commandAllowlist` lists the commands it may [call](#command-calls); `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`; an `ids` that is not a list of object ids is an error). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a grant is already recorded for a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when installing it needs a person's approval) and `needsReplace` (true when `plugin.install` needs `replace: true`). | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a grant is already recorded for a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. A module whose `commandAllowlist` lists a command no plug-in may call is refused; ids no command has are listed as `unknownCommands` ([command calls](#command-calls)). | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when installing it needs a person's approval), `needsReplace` (true when `plugin.install` needs `replace: true`), and the `commandAllowlist` ids no command has (`unknownCommands`) and those no plug-in may call (`refusedCommands`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). | | `plugin.clearCache` | `{"id"?}` | Deletes every entry in the [cache](#settings-and-cache) of plug-in `id`, or of every installed plug-in; settings stay. Returns `{"cleared": [id], "bytesFreed"}`. | | `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | @@ -178,6 +178,7 @@ capability model proposed for PhotoCraft plug-ins | `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | | `settings` | all | Keep settings of its own between runs and sessions ([settings and cache](#settings-and-cache)). Needs `api` 2. | | `cache` | all | Keep a cache of its own, which the user can clear ([settings and cache](#settings-and-cache)). Needs `api` 2. | +| `commands` | filter | Run the commands its `commandAllowlist` lists on the document ([command calls](#command-calls)). Needs `api` 2. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`, and also `doc.write` and `state` @@ -186,9 +187,10 @@ capability model proposed for PhotoCraft plug-ins these, and also installs without a prompt. - An effect, an exporter or an importer that lists permissions lists all of its implied ones (`doc.read` and `effect.render`; `doc.read` and `export.write`; `import.read`). An effect with commands may add `doc.write` and - `state`, which only its commands use. A filter may list any of its three. + `state`, which only its commands use. A filter may list any of its three, and `commands` with a `commandAllowlist`. Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a - name that doesn't apply to the kind, more than 16 names, `progress`, `settings` or `cache` with an `api` below 2, or + name that doesn't apply to the kind, more than 16 names, `progress`, `settings`, `cache` or `commands` with an `api` + below 2, or one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of @@ -308,7 +310,8 @@ module's memory. A plug-in whose manifest gives `"api": 2` and lists the [permission](#permissions) an import needs may import these functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version` still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; the `vc_settings_*` imports need -`settings` and the `vc_cache_*` imports need `cache` ([settings and cache](#settings-and-cache)). +`settings` and the `vc_cache_*` imports need `cache` ([settings and cache](#settings-and-cache)); `vc_call` and +`vc_call_result` need `commands` ([command calls](#command-calls)). | Import | Signature | Meaning | |---|---|---| @@ -389,6 +392,44 @@ A live effect runs while the art draws, so keep the entries it uses small. `plugin.info` give the cache's size as `cacheBytes`. - Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. +### Command calls + +A filter whose manifest lists the `commands` permission and names commands in `commandAllowlist` runs those commands +on the document while it runs (#2186). It names each command by its id, as `command_list` lists it, and passes its +arguments as a JSON object, the params the command takes from any request: + +| Import | Signature | Meaning | +|---|---|---| +| `vc_call` | `(id, id_len, args, args_len) -> i32` | Runs the command whose UTF-8 id is at `id` with the JSON object at `args` (`args_len` 0 means `{}`). Returns `0` when it ran, else a negative code. | +| `vc_call_result` | `(out, out_cap) -> i32` | The JSON result of the last call that ran: its length in bytes. When it fits in the `out_cap` bytes at `out`, its bytes are copied there; otherwise nothing is copied. `-10` when no call has a result, `-1` when the buffer doesn't lie inside the module's memory. | + +`vc_call` returns `-1` when the id or the arguments don't lie inside the module's memory, `-2` when they are not UTF-8, +`-3` when the arguments are not a JSON object, `-4` when `commandAllowlist` doesn't list the id, `-5` when no command +has the id, `-6` when a plug-in may not call the command, `-7` when the command can't run now (nothing is selected), +`-8` when the command ran and failed, `-9` when a limit below is passed, and `-11` when the run has no document to +call commands on. + +- Every code but `-11` also fails the run, whatever the plug-in does next, and the error names the call and why it + failed. The run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, + traps, runs out of budget or is cancelled leaves every document, its selection and its history as they were. +- Each call runs on the document the run is for, with its params checked as any request's are, and sees what the calls + before it changed: a `select.all` followed by `object.move` moves everything. The filter's own input is the one it + was given when the run started. +- A plug-in never calls a command that reads or writes a file or folder, uses the clipboard, changes preferences or + the view, runs a script, an action or a plug-in (a `plugin.*` command, or arguments naming one, such as an + `effect.apply` of `plugin.`), opens, closes or switches documents, or undoes or redoes, whatever its manifest + lists. The same goes for a command that changes a tool's options, keeps a drag's state or adds font names for the + whole app, and for a command VectorCraft adds later until it has been checked for plug-in calls. Installing a manifest that lists such a command is refused, and the error names it; `plugin.inspect` lists + them as `refusedCommands`. +- An id in `commandAllowlist` that no command has installs, so a plug-in can list a command a later version adds; + `plugin.install` and `plugin.inspect` list such ids as `unknownCommands`, and calling one returns `-5`. +- Without `doc.write`, a call that changes the document fails with `-6`. +- These limits are VectorCraft's own values: at most 1,000 calls a run, 8 MiB of arguments or result a call and 64 MiB + of arguments and results a run. Each call costs 10,000 instructions of the run's budget, plus one for every 64 bytes + of its arguments and its result, and the run checks Cancel and its time budget before and after each call. +- A filter that calls commands runs on the app's main thread, so it can't ask for a heavy budget: a manifest with + `commandAllowlist` and `budget: "heavy"` is refused. + ### Manifest ```json @@ -437,6 +478,10 @@ A live effect runs while the art draws, so keep the entries it uses small. `a-z 0-9 _` that starts with a letter, and appears once. A `label` is 1–64 printable characters; menus show it with control and bidirectional characters escaped. `params` are declared as the plug-in's own `params` are. A malformed entry is refused when the module is installed, and the error names the command by its id (or its place, `[0]`). +- `commandAllowlist` (filters at `api` 2 with the usual budget, and only with the `commands` permission, which needs + it): the ids of the commands the filter may [call](#command-calls), at most 256, each once (`"commandAllowlist": + ["select.all", "object.move"]`). An id is 1–128 characters from `A-Z a-z 0-9 . _ -` that starts with a letter, with + no empty part between dots. A malformed list is refused when the module is installed, and the error names the id. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c76d781c8..14b900f2d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1613,6 +1613,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is cancelled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.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. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/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 settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.