MCP: replies hide library paths outside the read roots in more forms

- Every request under a read root records the hidden libraries before it
  runs, and the reply hides those and the ones hidden after it. A panic
  is answered inside the request, so its error is hidden too.
- A hidden path followed by more path text stays only when the longer
  text is another loaded library's id with no `.` or `..` part.
- Encoded file data is skipped only for a base64 string under
  `dataBase64`, `pngBase64` or `thumbnailBase64`; objects and lists under
  those keys, and other keys ending in `Base64`, are searched.
- The error of a failed request hides a path outside the read roots that
  the request sent by its file name, so it reads the same whether or not
  a library was loaded from that path.
- Paths written with `%XX` escapes are hidden, and on Windows paths in
  another letter case.
- Recorded steps in `ui.inspect` leave out `~`, paths under `~` and paths
  starting with `./` or `../` outside the read roots under any param.
- Hidden libraries are numbered in load order and keep their id from one
  request to the next.
This commit is contained in:
dotCooCoo
2026-11-11 16:33:37 -08:00
parent 6a7214df4c
commit db8726e94b
6 changed files with 609 additions and 108 deletions
+1
View File
@@ -115,6 +115,7 @@ impl VectorcraftApp {
synthetic_dropped_before: 0,
pending_screenshots: vec![],
pointer_replies: vec![],
shown_library_ids: Default::default(),
queued_screenshots: vec![],
screenshot_token: 0,
screenshot_reserved: 0,
+362 -83
View File
@@ -326,30 +326,40 @@ const PANEL_FIELDS: [&str; 7] =
/// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a
/// command palette query, a workspace's or an action's name) is not read. When another of those ids,
/// or a library loaded in the session, already has that id, the next free one
/// of `loaded/<file name> (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that
/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id.
fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
let mut ids = std::collections::BTreeSet::new();
/// of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two and
/// `ui.inspect` and `ui.docking` show a library under the same id. A library keeps its id from one
/// request to the next while it is referred to. New ids are numbered in this order: the libraries
/// loaded in the session (the swatch, graphic style, symbol and brush libraries, each kind in the
/// order it was loaded), then the library panels in their order, the Color Guide's limit and the
/// other ids the UI state holds.
fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding {
let mut rest = std::collections::BTreeSet::new();
let ui = serde_json::to_value(&app.ui).unwrap_or_default();
for field in PANEL_FIELDS {
collect_library_panel_ids(&ui[field], &mut ids);
collect_library_panel_ids(&ui[field], &mut rest);
}
collect_library_params(&ui["action_sets"], &mut ids);
collect_library_params(&ui["action_sets"], &mut rest);
for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() {
for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") {
collect_library_panel_ids(layout, &mut ids);
collect_library_panel_ids(layout, &mut rest);
}
}
collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids);
collect_library_panel_ids(&json!(app.ui.transient_panel), &mut rest);
// The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded
// without a path.
let s = &app.session;
let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos());
let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect();
for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) {
add_library_id(id, &mut ids);
let mut ids: Vec<String> = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let first = loaded.iter().copied().chain(app.ui.library_panels.iter().map(|p| p.id.as_str())).chain([app.ui.color_guide_limit.as_str()]);
for id in first.map(str::to_string).chain(rest) {
if id.len() > "loaded/".len() && id.starts_with("loaded/") && seen.insert(id.clone()) {
ids.push(id);
}
}
let mut hidden = Vec::new();
let mut readable_ids = std::collections::BTreeSet::new();
let mut taken: std::collections::BTreeSet<String> = loaded.iter().map(|id| id.to_string()).collect();
for id in ids {
let path = &id["loaded/".len()..];
@@ -358,25 +368,110 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
if name != path && !readable(path) {
hidden.push((id, name));
} else {
taken.insert(id);
taken.insert(id.clone());
readable_ids.insert(id);
}
}
let mut hidden = hidden
.into_iter()
.map(|(id, name)| {
let mut shown = format!("loaded/{name}");
let mut n = 2;
while taken.contains(&shown) {
shown = format!("loaded/{name} ({n})");
n += 1;
}
taken.insert(shown.clone());
(id, shown)
})
.collect::<Vec<_>>();
// A library keeps the id it was shown under while it is still referred to and no library loaded
// without a path, or one the request may read, has taken that id since.
let mut remembered = app.shown_library_ids.borrow_mut();
remembered.retain(|(id, shown)| hidden.iter().any(|(h, _)| h == id) && !taken.contains(shown));
taken.extend(remembered.iter().map(|(_, shown)| shown.clone()));
let mut shown_ids = Vec::with_capacity(hidden.len());
for (id, name) in hidden {
if let Some((_, shown)) = remembered.iter().find(|(known, _)| *known == id) {
shown_ids.push((id, shown.clone()));
continue;
}
let mut shown = format!("loaded/{name}");
let mut n = 2;
while taken.contains(&shown) {
shown = format!("loaded/{name} ({n})");
n += 1;
}
taken.insert(shown.clone());
remembered.push((id.clone(), shown.clone()));
shown_ids.push((id, shown));
}
let mut hidden = shown_ids;
// The longest first, so that an id that begins another one does not replace part of it.
hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
hidden
Hiding { hidden, readable: readable_ids }
}
/// What a reply hides ([`hide_library_ids`]).
#[derive(Debug, Default)]
struct Hiding {
/// The ids of the loaded libraries the request may not read, longest first, each with the id
/// shown instead ([`unreadable_library_ids`]).
hidden: Vec<(String, String)>,
/// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or
/// path that continues into one of them stays ([`known_continuation`]).
readable: std::collections::BTreeSet<String>,
}
impl Hiding {
/// Adds the hidden and readable ids of `other` that this one does not hold.
fn merge(&mut self, other: Hiding) {
self.add_hidden(other.hidden);
self.readable.extend(other.readable);
}
/// Adds the hidden ids of `pairs` that this one does not hold, each with the id shown.
fn add_hidden(&mut self, pairs: Vec<(String, String)>) {
for pair in pairs {
if !self.hidden.iter().any(|(id, _)| *id == pair.0) {
self.hidden.push(pair);
}
}
self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
}
/// Adds the hidden ids of `pairs`, each with the id shown, in place of each id this one holds for
/// the same path: written with either separator, and in any case of ASCII letters with `fold`.
fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) {
let key = |id: &str| {
let id = id.replace('\\', "/");
if fold { id.to_ascii_lowercase() } else { id }
};
let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect();
self.hidden.retain(|(id, _)| !sent.contains(&key(id)));
self.add_hidden(pairs);
}
}
/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths
/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/<path>`) with `loaded/<file name>`
/// shown. The error of a failed request 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)>) {
fn add(s: &str, out: &mut Vec<(String, String)>) {
if !unreadable_path_like(s) {
return;
}
let path = match file_url_path(s) {
Some(Some(path)) => path,
Some(None) => return,
None => s.to_string(),
};
let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return };
let pair = (format!("loaded/{path}"), format!("loaded/{name}"));
if !out.contains(&pair) {
out.push(pair);
}
}
match v {
Value::String(s) => add(s, out),
Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)),
Value::Object(fields) => {
for (key, item) in fields {
add(key, out);
sent_paths(item, out);
}
}
_ => {}
}
}
/// Adds `id` to `ids` when it is a loaded library's id (`loaded/<path>`).
@@ -411,15 +506,20 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str
/// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id
/// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's
/// report and a recorded step. An occurrence followed by a character that continues a path
/// ([`continues_path`]) stays only when the request may read the longer path
/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path.
fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) {
replace_in_value(v, &hidden_text(ids));
/// ([`continues_path`]) stays only when the longer text is another loaded library's id
/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On
/// Windows the ids and paths are matched in any case of ASCII letters.
fn hide_library_ids(v: &mut Value, hiding: &Hiding) {
let pairs = hidden_text(&hiding.hidden);
if pairs.is_empty() {
return;
}
replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) });
}
/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the
/// path in it with the file name shown, the path written with either separator too, and with each
/// `\` doubled (a path quoted with `{:?}`).
/// path in it with the file name shown, the path written with either separator too, and with each `\`
/// doubled (a path quoted with `{:?}`).
fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> {
let mut pairs: Vec<(String, String)> = Vec::new();
for (id, shown) in ids {
@@ -438,49 +538,174 @@ fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> {
pairs
}
/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each
/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the
/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64`
/// (`dataBase64`, `pngBase64`), stays as it is.
fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) {
if pairs.is_empty() {
return;
}
/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match
/// in any case of ASCII letters.
struct Rewrite<'a> {
pairs: Vec<(String, String)>,
hiding: &'a Hiding,
fold: bool,
}
/// The keys of encoded file data in a reply.
const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"];
/// Is `s` written in the base64 alphabet, padded to a multiple of 4?
fn is_base64(s: &str) -> bool {
let body = s.trim_end_matches('=');
s.len().is_multiple_of(4) && s.len() - body.len() <= 2 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
}
/// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as
/// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays
/// as it is.
fn replace_in_value(v: &mut Value, r: &Rewrite) {
match v {
Value::String(s) => {
if let Some(replaced) = replace_text(s, pairs) {
if let Some(replaced) = replace_text(s, r) {
*s = replaced;
}
}
Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)),
Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)),
Value::Object(fields) => {
for (key, mut item) in std::mem::take(fields) {
if key.ends_with("Base64") {
if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) {
fields.insert(key, item);
continue;
}
replace_in_value(&mut item, pairs);
fields.insert(replace_text(&key, pairs).unwrap_or(key), item);
replace_in_value(&mut item, r);
fields.insert(replace_text(&key, r).unwrap_or(key), item);
}
}
_ => {}
}
}
/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes
/// them. `None` when nothing changed.
fn replace_text(s: &str, pairs: &[(String, String)]) -> Option<String> {
/// The byte offsets in `text` at which `needle` starts, not overlapping, in any case of ASCII letters
/// with `fold`.
fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> {
if needle.is_empty() {
return Vec::new();
}
if fold {
let (text, needle) = (text.to_ascii_lowercase(), needle.to_ascii_lowercase());
text.match_indices(needle.as_str()).map(|(at, _)| at).collect()
} else {
text.match_indices(needle).map(|(at, _)| at).collect()
}
}
/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence
/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id
/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is
/// replaced too; the escapes around it stay. `None` when nothing changed.
fn replace_text(s: &str, r: &Rewrite) -> Option<String> {
let replaced = replace_plain_text(s, r);
let text = replaced.as_deref().unwrap_or(s);
if !text.contains('%') {
return replaced;
}
let Some((decoded, spans)) = decoded_with_spans(text) else { return replaced };
if decoded == text {
return replaced;
}
// The spans of `text` to replace, found in the decoded text, longest `from` first.
let mut cuts: Vec<(usize, usize, &str)> = Vec::new();
let mut taken: Vec<(usize, usize)> = Vec::new();
for (from, to) in &r.pairs {
for at in occurrences(&decoded, from, r.fold) {
let end = at + from.len();
let free = !taken.iter().any(|&(a, b)| at < b && a < end);
if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) {
taken.push((at, end));
cuts.push((spans[at].0, spans[end - 1].1, to.as_str()));
}
}
}
if cuts.is_empty() {
return replaced;
}
cuts.sort_unstable_by_key(|&(start, _, _)| start);
let mut out = String::with_capacity(text.len());
let mut copied = 0;
for (start, stop, to) in cuts {
out.push_str(&text[copied..start]);
out.push_str(to);
copied = stop;
}
out.push_str(&text[copied..]);
Some(out)
}
/// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text`
/// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD.
fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut spans = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = bytes
.get(i + 1..i + 3)
.filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit))
.and_then(|h| std::str::from_utf8(h).ok())
.and_then(|h| u8::from_str_radix(h, 16).ok());
match hex {
Some(v) => {
out.push(v);
spans.push((i, i + 3));
i += 3;
}
None => {
out.push(bytes[i]);
spans.push((i, i + 1));
i += 1;
}
}
}
let mut decoded = String::with_capacity(out.len());
let mut decoded_spans = Vec::with_capacity(spans.len());
let mut at = 0;
for chunk in out.utf8_chunks() {
decoded.push_str(chunk.valid());
decoded_spans.extend_from_slice(spans.get(at..at + chunk.valid().len())?);
at += chunk.valid().len();
for _ in chunk.invalid() {
decoded.push(char::REPLACEMENT_CHARACTER);
let span = *spans.get(at)?;
decoded_spans.extend([span; 3]);
at += 1;
}
}
Some((decoded, decoded_spans))
}
/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, as [`replace_text`]
/// writes them in text without `%XX` escapes. With the rewrite's `fold`, the ids and paths as written
/// are replaced first, so that two libraries whose paths differ only in letter case keep their own
/// ids, and then the ones written in another case. `None` when nothing changed.
fn replace_plain_text(s: &str, r: &Rewrite) -> Option<String> {
let exact = replace_matches(s, r, false);
if !r.fold {
return exact;
}
replace_matches(exact.as_deref().unwrap_or(s), r, true).or(exact)
}
/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, matched in any case
/// of ASCII letters with `fold`. `None` when nothing changed.
fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> {
let mut out: Option<String> = None;
for (from, to) in pairs {
for (from, to) in &r.pairs {
let text = out.as_deref().unwrap_or(s);
if from.is_empty() || !text.contains(from.as_str()) {
let found = occurrences(text, from, fold);
if found.is_empty() {
continue;
}
let mut next = String::with_capacity(text.len());
let mut copied = 0;
for (at, _) in text.match_indices(from.as_str()) {
for at in found {
let end = at + from.len();
if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) {
if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) {
next.push_str(&text[copied..at]);
next.push_str(to);
copied = end;
@@ -500,13 +725,19 @@ fn continues_path(rest: &str) -> bool {
rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\'))
}
/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket,
/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden
/// one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden.
fn readable_continuation(text: &str) -> bool {
let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len());
let path = &text[..end];
readable(path.strip_prefix("loaded/").unwrap_or(path))
/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote,
/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written
/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a
/// hidden one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden.
fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool {
let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n);
let longer = &text[at..stop];
if longer.split(['/', '\\']).any(|part| part == "." || part == "..") {
return false;
}
let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want };
let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") };
r.hiding.readable.iter().any(|known| same(known, &id))
}
/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a
@@ -606,6 +837,19 @@ fn unreadable_absolute(s: &str) -> bool {
}
}
/// Is `s` written as a path that the request may not read: an absolute path or a `file:` URL
/// ([`unreadable_absolute`]), `~` or a path under it, or a path that starts with `.` or `..` and a
/// separator? It is checked as the file access check reads it, relative to the working folder. Other
/// text with a separator (`a/b`) stays.
fn unreadable_path_like(s: &str) -> bool {
if unreadable_absolute(s) {
return true;
}
let t = s.trim_start();
let relative = t.trim_end() == "~" || ["~/", "~\\", "./", ".\\", "../", "..\\"].iter().any(|p| t.starts_with(p));
relative && !readable_value(s)
}
/// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])?
fn readable_value(s: &str) -> bool {
match file_url_path(s) {
@@ -668,13 +912,16 @@ fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<Strin
/// 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 an absolute path
/// or a `file:` URL ([`unreadable_absolute`]).
/// 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`]).
fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) {
use vectorcraft_engine::file_access::is_path_key;
params.retain(|key, v| match v.as_str() {
Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)),
None => true,
params.retain(|key, v| {
!unreadable_path_like(key)
&& match v.as_str() {
Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)),
None => true,
}
});
for (key, v) in params.iter_mut() {
if let Value::Array(paths) = v
@@ -686,13 +933,13 @@ fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) {
}
}
/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL
/// the request may not read taken out of each list.
/// [`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) {
match v {
Value::Object(fields) => leave_out_unreadable_params(fields),
Value::Array(items) => {
items.retain(|item| !item.as_str().is_some_and(unreadable_absolute));
items.retain(|item| !item.as_str().is_some_and(unreadable_path_like));
items.iter_mut().for_each(leave_out_unreadable_values);
}
_ => {}
@@ -777,38 +1024,58 @@ const DOCKING_ACTION: &str = "action";
/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id
/// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each
/// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key,
/// errors included: the libraries hidden after it ran, and before it for a request that names one.
/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator.
/// errors included, an error from a panic too: the libraries hidden before it ran and after it. The
/// error of a failed request also hides the paths outside the read root that the request sent
/// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path
/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent
/// after the tool's edit) holds no library id or path.
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
if vectorcraft_engine::file_access::current().is_none() {
return dispatch(app, ctx, req);
}
let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() };
let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect();
// Pointer, key and text input does not load, close or rename a library: the libraries hidden after
// it ran are the ones hidden before, so frequent input does not read the UI state twice.
let before = if INPUT_METHODS.contains(&req.method.as_str()) { Hiding::default() } else { unreadable_library_ids(app) };
let shown: Vec<_> = before.hidden.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect();
let mut params = req.params.clone();
let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) {
let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq };
dispatch(app, ctx, &request)
} else {
dispatch(app, ctx, req)
let swapped = !shown.is_empty() && names_a_loaded_library(&req.params) && swap_library_params(&mut params, &shown);
let ran = vectorcraft_engine::guard::catch_panic(|| {
if swapped {
let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq };
dispatch(app, ctx, &request)
} else {
dispatch(app, ctx, req)
}
});
let outcome = match ran {
Ok(outcome) => outcome,
Err(msg) => {
app.picks.recover();
app.session.set_replaying(false);
err(format!("internal error: {msg} (please report this bug)"))
}
};
let Outcome::Done(mut reply) = outcome else { return outcome };
if !holds_a_separator(&reply) {
return Outcome::Done(reply);
}
let mut ids = unreadable_library_ids(app);
for pair in before {
if !ids.iter().any(|(id, _)| *id == pair.0) {
ids.push(pair);
}
let mut hiding = unreadable_library_ids(app);
hiding.merge(before);
if reply.get("ok").and_then(Value::as_bool) == Some(false) {
let mut sent = Vec::new();
sent_paths(&req.params, &mut sent);
hiding.set_hidden(sent, cfg!(windows));
}
hide_library_ids(&mut reply, &ids);
hide_library_ids(&mut reply, &hiding);
Outcome::Done(reply)
}
/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`?
/// The control methods that send synthetic input.
const INPUT_METHODS: [&str; 7] = ["ui.pointer", "ui.click", "ui.drag", "ui.move", "ui.wheel", "ui.key", "ui.text"];
/// Does a string or a key of `v`, at any depth, hold a `/`, a `\` or a `%`?
fn holds_a_separator(v: &Value) -> bool {
let has = |s: &str| s.contains(['/', '\\']);
let has = |s: &str| s.contains(['/', '\\', '%']);
match v {
Value::String(s) => has(s),
Value::Array(items) => items.iter().any(holds_a_separator),
@@ -886,6 +1153,18 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest)
hide_library_ids(&mut docking, &ids);
ok(docking)
}
// A request that stops with a panic quoting the status text, after clearing the Color Guide's
// limit with `forget`.
#[cfg(test)]
"test.panic" => {
if p.get("forget").and_then(Value::as_bool) == Some(true) {
app.ui.color_guide_limit.clear();
}
panic!("failed on {}", app.ui.status)
}
// A request that succeeds with the status text.
#[cfg(test)]
"test.status" => ok(json!(app.ui.status)),
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
+111 -15
View File
@@ -18,29 +18,125 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value
fn a_reply_hides_a_hidden_library_whatever_surrounds_it() {
let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned();
let id = format!("loaded/{path}");
let hidden = [(id.clone(), "loaded/b.svg".to_string())];
let hidden = hiding(&[(&id, "loaded/b.svg")], &[]);
let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")});
let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden));
let _ = std::fs::remove_dir_all(root);
hide_under_a_root("named-reply", &mut reply, &hidden);
assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}");
}
/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path.
#[test]
fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() {
let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())];
let data = "QUJDassets/iconREVG";
let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}});
let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id()));
/// A [`super::Hiding`] with `hidden` ids, each with its shown id, and `readable` ids.
fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding {
let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect();
hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect() }
}
/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`.
fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) {
let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden));
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden));
let _ = std::fs::remove_dir_all(root);
assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}");
}
/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or
/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64
/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched.
#[test]
fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() {
let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]);
let data = "QUJDassets/iconREVGxQQ==";
let mut reply = json!({"ok": true, "result": {
"files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}],
"status": "from assets/icon",
"dataBase64": {"note": "from assets/icon"},
"pngBase64": ["from assets/icon"],
"otherBase64": data,
}});
hide_under_a_root("base64", &mut reply, &hidden);
let files = &reply["result"]["files"][0];
assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}");
assert_eq!(files["pngBase64"], "icon.png", "{reply}");
assert_eq!(reply["result"]["status"], "from icon", "{reply}");
assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}");
assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}");
assert_eq!(reply["result"]["otherBase64"], "QUJDiconREVGxQQ==", "{reply}");
}
/// A hidden path followed by more text stays only when the longer text is another loaded library's
/// id: text after the path that the request may read, a `..` part and the part of the path before a
/// space do not keep it.
#[test]
fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() {
let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id()));
let root = base.join("in");
std::fs::create_dir_all(&root).unwrap();
let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned();
let other = base.join("outside").join("b.svg").to_string_lossy().into_owned();
let known = format!("loaded/{other}{}sub.svg", std::path::MAIN_SEPARATOR);
let hidden = hiding(&[(&format!("loaded/{hidden_path}"), "loaded/b.svg"), (&format!("loaded/{other}"), "loaded/b.svg (2)")], &[&known]);
let up = ["", "..", "..", "in", "a.svg"].join(std::path::MAIN_SEPARATOR_STR);
let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")});
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden));
let _ = std::fs::remove_dir_all(base);
assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}");
}
/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden
/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform.
#[test]
fn a_reply_hides_a_path_in_any_case_and_escaped() {
let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]);
let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"});
let pairs = super::hidden_text(&hidden.hidden);
let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true };
super::replace_in_value(&mut reply, &rewrite);
assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}");
// Two libraries whose paths differ only in letter case keep their own ids.
let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]);
let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"});
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true });
assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}");
let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"});
hide_under_a_root("escaped", &mut reply, &hidden);
assert_eq!(reply["error"], "file:///b.svg", "{reply}");
let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]);
let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"});
hide_under_a_root("escaped-mixed", &mut reply, &hidden);
assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}");
}
/// A path a failed request sent takes the place of a hidden library's id for the same path, written
/// with the other separator, and in another letter case when matching in any case (as on Windows).
#[test]
fn a_sent_path_replaces_a_hidden_id_for_the_same_path() {
let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]);
hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true);
let ids: Vec<&str> = hidden.hidden.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["loaded/c:/libs/B.svg", "loaded//other/b.svg"]);
hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/b.svg".to_string())], false);
assert_eq!(hidden.hidden.len(), 3, "{hidden:?}");
}
/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or
/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and
/// a path inside the root are not.
#[test]
fn a_request_sends_paths_that_an_error_hides() {
let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id()));
let root = base.join("in");
std::fs::create_dir_all(&root).unwrap();
let inside = root.join("a.svg").to_string_lossy().into_owned();
let keep = json!(["a/b", inside, "file://host/g.svg"]);
let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep});
let mut sent = Vec::new();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&params, &mut sent));
let _ = std::fs::remove_dir_all(base);
let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}"));
assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]);
}
/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a
+4
View File
@@ -166,6 +166,10 @@ pub struct VectorcraftApp {
/// `ui.pointer` replies waiting for their events and the edit the tool computes on another
/// thread ([`control::Outcome::AfterToolEdit`]), with the numbers of those events.
pointer_replies: Vec<control::PendingPointerReply>,
/// The id each library loaded from outside the automation read roots is shown under, kept while
/// the UI state or the session refers to the library, so that the id a control client was given
/// keeps naming the same library.
shown_library_ids: std::cell::RefCell<Vec<(String, String)>>,
/// Admitted captures whose settled frame has not come yet (issued one at a time).
queued_screenshots: Vec<QueuedScreenshot>,
screenshot_token: u64,
+113 -1
View File
@@ -627,6 +627,118 @@ fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() {
let _ = std::fs::remove_dir_all(base);
}
/// Hidden libraries with the same file name are numbered in the order they were loaded, not in the
/// order of their paths.
#[test]
fn hidden_libraries_are_numbered_in_load_order() {
let (mut app, tx, base) = confined("hidden-load-order");
let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n";
for dir in ["z", "a"] {
std::fs::create_dir_all(base.join("outside").join(dir)).unwrap();
let path = base.join("outside").join(dir).join("b.gpl");
std::fs::write(&path, gpl).unwrap();
app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap();
}
app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl")));
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}");
let _ = std::fs::remove_dir_all(base);
}
/// A hidden library keeps the id a reply showed for it when another library with the same file name is
/// loaded later.
#[test]
fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() {
let (mut app, tx, base) = confined("hidden-stable-id");
let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl")));
let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap();
app.ui.library_panels = vec![panel];
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}");
std::fs::create_dir_all(base.join("outside").join("z")).unwrap();
let path = base.join("outside").join("z").join("b.gpl");
std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap();
app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap();
app.ui.color_guide_limit = format!("loaded/{}", s(path));
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}");
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}");
let _ = std::fs::remove_dir_all(base);
}
/// An error from a panic hides a hidden library's path, also when the request stopped referring to
/// the library before it panicked.
#[test]
fn a_panic_reply_hides_a_library_hidden_before_the_request() {
let (mut app, tx, base) = confined("panic-hidden");
let path = s(base.join("outside/b.svg"));
app.ui.color_guide_limit = format!("loaded/{path}");
app.ui.status = format!("Loaded {path}");
let r = request(&mut app, &tx, "test.panic", json!({"forget": true}));
assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}");
let _ = std::fs::remove_dir_all(base);
}
/// An error shows a path outside the read root that the request sent by its file name, whether or not
/// a hidden library was loaded from that path, so that it reads the same either way. A path the
/// request sends does not show the app's own text that holds it, in an error or a result.
#[test]
fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() {
let (mut app, tx, base) = confined("client-echo");
let path = s(base.join("outside/b.svg"));
// Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`.
let first = format!("loaded/{}", s(base.join("outside/z/b.svg")));
let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap();
app.ui.library_panels = vec![panel];
app.ui.color_guide_limit = format!("loaded/{path}");
app.ui.status = format!("Loaded {path}");
let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")];
for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) {
let r = request(&mut app, &tx, "app.open", json!({"path": sent}));
assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}");
}
let r = request(&mut app, &tx, "test.status", json!({"guess": path}));
assert_eq!(r["result"], "Loaded b.svg (2)", "{r}");
let r = request(&mut app, &tx, "test.panic", json!({"guess": path}));
assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}");
let _ = std::fs::remove_dir_all(base);
}
/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped
/// shows the file name.
#[test]
fn a_reply_hides_a_path_with_escaped_separators() {
let (mut app, tx, base) = confined("escaped-separators");
let path = s(base.join("outside/b.svg"));
app.ui.color_guide_limit = format!("loaded/{path}");
app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C"));
let r = request(&mut app, &tx, "test.status", json!({}));
assert_eq!(r["result"], "from file:b.svg", "{r}");
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and
/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and
/// other text with a separator stay.
#[test]
fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() {
let (mut app, tx, base) = confined("inspect-relative");
let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({
"name": "set",
"actions": [{"name": "files", "steps": [
["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}],
["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}],
]}],
}))
.unwrap();
app.ui.action_sets = vec![set];
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"];
let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]);
assert_eq!(steps, &want, "{r}");
let _ = std::fs::remove_dir_all(base);
}
/// A closed panel's former place, as `ui.docking_hidden` keeps it.
fn hidden_place() -> craft_ui::docking::Location<String> {
serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap()
@@ -687,7 +799,7 @@ fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() {
let ui = &r["result"]["ui"];
assert_eq!(
(ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()),
(Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")),
(Some("loaded/b.svg (2)"), Some("library:swatches:loaded/b.svg")),
"{r}"
);
let d = request(&mut app, &tx, "ui.docking", json!({}));
+18 -9
View File
@@ -131,9 +131,10 @@ the suite) keep the agent to the folders you give it:
documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`;
in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`,
`files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
`destinationFolder`), the entries of a list under such a param, and under any other param or in any list an
absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names
another host is always left out); and the folder
`destinationFolder`), the entries of a list under such a param, and under any other param, as a key or in any
list, an absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that
names another host is always left out), and `~`, a path under `~` or a path that starts with `./` or `../`
(either separator) that resolves outside the roots from the app's working folder; and the folder
preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields`
(`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an
empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the
@@ -142,17 +143,25 @@ the suite) keep the agent to the folders you give it:
params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with
either separator counts as a path, also one saved on another platform. When another hidden library has the same
file name, the libraries take `loaded/<file name>`,
`loaded/<file name> (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both
replies. A hidden library skips an id that a library loaded without a path already has. The other entries keep
their order, and the app's own state is not changed. A request may name such a library or its panel by the id
`loaded/<file name> (2)`, `(3)` and so on: first the libraries loaded in the session (swatch, graphic style,
symbol and brush libraries, each kind in the order it was loaded), then the library panels in their order, the
Color Guide's limit and the other ids. Each one keeps its id in both replies, and from one request to the next
while the app refers to it, also when another library with the same file name is loaded later. A hidden library skips an id that
a library loaded without a path already has. The other entries keep their order, and the app's own state is not
changed. A request may name such a library or its panel by the id
shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`,
`below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}`
reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path
appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its
path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query
(`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one
keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one
stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is.
(`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the
libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:`
URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path
keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable
library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the
read roots that the request sent by its file name, whether or not a library was loaded from it, so that the
error reads the same either way. Encoded file data, a base64 string under `dataBase64`,
`pngBase64` or `thumbnailBase64`, is sent as it is.
- Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any
of the write roots written, so one session can read a project folder and a shared assets folder, or write to an
output folder and a scratch folder, without widening a root to their common parent. A folder given twice