MCP: replies hide each sent path the same way whatever the outcome

Each hidden id or path is replaced once, chosen from the reply as given,
so a hidden path inside another library's shown id is not replaced
again, and a readable id written with %XX escapes keeps the hidden path
it begins with. A path quoted with {:?} is matched with its quotes and
control characters escaped. A path outside the read roots that the
request sent is shown by its file name in a reply that succeeded as in
one that failed, and a path param counts with a relative path too. A
relative path is not replaced where it ends a longer path. Without a
read root, a file: URL that names another host stays in ui.inspect's
recorded steps.
This commit is contained in:
dotCooCoo
2026-11-12 07:17:46 -08:00
parent f6cabe62cc
commit 62be2df058
4 changed files with 200 additions and 112 deletions
+117 -99
View File
@@ -401,13 +401,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`]), 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.
/// ([`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.
fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) {
fn add(s: &str, out: &mut Vec<(String, String)>) {
if !unreadable_path_like(s) {
collect_sent_paths(v, 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)>) {
fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) {
if !unreadable_path_like(s) && !(path_param && !readable_value(s)) {
return;
}
// A URL that names another host as the path after its scheme (`//host/share/…`).
@@ -423,12 +429,12 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) {
}
}
match v {
Value::String(s) => add(s, out),
Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)),
Value::String(s) => add(s, path_param, out),
Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)),
Value::Object(fields) => {
for (key, item) in fields {
add(key, out);
sent_paths(item, out);
add(key, false, out);
collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out);
}
}
_ => {}
@@ -470,7 +476,7 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str
/// ([`begins_known_id`]): 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);
let pairs = hidden_text(hiding);
if pairs.is_empty() {
return;
}
@@ -478,16 +484,22 @@ fn hide_library_ids(v: &mut Value, hiding: &Hiding) {
}
/// 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 `{:?}`).
fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> {
/// path in it with the file name shown, the path written with either separator too, and as `{:?}`
/// quotes it (each `\`, quote and control character escaped).
fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> {
let quoted = |s: &str| {
let q = format!("{s:?}");
q[1..q.len() - 1].to_string()
};
let mut pairs: Vec<(String, String)> = Vec::new();
for (id, shown) in ids {
for (id, shown) in &hiding.hidden {
let path = &id["loaded/".len()..];
let name = shown.strip_prefix("loaded/").unwrap_or(shown);
let back = path.replace('/', "\\");
for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] {
for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] {
let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\"));
let debug = [quoted(path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name)));
let plain = [path.to_string(), forward, back].map(|p| (p, name.to_string()));
for (path, name) in plain.into_iter().chain(debug) {
for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] {
if !pairs.iter().any(|(f, _)| *f == from) {
pairs.push((from, to));
}
@@ -556,46 +568,70 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> {
/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence
/// that does not begin another loaded library's id ([`begins_known_id`]). 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.
/// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence
/// is found in `s` as it was given, longest `from` first, and one that overlaps an occurrence already
/// found, or a loaded library's id that one began, stays: a replacement's text is not searched again.
/// With the rewrite's `fold`, the ids and paths as written are found 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_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 && !begins_known_id(&decoded, at, end, r) {
taken.push((at, end));
cuts.push((spans[at].0, spans[end - 1].1, to.as_str()));
let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None };
let mut found = Found::default();
for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) {
for (from, to) in &r.pairs {
if let Some((text, spans)) = &decoded {
found.claim(text, Some(spans.as_slice()), from, to, fold, r);
}
found.claim(s, None, from, to, fold, r);
}
}
if cuts.is_empty() {
return replaced;
if found.cuts.is_empty() {
return None;
}
let mut cuts = found.cuts;
cuts.sort_unstable_by_key(|&(start, _, _)| start);
let mut out = String::with_capacity(text.len());
let mut out = String::with_capacity(s.len());
let mut copied = 0;
for (start, stop, to) in cuts {
out.push_str(&text[copied..start]);
out.push_str(&s[copied..start]);
out.push_str(to);
copied = stop;
}
out.push_str(&text[copied..]);
out.push_str(&s[copied..]);
Some(out)
}
/// The spans of a text that [`replace_text`] replaces, each with its replacement, and every span it has
/// taken: the ones it replaces and the loaded library ids it keeps.
#[derive(Default)]
struct Found<'a> {
cuts: Vec<(usize, usize, &'a str)>,
taken: Vec<(usize, usize)>,
}
impl<'a> Found<'a> {
/// Takes each occurrence of `from` in `text` that overlaps no span taken: as a span to write as `to`,
/// or, where it begins a loaded library's id ([`begins_known_id`]), that id's span, kept. `spans`
/// gives the range of the original text each byte of a decoded `text` was read from.
fn claim(&mut self, text: &str, spans: Option<&[(usize, usize)]>, from: &str, to: &'a str, fold: bool, r: &Rewrite) {
let span = |at: usize, end: usize| spans.map_or((at, end), |s| (s[at].0, s[end - 1].1));
for at in occurrences(text, from, fold) {
let end = at + from.len();
let (start, stop) = span(at, end);
if self.taken.iter().any(|&(a, b)| start < b && a < stop) || ends_a_longer_path(text, at, from) {
continue;
}
match begins_known_id(text, at, end, r) {
Some(len) => self.taken.push(span(at, at + len)),
None => {
self.taken.push((start, stop));
self.cuts.push((start, stop, to));
}
}
}
}
}
/// `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)>)> {
@@ -639,44 +675,18 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> {
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 &r.pairs {
let text = out.as_deref().unwrap_or(s);
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 found {
let end = at + from.len();
if !begins_known_id(text, at, end, r) {
next.push_str(&text[copied..at]);
next.push_str(to);
copied = end;
}
}
if copied > 0 {
next.push_str(&text[copied..]);
out = Some(next);
}
}
out
/// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at
/// `text[at..]` the end of a longer path: is it preceded by a letter, a digit, `_`, `-`, `.`, `~` or a
/// separator other than one after `.` or `..`? `/work/data/icon.svg` does not hold the path
/// `data/icon.svg`; `./data/icon.svg` does.
fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool {
let absolute =
from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
let before = &text[..at];
!absolute
&& !before.ends_with("./")
&& !before.ends_with(".\\")
&& before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\'))
}
/// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`,
@@ -687,17 +697,18 @@ fn continues_path(rest: &str) -> bool {
/// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written
/// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops
/// continuing a path ([`continues_path`])?
/// continuing a path ([`continues_path`])? The length of that id as written, when it does.
/// A readable library's id that begins with a hidden one stays, also with a space after the hidden
/// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden.
fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool {
fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> Option<usize> {
let rest = &text[at..];
let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b };
let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == "..");
let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/"));
r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| {
r.hiding.readable.iter().filter(|known| !dotted(known)).find_map(|known| {
let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) };
id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..])
let begins = id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]);
begins.then_some(id.len())
})
}
@@ -786,11 +797,11 @@ fn readable(path: &str) -> bool {
}
/// Is `s` a path the request may not read, written as an absolute path (either separator, with or
/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is
/// never readable.
/// without a drive) or a `file:` URL ([`file_url_path`])? Under a read root, a `file:` URL that names
/// another host is never readable.
fn unreadable_absolute(s: &str) -> bool {
match file_url_path(s) {
Some(path) => !path.is_some_and(|p| readable(&p)),
Some(path) => !readable_url_path(path),
None => {
let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic());
absolute && !readable(s)
@@ -814,11 +825,20 @@ fn unreadable_path_like(s: &str) -> bool {
/// 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) {
Some(path) => path.is_some_and(|p| readable(&p)),
Some(path) => readable_url_path(path),
None => readable(s),
}
}
/// May the request read the local path a `file:` URL names ([`file_url_path`])? A URL that names
/// another host may be read only without a read root in force.
fn readable_url_path(path: Option<String>) -> bool {
match path {
Some(path) => readable(&path),
None => vectorcraft_engine::file_access::current().is_none(),
}
}
/// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX`
/// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`).
/// `file:///C:/x` names `C:/x`.
@@ -1001,8 +1021,8 @@ const DOCKING_ACTION: &str = "action";
/// 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, 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
/// reply also shows each path outside the read root that the request sent by its file name, whether it
/// succeeded or failed ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path
/// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent
/// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id
/// or path.
@@ -1037,19 +1057,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
Outcome::Done(reply)
}
/// Hides in `reply` the libraries hidden now and in `before`, and for a failed request the paths
/// outside the read root that `sent`, the request's params, holds ([`handle`]).
/// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that
/// `sent`, the request's params, holds ([`handle`]).
fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) {
if !holds_a_separator(reply) {
return;
}
let mut hiding = unreadable_library_ids(app);
hiding.merge(before);
if reply.get("ok").and_then(Value::as_bool) == Some(false) {
let mut paths = Vec::new();
sent_paths(sent, &mut paths);
hiding.set_hidden(paths, cfg!(windows));
}
let mut paths = Vec::new();
sent_paths(sent, &mut paths);
hiding.set_hidden(paths, cfg!(windows));
hide_library_ids(reply, &hiding);
}
+75 -8
View File
@@ -46,7 +46,7 @@ fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) {
#[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 data = "QUJ+assets/iconREVGxQQ==";
let mut reply = json!({"ok": true, "result": {
"files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}],
"status": "from assets/icon",
@@ -61,7 +61,7 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() {
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}");
assert_eq!(reply["result"]["otherBase64"], "QUJ+iconREVGxQQ==", "{reply}");
}
/// A hidden path followed by more text stays only when the longer text is another loaded library's
@@ -96,19 +96,19 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() {
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 pairs = super::hidden_text(&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 });
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two), hiding: &two, fold: true });
assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}");
// A readable id that begins with a hidden one stays whole in any case, `loaded/` included.
let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]);
let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"});
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested.hidden), hiding: &nested, fold: true });
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested), hiding: &nested, fold: true });
assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}");
let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"});
hide_under_a_root("escaped", &mut reply, &hidden);
@@ -132,8 +132,8 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() {
}
/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names
/// another host), under `~` or starting with `../`, are hidden by their file names; plain text and a
/// path inside the root are not.
/// another host), under `~` or starting with `../`, or as a relative path in a path param, are hidden
/// by their file names; 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()));
@@ -141,13 +141,14 @@ fn a_request_sends_paths_that_an_error_hides() {
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"], "other": keep});
let params = json!({"path": "/x/my lib/b.svg", "file": "outside/h.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": 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}"));
let want = [
pair("outside/h.svg", "h.svg"),
pair("/y/c d.svg", "c d.svg"),
pair("~/e.svg", "e.svg"),
pair("../f.svg", "f.svg"),
@@ -183,6 +184,72 @@ fn an_absolute_path_outside_the_read_root_is_unreadable() {
let _ = std::fs::remove_dir_all(base);
}
/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps
/// it.
#[test]
fn a_url_naming_another_host_stays_without_a_read_root() {
assert!(!super::unreadable_absolute("file://host/share/a.svg"));
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);
assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}));
}
/// 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() {
let hidden = hiding(&[("loaded//outside/a\"b\t.svg", "loaded/a\"b\t.svg")], &[]);
let mut reply = json!({"error": format!("can't read {:?}", "/outside/a\"b\t.svg")});
hide_under_a_root("quoted", &mut reply, &hidden);
assert_eq!(reply["error"], "can't read \"a\\\"b\\t.svg\"", "{reply}");
}
/// Each replacement is chosen from the text as given: a hidden path inside the id shown for another
/// hidden library is not replaced again, and a readable id written with `%XX` escapes keeps the hidden
/// path it begins with.
#[test]
fn a_reply_replaces_each_hidden_text_once() {
let hidden = hiding(&[("loaded//b.svg", "loaded/b.svg")], &[]);
let mut reply = json!({"error": "no loaded//b.svg"});
hide_under_a_root("root-file", &mut reply, &hidden);
assert_eq!(reply["error"], "no loaded/b.svg", "{reply}");
let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg"]);
let mut reply = json!({"error": "file:///art/in%20work/a.svg"});
hide_under_a_root("escaped-readable", &mut reply, &hidden);
assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}");
}
/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a
/// longer path.
#[test]
fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() {
let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]);
let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"});
hide_under_a_root("relative", &mut reply, &hidden);
assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}");
}
/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded
/// as in one that failed.
#[test]
fn a_sent_path_reads_the_same_whatever_the_outcome() {
let app = VectorcraftApp::new(Session::new(), Services::default());
let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let sent = json!({"path": "/outside/b.svg"});
let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}});
let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"});
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default());
super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default());
});
let _ = std::fs::remove_dir_all(root);
assert_eq!(done["result"]["path"], "b.svg", "{done}");
assert_eq!(failed["error"], "can't read b.svg", "{failed}");
}
#[test]
fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
+3 -2
View File
@@ -729,7 +729,8 @@ fn a_panic_reply_hides_a_library_hidden_before_the_request() {
/// 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.
/// request sends does not show the app's own text that holds it, in an error or a result, and a result
/// shows that path by its file name as an error does.
#[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");
@@ -746,7 +747,7 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() {
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}");
assert_eq!(r["result"], "Loaded b.svg", "{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);
+5 -3
View File
@@ -169,9 +169,11 @@ the suite) keep the agent to the folders you give it:
(`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply 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 hidden id or path stays only where it begins
another loaded library's whole 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`,
another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply 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 and whether the
request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`,
`destinationFolder` and the like) counts with a relative path too. A relative path is not replaced where it ends a
longer path (`/work/data/icon.svg` keeps `data/icon.svg`). Encoded file data, a base64 string under `dataBase64`,
`pngBase64` or `thumbnailBase64`, is sent as it is.
- `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library
but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder`