mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:42:42 +00:00
Merge pull request #7203 from dotCooCoo/fix/file-folder-param-classification
Automation: one classification of file and folder params
This commit is contained in:
@@ -827,7 +827,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 use plugin_call::{Location, ParamsPlace, 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
|
||||
|
||||
@@ -183,7 +183,7 @@ pub(crate) fn accepted_keys(doc: &'static str) -> Option<Vec<&'static str>> {
|
||||
|
||||
/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands
|
||||
/// its `…` entries name)?
|
||||
pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
|
||||
pub fn takes_key(doc: &'static str, key: &str) -> bool {
|
||||
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
|
||||
}
|
||||
|
||||
|
||||
@@ -101,12 +101,30 @@ const FILE_WORDS: &[&str] = &[
|
||||
"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"];
|
||||
/// Param keys that name a file or a folder, each by its whole name, the folder preferences
|
||||
/// `prefs.set {values}` takes included: a command that adds a key naming one adds it here. A key
|
||||
/// that only ends like one (`alignToPath`, `linksFolder`, `sortPaths`) names none, nor do `folders`
|
||||
/// (how an export groups its files) and `url` (a web link).
|
||||
const FILE_KEYS: &[&str] = &[
|
||||
"path",
|
||||
"paths",
|
||||
"file",
|
||||
"files",
|
||||
"folder",
|
||||
"dir",
|
||||
"directory",
|
||||
"destinationFolder",
|
||||
"sourceFolder",
|
||||
"diffPath",
|
||||
"fontsFolder",
|
||||
"pluginsFolder",
|
||||
"recoveryFolder",
|
||||
"templatesFolder",
|
||||
];
|
||||
|
||||
/// The keys in [`FILE_KEYS`] that name a folder.
|
||||
const FOLDER_KEYS: &[&str] = &["folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder"];
|
||||
const FOLDER_KEYS: &[&str] =
|
||||
&["folder", "dir", "directory", "destinationFolder", "sourceFolder", "fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// What a param key that names a place on disk names there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -132,7 +150,83 @@ pub fn key_location(key: &str) -> Option<Location> {
|
||||
/// 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) }
|
||||
ParamsPlace::top(Some(spec)).member(key, None).1
|
||||
}
|
||||
|
||||
/// A place in the params of a command. What a key names on disk depends on the command and on the
|
||||
/// keys above it: [`key_location`], less a key [`NOT_FILES`] names for the command at the top of its
|
||||
/// params and one at the end of a [`NESTED_NOT_FILES`] key list. A walk of params starts at
|
||||
/// [`ParamsPlace::top`] and goes down with [`ParamsPlace::member`] and [`ParamsPlace::item`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ParamsPlace {
|
||||
/// The command whose params these are; `None` for a command that isn't known.
|
||||
command: Option<&'static str>,
|
||||
/// Whether this is the top of the params.
|
||||
top: bool,
|
||||
/// The keys of a [`NESTED_NOT_FILES`] list still to follow below this place.
|
||||
trail: Option<&'static [&'static str]>,
|
||||
}
|
||||
|
||||
impl ParamsPlace {
|
||||
/// The top of the params of `spec`, or of a command that isn't known (`None`), where every key in
|
||||
/// [`FILE_KEYS`] names a file or a folder.
|
||||
pub fn top(spec: Option<&CommandSpec>) -> ParamsPlace {
|
||||
ParamsPlace { command: spec.map(|s| s.id), top: true, trail: None }
|
||||
}
|
||||
|
||||
/// The top of the params of the command with id `id` ([`ParamsPlace::top`]).
|
||||
pub fn of(id: &str) -> ParamsPlace {
|
||||
ParamsPlace::top(super::find_command(id))
|
||||
}
|
||||
|
||||
/// The top of the params of the command that object `fields` calls, when it is a call
|
||||
/// (`{command | id: <command id>, params}`, as a `command.batch` step or a control request is):
|
||||
/// the place of its `params` member ([`ParamsPlace::member`]).
|
||||
pub fn carried(fields: &serde_json::Map<String, Value>) -> Option<ParamsPlace> {
|
||||
if !fields.contains_key("params") {
|
||||
return None;
|
||||
}
|
||||
let id = fields.get("command").or_else(|| fields.get("id")).and_then(Value::as_str)?;
|
||||
super::find_command(id).map(|spec| ParamsPlace::top(Some(spec)))
|
||||
}
|
||||
|
||||
/// The top of the params of the command that array `items` records, when it is a recorded step
|
||||
/// (`[command id, params]`): the place of its item 1 ([`ParamsPlace::item`]).
|
||||
pub fn step(items: &[Value]) -> Option<ParamsPlace> {
|
||||
match items {
|
||||
[Value::String(id), Value::Object(_)] => super::find_command(id).map(|spec| ParamsPlace::top(Some(spec))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The place of member `key` of an object here, with what `key` names on disk there. `carried` is
|
||||
/// [`ParamsPlace::carried`] of that object: its `params` member is then the top of that command's
|
||||
/// params, and names no file.
|
||||
pub fn member(self, key: &str, carried: Option<ParamsPlace>) -> (ParamsPlace, Option<Location>) {
|
||||
if key == "params"
|
||||
&& let Some(inner) = carried
|
||||
{
|
||||
return (inner, None);
|
||||
}
|
||||
let rest = if self.top {
|
||||
let id = self.command;
|
||||
NESTED_NOT_FILES.iter().find(|(c, keys)| Some(*c) == id && keys.first() == Some(&key)).map(|(_, keys)| keys.get(1..).unwrap_or_default())
|
||||
} else {
|
||||
self.trail.filter(|keys| keys.first() == Some(&key)).map(|keys| keys.get(1..).unwrap_or_default())
|
||||
};
|
||||
let document = (self.top && self.command.is_some_and(|id| NOT_FILES.contains(&(id, key)))) || rest.is_some_and(<[&str]>::is_empty);
|
||||
let place = ParamsPlace { command: self.command, top: false, trail: rest.filter(|keys| !keys.is_empty()) };
|
||||
(place, if document { None } else { key_location(key) })
|
||||
}
|
||||
|
||||
/// The place of item `index` of an array here. `step` is [`ParamsPlace::step`] of that array: its
|
||||
/// item 1 is then the top of that command's params.
|
||||
pub fn item(self, index: usize, step: Option<ParamsPlace>) -> ParamsPlace {
|
||||
match step {
|
||||
Some(inner) if index == 1 => inner,
|
||||
_ => ParamsPlace { command: self.command, top: false, trail: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The param keys of `spec` that name a file or a folder, with what they name: the keys in
|
||||
@@ -158,9 +252,6 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[
|
||||
("symbol.instance.setAppearance", "path"),
|
||||
("symbol.instance.selectPart", "path"),
|
||||
("object.objectsOnPath.attach", "path"),
|
||||
("attributes.set", "url"),
|
||||
("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,
|
||||
@@ -251,34 +342,24 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Does `args`, the arguments of a call of command `id`, carry a key that names a file or a folder at
|
||||
/// any depth (in objects and in arrays, [`ParamsPlace`]), 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. A call or a recorded step inside `args`
|
||||
/// is read as part of `id`'s arguments, not as the command it names. 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() {
|
||||
let mut stack: Vec<(&Value, ParamsPlace)> = vec![(args, ParamsPlace::of(id))];
|
||||
while let Some((v, place)) = stack.pop() {
|
||||
match v {
|
||||
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))),
|
||||
Value::Array(a) => stack.extend(a.iter().enumerate().map(|(i, x)| (x, place.item(i, 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 {
|
||||
let (below, location) = place.member(k, None);
|
||||
if location.is_some() {
|
||||
return true;
|
||||
}
|
||||
stack.push((x, false, rest.filter(|keys| !keys.is_empty())));
|
||||
stack.push((x, below));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -638,7 +719,13 @@ mod tests {
|
||||
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("select.all", &json!([[[{"file": "file:///etc/hosts"}]]])));
|
||||
// A web link and an export's grouping name no file.
|
||||
assert!(!names_file("attributes.set", &json!({"url": "https://example.com/a"})));
|
||||
assert!(!names_file("object.move", &json!({"opts": {"folders": "rows", "alignToPath": "baseline"}})));
|
||||
// A call nested in the arguments doesn't vouch for its own keys.
|
||||
assert!(names_file("object.move", &json!({"opts": {"command": "object.setLiveShape", "params": {"path": "/tmp/x"}}})));
|
||||
assert!(names_file("object.move", &json!({"opts": ["object.setLiveShape", {"path": "/tmp/x"}]})));
|
||||
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 the path text is set on in text.createInPath, but only as its own param.
|
||||
@@ -647,7 +734,13 @@ mod tests {
|
||||
assert!(names_file("object.move", &json!({"path": [[0, 0]]})));
|
||||
// object.setLiveShape takes no `path`; its description says "on any path:" in its prose.
|
||||
assert!(names_file("object.setLiveShape", &json!({"path": "/tmp/x"})));
|
||||
// Image Trace's path fidelity setting, only inside its tracing settings.
|
||||
}
|
||||
|
||||
/// Image Trace's path fidelity setting `paths` names no file, but only inside its tracing
|
||||
/// settings.
|
||||
#[test]
|
||||
fn image_trace_paths_name_no_file_only_in_its_settings() {
|
||||
use serde_json::json;
|
||||
for id in ["imageTrace.make", "imageTrace.makeAndExpand"] {
|
||||
assert!(!names_file(id, &json!({"preset": "Default", "params": {"mode": "color", "paths": 50}})));
|
||||
assert!(!names_file(id, &json!({"savedPreset": {"paths": 50, "corners": 75}})));
|
||||
@@ -658,7 +751,12 @@ mod tests {
|
||||
assert!(names_file(id, &json!({"options": {"paths": 50}})));
|
||||
}
|
||||
assert!(names_file("object.move", &json!({"params": {"paths": 50}})));
|
||||
// A custom arrowhead's outline in stroke.set, only where stroke.set takes one.
|
||||
}
|
||||
|
||||
/// A custom arrowhead's outline `path` names no file, but only where stroke.set takes one.
|
||||
#[test]
|
||||
fn a_custom_arrowhead_outline_names_no_file_only_in_stroke_set() {
|
||||
use serde_json::json;
|
||||
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"})));
|
||||
@@ -668,7 +766,12 @@ mod tests {
|
||||
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.
|
||||
}
|
||||
|
||||
/// A file key under deep nesting is found, and the walk doesn't recurse.
|
||||
#[test]
|
||||
fn a_file_key_is_found_under_deep_nesting() {
|
||||
use serde_json::json;
|
||||
let mut deep = json!({"file": "/tmp/x"});
|
||||
for _ in 0..10_000 {
|
||||
deep = Value::Array(vec![deep]);
|
||||
@@ -726,11 +829,48 @@ mod tests {
|
||||
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("url"), None);
|
||||
assert_eq!(key_location("folders"), None);
|
||||
assert_eq!(key_location("linksFolder"), None);
|
||||
assert_eq!(key_location("sourceFolder"), Some(Location::Folder));
|
||||
assert_eq!(key_location("pathData"), None);
|
||||
}
|
||||
|
||||
/// A key keeps the meaning its command gives it at every depth: a call (`{command, params}`) and a
|
||||
/// recorded step (`[command id, params]`) carry their command's exceptions down, and a custom
|
||||
/// arrowhead's outline is part of the document only at its own place in `stroke.set`.
|
||||
#[test]
|
||||
fn a_params_place_follows_its_command() {
|
||||
use serde_json::json;
|
||||
let at = |place: ParamsPlace, keys: &[&str]| {
|
||||
let mut place = place;
|
||||
let mut location = None;
|
||||
for k in keys {
|
||||
(place, location) = place.member(k, None);
|
||||
}
|
||||
location
|
||||
};
|
||||
assert_eq!(at(ParamsPlace::of("text.createInPath"), &["path"]), None);
|
||||
assert_eq!(at(ParamsPlace::of("text.createInPath"), &["options", "path"]), Some(Location::File));
|
||||
assert_eq!(at(ParamsPlace::of("stroke.set"), &["endArrow", "Custom", "path"]), None);
|
||||
assert_eq!(at(ParamsPlace::of("stroke.set"), &["endArrow", "Custom", "file"]), Some(Location::File));
|
||||
assert_eq!(at(ParamsPlace::of("no.such.command"), &["path"]), Some(Location::File));
|
||||
assert_eq!(at(ParamsPlace::top(None), &["destinationFolder"]), Some(Location::Folder));
|
||||
let call = json!({"command": "text.createInPath", "params": {"path": [[0, 0]]}});
|
||||
let carried = call.as_object().and_then(ParamsPlace::carried);
|
||||
let (inner, location) = ParamsPlace::of("command.batch").member("params", carried);
|
||||
assert_eq!(location, None);
|
||||
assert_eq!(inner.member("path", None).1, None);
|
||||
assert_eq!(ParamsPlace::carried(&serde_json::Map::new()).map(|_| ()), None);
|
||||
let unknown = json!({"command": "no.such.command", "params": {}});
|
||||
assert!(unknown.as_object().and_then(ParamsPlace::carried).is_none());
|
||||
let step = [json!("text.createInPath"), json!({"path": [[0, 0]]})];
|
||||
let place = ParamsPlace::top(None).item(1, ParamsPlace::step(&step));
|
||||
assert_eq!(place.member("path", None).1, None);
|
||||
assert_eq!(ParamsPlace::top(None).item(0, ParamsPlace::step(&step)).member("path", None).1, Some(Location::File));
|
||||
assert!(ParamsPlace::step(&[json!("text.createInPath")]).is_none());
|
||||
}
|
||||
|
||||
/// A file key counts as a command's param only where its description takes it: not a word in
|
||||
/// the prose after the params, in a nested object or in the result. A description that doesn't
|
||||
/// say which keys it takes counts any `key:` in its text.
|
||||
|
||||
@@ -222,11 +222,11 @@ pub fn written_elsewhere(path: &str) -> bool {
|
||||
!cfg!(windows) && foreign && current().is_some()
|
||||
}
|
||||
|
||||
/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`,
|
||||
/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
|
||||
/// `destinationFolder`)?
|
||||
/// Does a command param named `key` hold a file or folder path in a command that gives it no other
|
||||
/// meaning ([`crate::cmd::key_location`]: `path`, `file`, `diffPath`, `destinationFolder` and the
|
||||
/// like)? [`crate::cmd::ParamsPlace`] tells what it holds in a given command.
|
||||
pub fn is_path_key(key: &str) -> bool {
|
||||
matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end))
|
||||
crate::cmd::key_location(key).is_some()
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::{Location, ParamsPlace};
|
||||
use vectorcraft_engine::{Session, command_specs};
|
||||
use vectorcraft_testkit::catch_quiet;
|
||||
use vectorcraft_testkit::fixtures::Fixture;
|
||||
@@ -272,44 +273,46 @@ fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value
|
||||
cases
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
/// The strings in `v`, the value at `at` (found at `place`) 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 as the command reads them (`vectorcraft_engine::cmd::ParamsPlace`).
|
||||
fn outside_locations(
|
||||
id: &str,
|
||||
at: &str,
|
||||
v: &Value,
|
||||
place: ParamsPlace,
|
||||
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) => {
|
||||
let step = ParamsPlace::step(a);
|
||||
for (i, x) in a.iter().enumerate() {
|
||||
outside_locations(id, &format!("{at}[{i}]"), x, named, dir, out);
|
||||
outside_locations(id, &format!("{at}[{i}]"), x, place.item(i, step), named, dir, out);
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let carried = ParamsPlace::carried(o);
|
||||
for (k, x) in o {
|
||||
outside_locations(id, &format!("{at}.{k}"), x, names_location(None, k), dir, out);
|
||||
let (below, location) = place.member(k, carried);
|
||||
outside_locations(id, &format!("{at}.{k}"), x, below, location.is_some(), 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`]).
|
||||
/// command alone in at least one case ([`junk_cases`]). Which catalog keys name a file or folder is
|
||||
/// checked on its own by [`catalog_path_keys_match_location_policy`].
|
||||
#[test]
|
||||
fn sweep_cases_name_files_only_inside_the_temp_folder() {
|
||||
let dir = vectorcraft_testkit::TempDir::new("sweep");
|
||||
@@ -330,7 +333,8 @@ fn sweep_cases_name_files_only_inside_the_temp_folder() {
|
||||
for p in &cases {
|
||||
let Some(o) = p.as_object() else { continue };
|
||||
for (k, v) in o {
|
||||
outside_locations(c.id, k, v, names_location(Some(c), k), &dir, &mut outside);
|
||||
let (below, location) = ParamsPlace::top(Some(c)).member(k, None);
|
||||
outside_locations(c.id, k, v, below, location.is_some(), &dir, &mut outside);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +343,104 @@ fn sweep_cases_name_files_only_inside_the_temp_folder() {
|
||||
assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n"));
|
||||
}
|
||||
|
||||
/// The param keys that name a file or a folder, and which.
|
||||
const LOCATION_KEYS: &[(&str, Location)] = &[
|
||||
("path", Location::File),
|
||||
("paths", Location::File),
|
||||
("file", Location::File),
|
||||
("files", Location::File),
|
||||
("diffPath", Location::File),
|
||||
("folder", Location::Folder),
|
||||
("dir", Location::Folder),
|
||||
("directory", Location::Folder),
|
||||
("destinationFolder", Location::Folder),
|
||||
("sourceFolder", Location::Folder),
|
||||
("fontsFolder", Location::Folder),
|
||||
("pluginsFolder", Location::Folder),
|
||||
("recoveryFolder", Location::Folder),
|
||||
("templatesFolder", Location::Folder),
|
||||
];
|
||||
|
||||
/// Param keys that read like a file or folder key ([`reads_like_a_location`]) and name neither:
|
||||
/// web links, an export's grouping of its files, and choices and switches.
|
||||
const NOT_LOCATIONS: &[&str] = &[
|
||||
"url",
|
||||
"copyrightUrl",
|
||||
"folders",
|
||||
"alignToPath",
|
||||
"wholePaths",
|
||||
"emptyTextPaths",
|
||||
"sortPaths",
|
||||
"alterPaths",
|
||||
"includeLinkedFiles",
|
||||
"linksFolder",
|
||||
];
|
||||
|
||||
/// Commands and the key in [`LOCATION_KEYS`] that, at the top of their params, names part of the
|
||||
/// document (a path of its art, a result field), not a file.
|
||||
const DOCUMENT_PARTS: &[(&str, &str)] = &[
|
||||
("object.setLiveShape", "path"),
|
||||
("path.reverse", "paths"),
|
||||
("object.pathfinder.options", "paths"),
|
||||
("object.maskImage", "path"),
|
||||
("text.createInPath", "path"),
|
||||
("symbol.instance.setAppearance", "path"),
|
||||
("symbol.instance.selectPart", "path"),
|
||||
("object.objectsOnPath.attach", "path"),
|
||||
("svgInteractivity.info", "files"),
|
||||
("addon.cleanup.rejoin", "paths"),
|
||||
];
|
||||
|
||||
/// Does param key `k` read like one that names a file, a folder or a link: a bare name for one, or a
|
||||
/// name that ends like one?
|
||||
fn reads_like_a_location(k: &str) -> bool {
|
||||
const ENDS: &[&str] = &["Path", "Paths", "File", "Files", "Folder", "Folders", "Dir", "Directory", "Url", "URL"];
|
||||
matches!(k, "path" | "paths" | "file" | "files" | "folder" | "folders" | "dir" | "directory" | "url") || ENDS.iter().any(|end| k.ends_with(end))
|
||||
}
|
||||
|
||||
/// Every key of every command that reads like a file or folder key (in its params description, the
|
||||
/// commands its `…` entries name and its schema) is in [`LOCATION_KEYS`] or [`NOT_LOCATIONS`], so a
|
||||
/// command that adds one fails here until it is sorted. The generic classifications
|
||||
/// (`vectorcraft_engine::cmd::key_location`, `vectorcraft_engine::file_access::is_path_key`) agree
|
||||
/// with those lists, and each command reads such a key as they say (`location_in`), less
|
||||
/// [`DOCUMENT_PARTS`].
|
||||
#[test]
|
||||
fn catalog_path_keys_match_location_policy() {
|
||||
use vectorcraft_engine::cmd::{key_location, location_in, params::takes_key};
|
||||
let catalog: Vec<(&vectorcraft_engine::CommandSpec, Vec<String>)> = command_specs().iter().map(|c| (c, command_param_keys(c))).collect();
|
||||
let mut keys: std::collections::BTreeSet<String> =
|
||||
catalog.iter().flat_map(|(_, keys)| keys.iter().filter(|k| reads_like_a_location(k)).cloned()).collect();
|
||||
keys.extend(LOCATION_KEYS.iter().map(|(k, _)| k.to_string()).chain(NOT_LOCATIONS.iter().map(|k| k.to_string())));
|
||||
let mut wrong = vec![];
|
||||
for k in &keys {
|
||||
let want = LOCATION_KEYS.iter().find(|(l, _)| l == k).map(|(_, l)| *l);
|
||||
if want.is_none() && !NOT_LOCATIONS.contains(&k.as_str()) {
|
||||
wrong.push(format!("`{k}` is in neither LOCATION_KEYS nor NOT_LOCATIONS"));
|
||||
continue;
|
||||
}
|
||||
if key_location(k) != want {
|
||||
wrong.push(format!("key_location(`{k}`) is {:?}, not {want:?}", key_location(k)));
|
||||
}
|
||||
if vectorcraft_engine::file_access::is_path_key(k) != want.is_some() {
|
||||
wrong.push(format!("is_path_key(`{k}`) is {}", want.is_none()));
|
||||
}
|
||||
for (c, named) in &catalog {
|
||||
if !named.contains(k) && !(c.params.contains('…') && takes_key(c.params, k)) {
|
||||
continue;
|
||||
}
|
||||
let here = if DOCUMENT_PARTS.contains(&(c.id, k.as_str())) { None } else { want };
|
||||
if location_in(c, k) != here {
|
||||
wrong.push(format!("{}: location_in(`{k}`) is {:?}, not {here:?}", c.id, location_in(c, k)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, key) in DOCUMENT_PARTS {
|
||||
let named = catalog.iter().any(|(c, _)| c.id == *id && c.params.contains(key));
|
||||
assert!(named, "{id} doesn't name `{key}`");
|
||||
}
|
||||
assert!(wrong.is_empty(), "{} keys are classified unlike the reviewed lists:\n{}", wrong.len(), wrong.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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::{Location, key_location, location_in, location_keys};
|
||||
use vectorcraft_engine::cmd::{Location, ParamsPlace, key_location, location_keys};
|
||||
use vectorcraft_engine::{CommandSpec, Session, command_specs};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath};
|
||||
|
||||
@@ -518,20 +518,8 @@ pub fn command_param_keys(spec: &CommandSpec) -> Vec<String> {
|
||||
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 {
|
||||
key_location(k).is_some()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 (in
|
||||
/// the same temp folder) for a folder key.
|
||||
/// The location to send under the path key `k` ([`key_location`]): `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 {
|
||||
location(key_location(k).unwrap_or(Location::File), safe_path)
|
||||
}
|
||||
@@ -542,55 +530,74 @@ 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 {
|
||||
/// `v`, found at `place` in a command's params, 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 key that names a file or a
|
||||
/// folder there ([`ParamsPlace::member`]: a call or a recorded step inside `v` keeps its command's
|
||||
/// meaning). Other values keep their junk shape.
|
||||
fn redirect(place: ParamsPlace, 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()),
|
||||
Value::Array(a) => {
|
||||
let step = ParamsPlace::step(a);
|
||||
Value::Array(a.iter().enumerate().map(|(i, x)| redirect(place.item(i, step), kind, x, safe_path)).collect())
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let carried = ParamsPlace::carried(o);
|
||||
let member = |k: &String, x: &Value| {
|
||||
let (below, l) = place.member(k, carried);
|
||||
(k.clone(), redirect(below, l, x, safe_path))
|
||||
};
|
||||
Value::Object(o.iter().map(|(k, x)| member(k, x)).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 {
|
||||
redirect(key_location(k), v, safe_path)
|
||||
/// `v`, sent under key `k` at `place`, as [`redirect`] makes it.
|
||||
fn redirect_under(place: ParamsPlace, k: &str, v: &Value, safe_path: &str) -> Value {
|
||||
let (below, kind) = place.member(k, None);
|
||||
redirect(below, kind, 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.
|
||||
/// `v`, with its strings sent to the temp location when `k` is a path key in a command that gives it
|
||||
/// no other meaning ([`key_location`]), and the strings under path keys in objects inside it too.
|
||||
pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value {
|
||||
redirect_under(ParamsPlace::top(None), k, v, safe_path)
|
||||
}
|
||||
|
||||
/// `v` as [`safe_value`] makes it for command `spec` ([`vectorcraft_engine::cmd::location_in`]): a key that names part of
|
||||
/// the document in it keeps its strings, at the top level and below.
|
||||
pub fn safe_value_for_command(spec: &CommandSpec, k: &str, v: &Value, safe_path: &str) -> Value {
|
||||
redirect(location_in(spec, k), v, safe_path)
|
||||
redirect_under(ParamsPlace::top(Some(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. A string
|
||||
/// under a key that names a file or folder in any command ([`is_path_key`]) is a temp location.
|
||||
/// under a key that names a file or folder in a command that gives it no other meaning
|
||||
/// ([`key_location`]) is a temp location.
|
||||
pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
|
||||
junk_params_with(¶m_keys(doc), |k, v| safe_value(k, v, safe_path), key_location, safe_path)
|
||||
junk_params_with(¶m_keys(doc), ParamsPlace::top(None), 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
|
||||
/// ([`vectorcraft_engine::cmd::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)
|
||||
junk_params_with(&command_param_keys(spec), ParamsPlace::top(Some(spec)), 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> {
|
||||
/// The junk cases for `keys` at `place`, the top of a command's params: what is sent for a junk value
|
||||
/// under a key is [`redirect_under`] it, and a key that names a file or a folder there also gets a
|
||||
/// list of locations and a list of objects with a `path`.
|
||||
fn junk_params_with(keys: &[String], place: ParamsPlace, 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 {
|
||||
let (below, kind) = place.member(k, None);
|
||||
for (i, v) in junk.iter().enumerate() {
|
||||
let v = safe(k, v);
|
||||
let v = redirect(below, kind, v, safe_path);
|
||||
// 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());
|
||||
@@ -598,17 +605,62 @@ fn junk_params_with(keys: &[String], safe: impl Fn(&str, &Value) -> Value, kind:
|
||||
if i % 5 == 0 {
|
||||
let mut all = serde_json::Map::new();
|
||||
for k2 in keys {
|
||||
let v2 = kind(k2).map_or_else(|| v.clone(), |l| json!(location(l, safe_path)));
|
||||
let v2 = place.member(k2, None).1.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) {
|
||||
if let Some(l) = kind {
|
||||
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.push(json!({ k.as_str(): redirect(below, Some(l), &v, safe_path) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The strings the sweeps send keep the meaning the command gives their key, at every depth: an
|
||||
/// export's folder grouping and a web link stay as sent, also inside a `command.batch` step and
|
||||
/// a recorded step, and a real file or folder key there goes to the temp location. A key that names
|
||||
/// part of the document in a recorded step's command stays, and the same key in a command that
|
||||
/// doesn't take it as a param names a file.
|
||||
#[test]
|
||||
fn nested_locations_keep_command_meaning() {
|
||||
let spec = |id: &str| command_specs().iter().find(|c| c.id == id).unwrap_or_else(|| panic!("no command {id}"));
|
||||
let safe = "/tmp/sweep/out.bin";
|
||||
let settings = json!({"folders": "rows", "formats": [{"format": "png", "folder": "junk"}]});
|
||||
assert_eq!(
|
||||
safe_value_for_command(spec("document.exportPresets.save"), "settings", &settings, safe),
|
||||
json!({"folders": "rows", "formats": [{"format": "png", "folder": "/tmp/sweep/out.bin-folder"}]})
|
||||
);
|
||||
let steps = json!([
|
||||
{"command": "attributes.set", "params": {"url": "https://example.com/a"}},
|
||||
{"command": "document.save", "params": {"path": "junk"}},
|
||||
{"command": "object.path.simplify", "params": {"wholePaths": "junk"}},
|
||||
]);
|
||||
assert_eq!(
|
||||
safe_value_for_command(spec("command.batch"), "commands", &steps, safe),
|
||||
json!([
|
||||
{"command": "attributes.set", "params": {"url": "https://example.com/a"}},
|
||||
{"command": "document.save", "params": {"path": safe}},
|
||||
{"command": "object.path.simplify", "params": {"wholePaths": "junk"}},
|
||||
])
|
||||
);
|
||||
let recorded = json!([
|
||||
["text.createInPath", {"path": "junk"}],
|
||||
["object.setLiveShape", {"path": "junk"}],
|
||||
["document.compare", {"diffPath": "junk"}],
|
||||
]);
|
||||
assert_eq!(
|
||||
safe_value("steps", &recorded, safe),
|
||||
json!([["text.createInPath", {"path": "junk"}], ["object.setLiveShape", {"path": safe}], ["document.compare", {"diffPath": safe}]])
|
||||
);
|
||||
assert_eq!(safe_value("url", &json!("https://example.com/a"), safe), json!("https://example.com/a"));
|
||||
assert_eq!(safe_value("destinationFolder", &json!("junk"), safe), json!("/tmp/sweep/out.bin-folder"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::ParamsPlace;
|
||||
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
@@ -427,17 +428,19 @@ impl Hiding {
|
||||
}
|
||||
|
||||
/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths
|
||||
/// ([`unreadable_path_like`]) or held by a path param
|
||||
/// ([`vectorcraft_engine::file_access::is_path_key`]), a relative one too, each as a hidden library id
|
||||
/// (`loaded/<path>`) with `loaded/<file name>` shown. A reply hides them as it hides a hidden library's
|
||||
/// path, with that file name also for a hidden library shown as `loaded/<file name> (2)`, so that it
|
||||
/// reads the same whether or not a library was loaded from one of them.
|
||||
/// ([`unreadable_path_like`]) or held by a path param, a relative one too, each as a hidden library id
|
||||
/// (`loaded/<path>`) with `loaded/<file name>` shown. A param is a path param as the command it is
|
||||
/// for reads it ([`ParamsPlace`]: `engine.execute`'s `params`, a `command.batch` step's and a
|
||||
/// recorded step's), so a web link or an export's folder grouping is not one. A reply hides them as
|
||||
/// it hides a hidden library's path, with that file name also for a hidden library shown as
|
||||
/// `loaded/<file name> (2)`, so that it reads the same whether or not a library was loaded from one
|
||||
/// of them.
|
||||
fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) {
|
||||
collect_sent_paths(v, false, out);
|
||||
collect_sent_paths(v, ParamsPlace::top(None), false, out);
|
||||
}
|
||||
|
||||
/// [`sent_paths`] on `v`, which a path param holds when `path_param` is set.
|
||||
fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String)>) {
|
||||
/// [`sent_paths`] on `v`, found at `place`, which a path param holds when `path_param` is set.
|
||||
fn collect_sent_paths(v: &Value, place: ParamsPlace, path_param: bool, out: &mut Vec<(String, String)>) {
|
||||
fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) {
|
||||
if !unreadable_path_like(s) && !(path_param && !readable_value(s)) {
|
||||
return;
|
||||
@@ -459,11 +462,18 @@ fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String
|
||||
}
|
||||
match v {
|
||||
Value::String(s) => add(s, path_param, out),
|
||||
Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)),
|
||||
Value::Array(items) => {
|
||||
let step = ParamsPlace::step(items);
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
collect_sent_paths(item, place.item(i, step), path_param, out);
|
||||
}
|
||||
}
|
||||
Value::Object(fields) => {
|
||||
let carried = ParamsPlace::carried(fields);
|
||||
for (key, item) in fields {
|
||||
add(key, false, out);
|
||||
collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out);
|
||||
let (below, location) = place.member(key, carried);
|
||||
collect_sent_paths(item, below, location.is_some(), out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1057,7 +1067,7 @@ fn steps_mut(set: &mut Value) -> impl Iterator<Item = (String, &mut serde_json::
|
||||
/// [`leave_out_unreadable_params`] takes out, each in one pass over the params.
|
||||
fn leave_out_unreadable_step(command: &str, params: &mut serde_json::Map<String, Value>) {
|
||||
leave_out_unreadable_pref_steps(command, params);
|
||||
leave_out_unreadable_params(params);
|
||||
leave_out_unreadable_params(params, ParamsPlace::of(command));
|
||||
}
|
||||
|
||||
/// Takes out of the params of a step running `command` the folder preferences' values the request
|
||||
@@ -1087,39 +1097,46 @@ fn leave_out_unreadable_pref_steps(command: &str, params: &mut serde_json::Map<S
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the
|
||||
/// paths the request may not read: the value of a path param
|
||||
/// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder`
|
||||
/// and the like), each path of a list under such a param, and under any other param, as a key too, a
|
||||
/// string written as a path ([`unreadable_path_like`]). An empty or blank value names no path and stays.
|
||||
fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) {
|
||||
use vectorcraft_engine::file_access::is_path_key;
|
||||
/// Takes out of a recorded step's params at `place`, at any depth (the commands of a `command.batch`
|
||||
/// step), the paths the request may not read: the value of a path param as the command reads it
|
||||
/// ([`ParamsPlace`]: `path`, `file`, `diffPath`, `destinationFolder` and the like, but not a key that
|
||||
/// names part of the document in that command), each path of a list under such a param, and under
|
||||
/// any other param, as a key too, a string written as a path ([`unreadable_path_like`]). A web link,
|
||||
/// a choice or a switch whose name ends like a path param's (`alignToPath`, `linksFolder`) stays, and
|
||||
/// so does an empty or blank value, which names no path.
|
||||
fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>, place: ParamsPlace) {
|
||||
let shown = |p: &str| p.trim().is_empty() || readable_value(p);
|
||||
let carried = ParamsPlace::carried(params);
|
||||
params.retain(|key, v| {
|
||||
!unreadable_path_like(key)
|
||||
&& match v.as_str() {
|
||||
Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !shown(p)),
|
||||
Some(p) => !unreadable_path_like(p) && !(place.member(key, carried).1.is_some() && !shown(p)),
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
let carried = ParamsPlace::carried(params);
|
||||
for (key, v) in params.iter_mut() {
|
||||
let (below, location) = place.member(key, carried);
|
||||
if let Value::Array(paths) = v
|
||||
&& is_path_key(key)
|
||||
&& location.is_some()
|
||||
{
|
||||
paths.retain(|p| p.as_str().is_none_or(shown));
|
||||
}
|
||||
leave_out_unreadable_values(v);
|
||||
leave_out_unreadable_values(v, below);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`leave_out_unreadable_params`] on each object `v` holds, and a string written as a path that the
|
||||
/// request may not read ([`unreadable_path_like`]) taken out of each list.
|
||||
fn leave_out_unreadable_values(v: &mut Value) {
|
||||
/// [`leave_out_unreadable_params`] on each object `v`, found at `place`, holds, and a string written
|
||||
/// as a path that the request may not read ([`unreadable_path_like`]) taken out of each list.
|
||||
fn leave_out_unreadable_values(v: &mut Value, place: ParamsPlace) {
|
||||
match v {
|
||||
Value::Object(fields) => leave_out_unreadable_params(fields),
|
||||
Value::Object(fields) => leave_out_unreadable_params(fields, place),
|
||||
Value::Array(items) => {
|
||||
items.retain(|item| !item.as_str().is_some_and(unreadable_path_like));
|
||||
items.iter_mut().for_each(leave_out_unreadable_values);
|
||||
let step = ParamsPlace::step(items);
|
||||
for (i, item) in items.iter_mut().enumerate() {
|
||||
leave_out_unreadable_values(item, place.item(i, step));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1284,7 +1301,7 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before:
|
||||
return;
|
||||
}
|
||||
let mut given = Vec::new();
|
||||
written_paths(sent, false, &mut given);
|
||||
written_paths(sent, ParamsPlace::top(None), false, &mut given);
|
||||
let kept = kept_written_paths(reply, &given);
|
||||
let mut hiding = unreadable_library_ids(app);
|
||||
hiding.merge(before);
|
||||
@@ -1294,10 +1311,10 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before:
|
||||
// first folder on the way to it that is a file or is missing.
|
||||
let current = vectorcraft_engine::cmd::recovery::folder(&app.session).map(|(f, _)| f);
|
||||
for folder in app.recovery.named.iter().chain(current.as_ref()) {
|
||||
collect_sent_paths(&json!(folder), true, &mut paths);
|
||||
collect_sent_paths(&json!(folder), ParamsPlace::top(None), true, &mut paths);
|
||||
for on_the_way in std::path::Path::new(folder).ancestors().skip(1).map(|p| p.to_string_lossy()) {
|
||||
if !on_the_way.is_empty() && writes_whole_path(reply, &on_the_way) {
|
||||
collect_sent_paths(&json!(on_the_way), true, &mut paths);
|
||||
collect_sent_paths(&json!(on_the_way), ParamsPlace::top(None), true, &mut paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1326,16 +1343,24 @@ fn writes_whole_path(v: &Value, path: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The strings that `v`'s path params hold at any depth ([`vectorcraft_engine::file_access::is_path_key`])
|
||||
/// that name a file inside the write roots; `v` is a path param's value when `path_param` is set.
|
||||
fn written_paths(v: &Value, path_param: bool, out: &mut Vec<String>) {
|
||||
use vectorcraft_engine::file_access::{Access, current, is_path_key};
|
||||
/// The strings that `v`'s path params hold at any depth, as the command they are for reads them
|
||||
/// ([`ParamsPlace`]), that name a file inside the write roots; `v`, found at `place`, is a path
|
||||
/// param's value when `path_param` is set.
|
||||
fn written_paths(v: &Value, place: ParamsPlace, path_param: bool, out: &mut Vec<String>) {
|
||||
use vectorcraft_engine::file_access::{Access, current};
|
||||
match v {
|
||||
Value::String(s) if path_param && !s.is_empty() && current().is_some_and(|r| r.check(s, Access::Write).is_ok()) => out.push(s.clone()),
|
||||
Value::Array(items) => items.iter().for_each(|item| written_paths(item, path_param, out)),
|
||||
Value::Array(items) => {
|
||||
let step = ParamsPlace::step(items);
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
written_paths(item, place.item(i, step), path_param, out);
|
||||
}
|
||||
}
|
||||
Value::Object(fields) => {
|
||||
let carried = ParamsPlace::carried(fields);
|
||||
for (key, item) in fields {
|
||||
written_paths(item, is_path_key(key), out);
|
||||
let (below, location) = place.member(key, carried);
|
||||
written_paths(item, below, location.is_some(), out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -306,7 +306,7 @@ fn a_spaced_absolute_path_outside_the_read_root_is_left_out() {
|
||||
assert!(super::unreadable_path_like(" /outside/root/x"));
|
||||
assert!(super::unreadable_path_like("\tfile:///outside/root/x "));
|
||||
assert!(!super::unreadable_path_like(&inside), "{inside}");
|
||||
super::leave_out_unreadable_params(params.as_object_mut().unwrap());
|
||||
super::leave_out_unreadable_params(params.as_object_mut().unwrap(), vectorcraft_engine::cmd::ParamsPlace::top(None));
|
||||
});
|
||||
assert_eq!(params, json!({"keep": inside, "list": []}));
|
||||
}
|
||||
@@ -337,10 +337,74 @@ fn a_url_naming_another_host_stays_without_a_read_root() {
|
||||
assert!(super::readable_value("file://host/share/a.svg"));
|
||||
let mut params = json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]});
|
||||
let fields = params.as_object_mut().unwrap();
|
||||
super::leave_out_unreadable_params(fields);
|
||||
super::leave_out_unreadable_params(fields, vectorcraft_engine::cmd::ParamsPlace::top(None));
|
||||
assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}));
|
||||
}
|
||||
|
||||
/// Under a read root, a recorded step loses a relative path outside it under a key that names a file
|
||||
/// or a folder as its command reads it (also inside a `command.batch` step), and a `file:` URL outside
|
||||
/// it under any key. A web link, a choice or a switch whose name ends like a path key's, and a key
|
||||
/// that names part of the document in its command, keep their values; the same key in a command that
|
||||
/// doesn't take it as a param names a file.
|
||||
#[test]
|
||||
fn recorded_steps_filter_only_unreadable_locations() {
|
||||
use vectorcraft_engine::cmd::ParamsPlace;
|
||||
let root = std::env::temp_dir().join(format!("vc-recorded-locations-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
|
||||
let steps = [
|
||||
("document.compare", json!({"diffPath": "elsewhere/a.svg", "directory": "elsewhere", "keep": 1})),
|
||||
("attributes.set", json!({"url": "https://example.com/elsewhere/a.html", "note": "kept"})),
|
||||
("object.slice.options", json!({"url": "file:///elsewhere/a.html"})),
|
||||
("object.path.simplify", json!({"wholePaths": "elsewhere/a", "alignToPath": "elsewhere/b", "linksFolder": "elsewhere/c"})),
|
||||
("document.exportPresets.save", json!({"settings": {"folders": "item/format", "formats": [{"folder": "elsewhere/png"}]}})),
|
||||
("text.createInPath", json!({"path": "elsewhere/shape"})),
|
||||
("object.setLiveShape", json!({"path": "elsewhere/shape"})),
|
||||
("command.batch", json!({"commands": [{"command": "document.save", "params": {"path": "elsewhere/b.svg"}}]})),
|
||||
];
|
||||
let filtered: Vec<Value> = vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
steps
|
||||
.iter()
|
||||
.map(|(id, params)| {
|
||||
let mut params = params.clone();
|
||||
super::leave_out_unreadable_params(params.as_object_mut().unwrap(), ParamsPlace::of(id));
|
||||
params
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let want = [
|
||||
json!({"keep": 1}),
|
||||
json!({"url": "https://example.com/elsewhere/a.html", "note": "kept"}),
|
||||
json!({}),
|
||||
json!({"wholePaths": "elsewhere/a", "alignToPath": "elsewhere/b", "linksFolder": "elsewhere/c"}),
|
||||
json!({"settings": {"folders": "item/format", "formats": [{}]}}),
|
||||
json!({"path": "elsewhere/shape"}),
|
||||
json!({}),
|
||||
json!({"commands": [{"command": "document.save", "params": {}}]}),
|
||||
];
|
||||
assert_eq!(filtered, want);
|
||||
}
|
||||
|
||||
/// A request's relative path outside the read root is hidden in a reply where the command it calls
|
||||
/// reads it as a file, and a web link or a key that names part of the document there is not.
|
||||
#[test]
|
||||
fn a_request_sends_paths_as_its_command_reads_them() {
|
||||
let root = std::env::temp_dir().join(format!("vc-sent-command-paths-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
|
||||
let mut sent = Vec::new();
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
super::sent_paths(&json!({"command": "attributes.set", "params": {"url": "https://example.com/a/page.html"}}), &mut sent);
|
||||
super::sent_paths(&json!({"command": "object.path.simplify", "params": {"wholePaths": "out/w.svg"}}), &mut sent);
|
||||
super::sent_paths(&json!({"command": "document.compare", "params": {"diffPath": "out/d.svg"}}), &mut sent);
|
||||
super::sent_paths(&json!({"command": "file.place", "params": {"directory": "out/folder"}}), &mut sent);
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}"));
|
||||
assert_eq!(sent, [pair("out/d.svg", "d.svg"), pair("out/folder", "folder")]);
|
||||
}
|
||||
|
||||
/// A hidden path quoted with `{:?}` is hidden with its quotes and control characters escaped.
|
||||
#[test]
|
||||
fn a_reply_hides_a_quoted_path_with_escaped_quotes() {
|
||||
|
||||
Reference in New Issue
Block a user