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