Plug-ins: diffPath is a file key and Slice Options' url is not; the command sweep uses these keys

The sweeps in command_sweep.rs kept their own list of file and folder
keys (path, diffPath, folder, destinationFolder) in testkit's
safe_value and again in the temp-folder test, so a command whose file
key was not on the list was sent raw junk strings, and a key written as
`{path | name ...}` (document.open) was never swept.

- The engine exports the plug-in call rules' file keys as
  `cmd::key_location`, `cmd::location_in` and `cmd::location_keys`,
  which read every key a params description names, the keys of the
  commands its `...` entries name, and the schema. `diffPath` is a file
  key, so a plug-in call that carries it is refused; Slice Options'
  `url` and Export for Screens' `folders` name part of the document, so
  a call to object.slice.options with a top-level `url` is accepted.
- testkit's `param_keys` reads the keys the engine reads, and
  `junk_params_for_command` and `safe_value_for_command` send every
  string under a file or folder key, in arrays and in objects inside
  them, as a location in the sweep's temp folder. Each such key also
  gets a list of paths and a list of `{ids, path}` objects.
- Every catalog sweep, the serializability sweep included, uses them,
  and the temp-folder test checks every key the catalog names, at any
  depth.
This commit is contained in:
dotCooCoo
2026-11-12 06:36:56 -08:00
parent 71b889605d
commit bf9baa0b85
5 changed files with 314 additions and 63 deletions
+1
View File
@@ -771,6 +771,7 @@ pub use docmenu::guide_indexes;
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use object::bounds_rewrite;
pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned};
pub use plugin_call::{Location, key_location, location_in, location_keys};
pub(crate) use vectorcraft_doc::range::parse_range;
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
+85 -2
View File
@@ -102,7 +102,53 @@ const FILE_WORDS: &[&str] = &[
];
/// Param keys that name a file or a folder.
const FILE_KEYS: &[&str] = &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url"];
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.
@@ -119,6 +165,8 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[
("attributes.set", "url"),
("svgInteractivity.info", "files"),
("addon.cleanup.rejoin", "paths"),
("object.slice.options", "url"),
("document.exportForScreens", "folders"),
];
/// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`],
@@ -466,7 +514,8 @@ mod tests {
}
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 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()
@@ -565,6 +614,40 @@ mod tests {
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"));
+146 -43
View File
@@ -9,11 +9,11 @@ use vectorcraft_engine::{Session, command_specs};
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::Fixture;
use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value};
use vectorcraft_testkit::strategies::{command_param_keys, junk_params_for_command, junk_values, param_keys, safe_value_for_command};
/// Commands that replace/close the active document or touch the filesystem are fine to call, but a
/// few need their file and folder params redirected into a temp dir (`safe_value`, which
/// `junk_params` applies).
/// Commands that replace/close the active document or touch the filesystem are fine to call, but
/// their file and folder params are redirected into a temp dir (`safe_value_for_command`, which
/// `junk_params_for_command` applies, by the keys the command catalog names as files and folders).
fn safe_path() -> String {
vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
}
@@ -190,6 +190,33 @@ fn heavy(id: &str) -> bool {
id.starts_with("document.serialize") || id.starts_with("document.export") || id == "document.open"
}
/// Is `p` a params object with `k` as its only key?
fn sends_alone(p: &Value, k: &str) -> bool {
p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key(k))
}
/// The junk params the sweeps send `c`: all of them, or for a [`heavy`] command the first 12 and,
/// for each key the command catalog names as a file or folder that no case among those sends alone
/// ([`sends_alone`]), the first case that sends a string under it alone (or any value when none
/// does).
fn junk_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
let mut cases = junk_params_for_command(c, path);
if heavy(c.id) {
let rest = cases.split_off(cases.len().min(12));
for (k, _) in vectorcraft_engine::cmd::location_keys(c) {
if cases.iter().any(|p| sends_alone(p, k)) {
continue;
}
let alone = || rest.iter().filter(|p| sends_alone(p, k));
let found = alone().find(|p| p.get(k).is_some_and(Value::is_string)).or_else(|| alone().next());
if let Some(p) = found {
cases.push(p.clone());
}
}
}
cases
}
#[test]
fn every_command_with_junk_params() {
let path = safe_path();
@@ -197,10 +224,7 @@ fn every_command_with_junk_params() {
let mut calls = 0usize;
for fx in Fixture::ALL {
for c in command_specs() {
let mut cases = junk_params(c.params, &path);
if heavy(c.id) {
cases.truncate(12);
}
let cases = junk_cases(c, &path);
for p in &cases {
calls += 1;
if let Some(f) = probe(fx, c.id, p) {
@@ -234,13 +258,10 @@ fn tool_edit_state(s: &Session) -> ToolEditState {
/// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and
/// off, with a string under a file or folder key replaced by a location inside `path`'s folder.
fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
let mut cases = junk_params(c.params, path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
let mut cases = junk_cases(c, path);
for k in command_param_keys(c) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): safe_value(&k, &v, path) }));
cases.push(json!({ k.as_str(): safe_value_for_command(c, &k, &v, path) }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
@@ -250,35 +271,109 @@ fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value
cases
}
/// Every string the sweeps send under a key that names a file or folder to write is inside the
/// sweep's temp folder, so no run writes into the working directory.
/// The strings in `v`, the value at `at` in a case of command `id`, that name a place on disk
/// outside `dir`, as `id at: value`: `v` itself and the members of arrays in it when `named` (it is
/// under a key that names a file or folder), and in objects at any depth, the values under such
/// keys ([`names_location`]).
fn outside_locations(id: &str, at: &str, v: &Value, named: bool, dir: &std::path::Path, out: &mut std::collections::BTreeSet<String>) {
match v {
Value::String(s) if named && !std::path::Path::new(s).starts_with(dir) => {
out.insert(format!("{id} {at}: {v}"));
}
Value::Array(a) => {
for (i, x) in a.iter().enumerate() {
outside_locations(id, &format!("{at}[{i}]"), x, named, dir, out);
}
}
Value::Object(o) => {
for (k, x) in o {
outside_locations(id, &format!("{at}.{k}"), x, names_location(None, k), dir, out);
}
}
_ => {}
}
}
/// Does `key` name a file or folder, in command `spec` when given: a key in
/// `vectorcraft_engine::cmd::key_location`'s list that `vectorcraft_engine::cmd::location_in` keeps
/// for `spec`.
fn names_location(spec: Option<&vectorcraft_engine::CommandSpec>, key: &str) -> bool {
match spec {
Some(spec) => vectorcraft_engine::cmd::location_in(spec, key).is_some(),
None => vectorcraft_engine::cmd::key_location(key).is_some(),
}
}
/// Every string the sweeps send under a key that names a file or folder, in the command catalog
/// (`vectorcraft_engine::cmd::location_keys`, so a command added later is checked too) or inside
/// an object or an array at any depth, is inside the sweep's temp folder, so no run reads or
/// writes in the working directory. `every_command_with_junk_params` sends every such key of every
/// command alone in at least one case ([`junk_cases`]).
#[test]
fn sweep_cases_name_files_only_inside_the_temp_folder() {
let path = safe_path();
let dir = vectorcraft_testkit::temp_dir("sweep");
let keys = ["path", "diffPath", "folder", "destinationFolder"];
let mut seen = std::collections::BTreeSet::new();
let mut unsent = vec![];
let mut outside = std::collections::BTreeSet::new();
for c in command_specs() {
let mut cases = junk_params(c.params, &path);
let keys = vectorcraft_engine::cmd::location_keys(c);
let run = junk_cases(c, &path);
for (k, _) in &keys {
if !run.iter().any(|p| sends_alone(p, k)) {
unsent.push(format!("{} {k}", c.id));
}
}
let mut cases = junk_params_for_command(c, &path);
cases.extend(tool_edit_cases(c, &path));
cases.extend(serializable_cases(c, &path));
for p in &cases {
let Some(o) = p.as_object() else { continue };
for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) {
seen.insert(k.clone());
if let Some(s) = v.as_str()
&& !std::path::Path::new(s).starts_with(&dir)
{
outside.insert(format!("{} {{\"{k}\": {v}}}", c.id));
}
for (k, v) in o {
outside_locations(c.id, k, v, names_location(Some(c), k), &dir, &mut outside);
}
}
}
assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}");
assert!(unsent.is_empty(), "every_command_with_junk_params sends no case for {unsent:?}");
let outside: Vec<String> = outside.into_iter().collect();
assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n"));
}
/// The cases sweep keys the command catalog names as files and folders: `document.open`'s
/// `{path | name …}`, `addon.document.saveTimestampedCopy`'s `{folder? (…)}`, `document.compare`'s
/// `diffPath`, `file.saveAs`'s `path` after `{…as document.save}` and `links.relink`'s
/// `paths: [{ids, path}]`, each with a string in the temp folder, and a folder key a folder there.
#[test]
fn sweep_cases_send_the_file_keys_of_the_command_catalog() {
let path = safe_path();
let dir = vectorcraft_testkit::temp_dir("sweep");
let spec = |id: &str| command_specs().iter().find(|c| c.id == id).unwrap();
let inside = |v: &Value| v.as_str().is_some_and(|s| std::path::Path::new(s).starts_with(&dir));
for (id, key) in [
("document.open", "path"),
("addon.document.saveTimestampedCopy", "folder"),
("document.compare", "diffPath"),
("text.addFontFiles", "files"),
("file.saveAs", "path"),
] {
let cases = junk_params_for_command(spec(id), &path);
let sent: Vec<&Value> = cases.iter().filter_map(|p| p.get(key)).collect();
assert!(sent.iter().any(|v| inside(v) || v.as_array().is_some_and(|a| a.iter().any(inside))), "{id} sends no `{key}` in the temp folder");
}
for (id, key) in
[("document.open", "path"), ("document.open", "name"), ("addon.document.saveTimestampedCopy", "folder"), ("links.relink", "folder")]
{
assert!(param_keys(spec(id).params).iter().any(|k| k == key), "{id} has no `{key}` key");
}
let folder = junk_params_for_command(spec("addon.document.saveTimestampedCopy"), &path);
assert!(folder.iter().filter_map(|p| p.get("folder")?.as_str()).all(|s| s != path && std::path::Path::new(s).starts_with(&dir)));
let relink = junk_params_for_command(spec("links.relink"), &path);
let nested = relink.iter().filter_map(|p| p.get("paths")?.as_array()).flatten().filter_map(|e| e.get("path")).filter(|v| v.is_string());
assert!(nested.clone().count() > 0 && nested.clone().all(inside), "links.relink sends no `paths[].path` in the temp folder");
// A key that names part of the document in a command keeps its junk there.
let on_path = junk_params_for_command(spec("text.createInPath"), &path);
assert!(on_path.iter().any(|p| p.get("path") == Some(&json!("junk"))));
}
/// Every command that runs at once while the active tool computes an edit on another thread (a
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
/// documents' revisions and selections, the active document and the current paint as they were,
@@ -1007,28 +1102,36 @@ fn polygon_properties_junk() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// The params `fuzzed_calls_keep_documents_serializable` sends `c`: each key with each of the
/// first junk values, a string under a file or folder key replaced by a location inside `path`'s
/// folder.
fn serializable_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
if heavy(c.id) || c.id.starts_with("file.") {
return vec![];
}
let mut cases = vec![];
for v in junk_values().iter().take(8) {
for k in command_param_keys(c) {
cases.push(json!({ k.as_str(): safe_value_for_command(c, &k, v, path) }));
}
}
cases
}
/// After a successful fuzzed call, the document still round-trips and exports.
#[test]
fn fuzzed_calls_keep_documents_serializable() {
let path = safe_path();
let mut failures = vec![];
for c in command_specs() {
if heavy(c.id) || c.id.starts_with("file.") {
continue;
}
for v in junk_values().iter().take(8) {
for k in param_keys(c.params) {
let k = if k == "path" { continue } else { k };
let mut s = Fixture::Multi.session();
let p = json!({ k.clone(): v });
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
failures.push(format!("{} {p}: {e}", c.id));
}
for p in serializable_cases(c, &path) {
let mut s = Fixture::Multi.session();
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
failures.push(format!("{} {p}: {e}", c.id));
}
}
}
let _ = path;
failures.sort();
failures.dedup();
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
@@ -1091,7 +1194,7 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() {
for fx in Fixture::ALL {
for id in ["file.recovery.save", "file.recovery.list", "file.recovery.restore", "file.recovery.discard"] {
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) {
let mut s = fx.session();
s.recovery.set_store(store.clone());
let r = catch_quiet(|| no_panic(s.execute(id, &p)));
@@ -1120,7 +1223,7 @@ fn envelope_commands_with_junk_params_on_envelopes() {
command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("object.envelope.") || id.starts_with("object.mesh.")).collect();
for id in ids {
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) {
let mut s = Fixture::Multi.session();
if s.execute(make, mp).is_err() {
failures.push(format!("{make} failed on the Multi fixture"));
@@ -1217,7 +1320,7 @@ fn text_commands_with_junk_params_on_inline_graphics() {
}
for id in ["text.insertInline", "text.editRange", "text.getRange", "text.setRangeStyle", "type.insert"] {
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) {
run(id, &p);
}
}
+81 -18
View File
@@ -2,7 +2,8 @@
use proptest::prelude::*;
use serde_json::{Value, json};
use vectorcraft_engine::{Session, command_specs};
use vectorcraft_engine::cmd::{Location, key_location, location_in, location_keys};
use vectorcraft_engine::{CommandSpec, Session, command_specs};
use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath};
use crate::fixtures::{art_nodes, top_level_ids};
@@ -473,8 +474,11 @@ pub fn all_param_keys() -> Vec<String> {
keys.into_iter().collect()
}
/// Keys of a params doc string like `{x, y, width?, radius?: pt} → {id}` (only the part before `→`).
/// Keys of a params doc string like `{x, y, width?, radius?: pt} → {id}` (only the part before `→`),
/// with every key the engine reads in it ([`vectorcraft_engine::cmd::params::doc_keys`]: `path | name`,
/// `folder? (…)`).
pub fn param_keys(doc: &str) -> Vec<String> {
let described = vectorcraft_engine::cmd::params::doc_keys(doc).map(|p| p.keys).unwrap_or_default();
let doc = doc.split('→').next().unwrap_or("");
let chars: Vec<char> = doc.chars().collect();
let mut out = vec![];
@@ -497,55 +501,114 @@ pub fn param_keys(doc: &str) -> Vec<String> {
i += 1;
}
}
out.extend(described.into_iter().map(str::to_string));
out.sort();
out.dedup();
out
}
/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
/// arbitrary strings could write files anywhere, so callers substitute a temp path.
/// The keys of command `spec`: those of its params doc ([`param_keys`]), of its schema and its file
/// and folder keys ([`location_keys`]).
pub fn command_param_keys(spec: &CommandSpec) -> Vec<String> {
let mut keys = param_keys(spec.params);
keys.extend(spec.schema.into_iter().flat_map(|s| s.params.iter().map(|p| p.name.to_string())));
keys.extend(location_keys(spec).into_iter().map(|(k, _)| k.to_string()));
keys.sort();
keys.dedup();
keys
}
/// Parameter-name keys that name a file or folder in some command ([`key_location`]): fuzzing these
/// with arbitrary strings could write files anywhere, so callers substitute a temp path.
pub fn is_path_key(k: &str) -> bool {
matches!(k, "path" | "diffPath") || is_folder_key(k)
key_location(k).is_some()
}
/// Path keys that name a folder a command creates or writes files into.
/// Path keys that name a folder a command reads, creates or writes files into ([`key_location`]):
/// `folder`, `folders`, `dir`, `directory`, `destinationFolder` and `sourceFolder`.
pub fn is_folder_key(k: &str) -> bool {
matches!(k, "folder" | "destinationFolder")
key_location(k) == Some(Location::Folder)
}
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
/// a folder key.
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it (in
/// the same temp folder) for a folder key.
pub fn safe_location(k: &str, safe_path: &str) -> String {
if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() }
location(key_location(k).unwrap_or(Location::File), safe_path)
}
/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string.
/// The temp location for a key that names `l`: `safe_path` for a file, a folder next to it (in
/// the same temp folder) for a folder.
fn location(l: Location, safe_path: &str) -> String {
if l == Location::Folder { format!("{safe_path}-folder") } else { safe_path.to_string() }
}
/// `v` with every string in it that can name a place on disk sent to the temp location: `v`
/// itself and the members of arrays in it, at any depth, when `kind` is `Some`, and in an object
/// at any depth, the strings under a path key ([`is_path_key`]). Other values keep their junk
/// shape.
fn redirect(kind: Option<Location>, v: &Value, safe_path: &str) -> Value {
match v {
Value::String(_) => kind.map_or_else(|| v.clone(), |l| json!(location(l, safe_path))),
Value::Array(a) => Value::Array(a.iter().map(|x| redirect(kind, x, safe_path)).collect()),
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| (k.clone(), redirect(key_location(k), x, safe_path))).collect()),
_ => v.clone(),
}
}
/// `v`, with its strings sent to the temp location when `k` is a path key in any command
/// ([`is_path_key`]), and the strings under path keys in objects inside it too.
pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value {
if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() }
redirect(key_location(k), v, safe_path)
}
/// `v` as [`safe_value`] makes it for command `spec` ([`location_in`]): a key that names part of
/// the document in it keeps its strings.
pub fn safe_value_for_command(spec: &CommandSpec, k: &str, v: &Value, safe_path: &str) -> Value {
redirect(location_in(spec, k), v, safe_path)
}
/// A deterministic list of junk param objects for `doc` (the command's params doc): each
/// documented key set to each junk value, all keys junk at once, wrong top-level types.
/// documented key set to each junk value, all keys junk at once, wrong top-level types. A string
/// under a key that names a file or folder in any command ([`is_path_key`]) is a temp location.
pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
let keys = param_keys(doc);
junk_params_with(&param_keys(doc), |k, v| safe_value(k, v, safe_path), key_location, safe_path)
}
/// [`junk_params`] for command `spec`, with the keys of its schema and its file and folder keys too
/// ([`command_param_keys`]), and a string under each key that names a file or folder in it
/// ([`location_in`]) a temp location, as are the strings in an array of them and under the path
/// keys of objects in one (`paths: [{ids, path}]`), which it also sends.
pub fn junk_params_for_command(spec: &CommandSpec, safe_path: &str) -> Vec<Value> {
junk_params_with(&command_param_keys(spec), |k, v| safe_value_for_command(spec, k, v, safe_path), |k| location_in(spec, k), safe_path)
}
/// The junk cases for `keys`: `safe(k, v)` is what is sent for junk value `v` under `k`, and
/// `kind(k)` what `k` names on disk, if anything, whose keys also get a list of locations and a
/// list of objects with a `path`.
fn junk_params_with(keys: &[String], safe: impl Fn(&str, &Value) -> Value, kind: impl Fn(&str) -> Option<Location>, safe_path: &str) -> Vec<Value> {
let junk = junk_values();
let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})];
for k in &keys {
for k in keys {
for (i, v) in junk.iter().enumerate() {
let v = safe_value(k, v, safe_path);
let v = safe(k, v);
// Sparse: one key at a time with every junk value, plus a few combos.
let mut o = serde_json::Map::new();
o.insert(k.clone(), v.clone());
out.push(Value::Object(o));
if i % 5 == 0 {
let mut all = serde_json::Map::new();
for k2 in &keys {
let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() };
for k2 in keys {
let v2 = kind(k2).map_or_else(|| v.clone(), |l| json!(location(l, safe_path)));
all.insert(k2.clone(), v2);
}
out.push(Value::Object(all));
}
}
if let Some(l) = kind(k) {
for v in [json!(["junk", "", 1]), json!([{"ids": [1], "path": "junk"}, {"path": null}])] {
out.push(json!({ k.as_str(): redirect(Some(l), &v, safe_path) }));
}
}
}
out
}
+1
View File
@@ -1726,6 +1726,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **A grant that can't be forgotten stops counting in the whole process (G17):** when such a grant can't be removed from `grants.json`, no session in the process that uses the same `Plug-ins` folder, by any path to it, counts it for `plugin.inspect`, `plugin.install` or loading plug-ins, until `grants.json` can be written for that plug-in; another process on the folder reads `grants.json` as it is. Regressions: `crates/engine/src/tests/plugins.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.<id>.<command>` 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 canceled 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.
- **The command sweep sends file and folder params into its temp folder by the command catalog:** the sweeps in `crates/engine/tests/command_sweep.rs` take each command's file and folder keys from the catalog (`cmd::location_keys`, the same key list the plug-in call rules use, less the keys that name part of the document in a command), read every key a params description names (`{path | name …}`, `{folder? (…)}` included) and its schema's params, and send every string under such a key, in arrays and in objects inside them (`paths: [{ids, path}]`), as a location in the temp folder, so a command added later is swept and checked without editing the sweep. `diffPath` counts as a file key, and Slice Options' `url` and Export for Screens' `folders` do not. Regressions: `sweep_cases_name_files_only_inside_the_temp_folder` and `sweep_cases_send_the_file_keys_of_the_command_catalog` in `command_sweep`, `location_keys_come_from_the_command_catalog` in `cmd::plugin_call`. Scores and hours unchanged.
- **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.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`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, 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.