MCP: hidden library paths are matched before the paths a request sent

A sent path whose text overlapped part of a hidden library's path could
keep that path from being hidden; the hidden libraries' ids and paths
are now matched first. A hidden relative path is also hidden where a
reply shows the absolute path it reads as from the working folder, and
a hidden library sent by that other form shows as the sent path does.
This commit is contained in:
dotCooCoo
2026-11-12 07:17:46 -08:00
parent d1f7888ad4
commit 6d7a8249a7
4 changed files with 106 additions and 40 deletions
+65 -32
View File
@@ -356,7 +356,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding {
.collect();
// 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()));
Hiding { hidden, readable: readable_ids }
Hiding { hidden, readable: readable_ids, sent: Vec::new() }
}
/// What a reply hides ([`hide_library_ids`]).
@@ -368,6 +368,9 @@ struct Hiding {
/// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or
/// path that begins one of them stays ([`begins_known_id`]).
readable: std::collections::BTreeSet<String>,
/// The paths outside the read roots that the request sent ([`sent_paths`]), each as a hidden
/// library id with the id shown. They are matched after the hidden libraries.
sent: Vec<(String, String)>,
}
impl Hiding {
@@ -387,16 +390,26 @@ impl Hiding {
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`.
/// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds
/// for the same path (written with either separator, relative or as the absolute path it reads as
/// ([`absolute_form`]), and in any case of ASCII letters with `fold`) is shown as the sent path is.
fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) {
let key = |id: &str| {
let id = id.replace('\\', "/");
let path = id.strip_prefix("loaded/").unwrap_or(id);
let id = absolute_form(path).unwrap_or_else(|| path.to_string()).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);
for (id, shown) in &mut self.hidden {
if let Some(at) = sent.iter().position(|k| *k == key(id)) {
shown.clone_from(&pairs[at].1);
}
}
for pair in pairs {
if !self.sent.iter().any(|(id, _)| *id == pair.0) {
self.sent.push(pair);
}
}
}
}
@@ -477,31 +490,48 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str
/// 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);
if pairs.is_empty() {
if pairs.iter().all(Vec::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 as `{:?}`
/// quotes it (each `\`, quote and control character escaped).
fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> {
/// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of
/// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with
/// the id shown, and the 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). A relative path is
/// also replaced as the absolute path the file access check reads it as, against the working folder.
fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> {
vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)]
}
/// The absolute path that `path`, a relative path (one that does not start with a separator, a drive
/// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other
/// path.
fn absolute_form(path: &str) -> Option<String> {
let relative = !path.starts_with(['/', '\\', '~']) && !matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
relative.then(|| std::path::absolute(path).ok()).flatten().map(|p| p.to_string_lossy().into_owned())
}
/// The text [`hidden_text`] replaces for the ids of `ids`, longest first.
fn text_pairs(ids: &[(String, String)]) -> 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 &hiding.hidden {
for (id, shown) in ids {
let path = &id["loaded/".len()..];
let name = shown.strip_prefix("loaded/").unwrap_or(shown);
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));
for path in std::iter::once(path.to_string()).chain(absolute_form(path)) {
let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\"));
let debug = [quoted(&path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name)));
let plain = [path, 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));
}
}
}
}
@@ -510,10 +540,10 @@ fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> {
pairs
}
/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match
/// in any case of ASCII letters.
/// What [`replace_in_value`] writes: the groups of text to replace ([`hidden_text`]), what the reply
/// hides, and whether to match in any case of ASCII letters.
struct Rewrite<'a> {
pairs: Vec<(String, String)>,
pairs: Vec<Vec<(String, String)>>,
hiding: &'a Hiding,
fold: bool,
}
@@ -569,20 +599,23 @@ 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. 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.
/// is found in `s` as it was given, the hidden libraries' text before the sent paths' text, each group
/// 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 of a group 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 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);
for group in &r.pairs {
for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) {
for (from, to) in group {
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);
}
found.claim(s, None, from, to, fold, r);
}
}
if found.cuts.is_empty() {
+36 -5
View File
@@ -28,7 +28,7 @@ fn a_reply_hides_a_hidden_library_whatever_surrounds_it() {
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::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect(), sent: Vec::new() }
}
/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`.
@@ -125,10 +125,41 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() {
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:?}");
let shown: Vec<&str> = hidden.hidden.iter().map(|(_, shown)| shown.as_str()).collect();
assert_eq!(shown, ["loaded/B.svg", "loaded/b.svg"], "{hidden:?}");
assert_eq!(hidden.sent, [("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())]);
hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/x.svg".to_string())], false);
assert_eq!((hidden.hidden[1].1.as_str(), hidden.sent.len()), ("loaded/b.svg", 2), "{hidden:?}");
// A library loaded by a relative path, and the absolute path it reads as, sent: the sent path's
// file name is shown for both, not the library's numbered id.
let mut hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg (2)")], &[]);
let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned();
hidden.set_hidden(vec![(format!("loaded/{absolute}"), "loaded/icon.svg".to_string())], false);
let mut reply = json!({"error": format!("can't read \"data/icon.svg\" or {absolute}")});
hide_under_a_root("sent-absolute", &mut reply, &hidden);
assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}");
}
/// A sent path whose text overlaps part of a hidden library's path does not keep that library's path
/// from being hidden: the hidden libraries are matched first.
#[test]
fn a_sent_path_does_not_stop_a_hidden_library_path_from_being_hidden() {
let mut hidden = hiding(&[("loaded//home/secret/dir/lib", "loaded/lib")], &[]);
hidden.set_hidden(vec![("loaded//dir/lib/sent-folder/longer".to_string(), "loaded/longer".to_string())], false);
let mut reply = json!({"error": "can't read /home/secret/dir/lib/sent-folder/longer"});
hide_under_a_root("sent-overlap", &mut reply, &hidden);
assert_eq!(reply["error"], "can't read lib/sent-folder/longer", "{reply}");
}
/// A hidden relative path is also hidden where a reply shows it as the absolute path it reads as from
/// the working folder.
#[test]
fn a_reply_hides_a_hidden_relative_path_written_as_absolute() {
let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]);
let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned();
let mut reply = json!({"error": format!("can't read {absolute}")});
hide_under_a_root("relative-absolute", &mut reply, &hidden);
assert_eq!(reply["error"], "can't read icon.svg", "{reply}");
}
/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names
+1 -1
View File
@@ -905,7 +905,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 (2)"), Some("library:swatches:loaded/b.svg")),
(Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")),
"{r}"
);
let d = request(&mut app, &tx, "ui.docking", json!({}));
+4 -2
View File
@@ -175,8 +175,10 @@ the suite) keep the agent to the folders you give it:
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`,
`destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are
matched before the paths the request sent, so a sent path written over part of a hidden library's path does not
keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg`
keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. 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`