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