MCP: hidden library names follow the platform's path rules

- Outside Windows a `\` stays part of a hidden library's or sent path's
  file name; another platform's absolute path is still split on `\`.
- A hidden relative path is matched after leading `./` and `../` parts
  only when they start the path, so `/work/./data/icon.svg` keeps its
  text.
- `ui.inspect` and `ui.docking` replies are hidden once, so a library
  at the top of the file system shows as `loaded/<file name>` with its
  separator.
This commit is contained in:
dotCooCoo
2026-11-11 21:56:02 -08:00
parent 8e6a9e5b71
commit c74821031e
5 changed files with 95 additions and 29 deletions
+12 -7
View File
@@ -471,6 +471,15 @@ fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
}
/// The file name a concealed library or path is shown by: the last part of `path` as this platform
/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute
/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one
/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part.
pub fn concealed_file_name(path: &str) -> String {
let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten();
native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned())
}
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is
/// never readable under a read root.
@@ -480,8 +489,8 @@ fn readable(path: &str) -> bool {
/// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each
/// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is
/// named. The file name is the last part of the path after either separator, and a path saved as
/// another platform's absolute path counts as one the request may not read. The ids are those of the
/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute
/// path counts as one the request may not read. The ids are those of the
/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers
/// to). When another id already is or shows that id, the next free
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library
@@ -501,11 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
let mut taken = BTreeSet::new();
for ids in [session, others] {
for id in ids {
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| {
// Either separator: a path saved on another platform counts as a path here too.
let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string();
(name, path)
});
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path));
match name {
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
_ => {
+19
View File
@@ -238,6 +238,25 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() {
let _ = std::fs::remove_dir_all(base);
}
/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl`
/// is shown under its whole file name. Another platform's absolute path is still named by the part
/// after its last `\`.
#[cfg(not(windows))]
#[test]
fn a_concealed_library_keeps_a_backslash_in_its_file_name() {
let base = folder("backslash-name");
let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a")));
let foreign = r"loaded/C:\Users\someone\other.gpl".to_string();
let s = Session::new();
let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()]));
let _ = std::fs::remove_dir_all(base);
hidden.sort();
let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())];
want.sort();
assert_eq!(hidden, want);
}
/// A concealed library keeps the id a client was shown when another library with the same file name,
/// whose full id sorts before it, is loaded later: the id still finds the first library.
#[test]
+20 -19
View File
@@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
fields.remove("floating_panels");
leave_out_unreadable_paths(fields);
}
let mut inspected = json!({
json!({
"scriptRunning": app.session.script_running(),
"scriptReport": app.session.script_report(),
"tool": app.session.tool_id(),
@@ -307,9 +307,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting
// for the person at the computer: what asked for it runs again when it answers.
"fileDialog": app.picks.waiting_kind(),
});
hide_library_ids(&mut inspected, &unreadable_library_ids(app));
inspected
})
}
/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel
@@ -435,7 +433,10 @@ fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String
Some(None) => percent_decoded(&s[5..]),
None => s.to_string(),
};
let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return };
let name = vectorcraft_engine::cmd::swatchlib::concealed_file_name(&path);
if name == path {
return;
}
let pair = (format!("loaded/{path}"), format!("loaded/{name}"));
if !out.contains(&pair) {
out.push(pair);
@@ -709,17 +710,22 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> {
}
/// 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.
/// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by
/// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and
/// `/work/./data/icon.svg` do not hold the path `data/icon.svg`; `./data/icon.svg` and
/// `../data/icon.svg` do.
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, '_' | '-' | '.' | '~' | '/' | '\\'))
let mut before = &text[..at];
while let Some(head) = before.strip_suffix(['/', '\\']) {
let Some(rest) = head.strip_suffix("..").or_else(|| head.strip_suffix('.')) else { break };
before = rest;
if !before.ends_with(['/', '\\']) {
break;
}
}
!absolute && 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 `.`,
@@ -1176,12 +1182,7 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest)
"engine.commands" => ok(all_commands(app)),
"document.inspect" => wrap(app.run("document.inspect", p.clone())),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.docking" => {
let mut docking = crate::panel_docking::inspect(app, ctx);
let ids = unreadable_library_ids(app);
hide_library_ids(&mut docking, &ids);
ok(docking)
}
"ui.docking" => ok(crate::panel_docking::inspect(app, ctx)),
// A request that stops with a panic quoting the status text, after clearing the Color Guide's
// limit with `forget`.
#[cfg(test)]
+42 -2
View File
@@ -278,14 +278,54 @@ fn another_platforms_absolute_path_is_unreadable() {
assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]);
}
/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a
/// longer path.
/// A hidden relative path is hidden where it stands alone or after leading `./` and `../` parts, and
/// stays where it ends a longer path, also one with a `.` or `..` part before it.
#[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}");
let mut reply = json!({"result": "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../data/icon.svg and './../data/icon.svg'"});
hide_under_a_root("relative-dots", &mut reply, &hidden);
assert_eq!(
reply["result"], "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../icon.svg and './../icon.svg'",
"{reply}"
);
}
/// Outside Windows a `\` is part of a file name: a sent path outside the read root is shown by its
/// whole file name.
#[cfg(not(windows))]
#[test]
fn a_sent_path_keeps_a_backslash_in_its_file_name() {
let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", 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!({"path": "/out/weird\\name.svg"}), &mut sent));
let _ = std::fs::remove_dir_all(root);
assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]);
}
/// `ui.inspect` and `ui.docking` show a hidden library whose file is at the top of the file system
/// (`/b.svg`) as `loaded/b.svg`, with its separator.
#[test]
fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
let panel = "library:swatches:loaded//b.svg".to_string();
app.ui.color_guide_limit = "loaded//b.svg".to_string();
app.ui.open_panel = Some(panel);
let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || {
(input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({})))
});
let _ = std::fs::remove_dir_all(root);
assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}");
assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}");
assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}");
}
/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded
+2 -1
View File
@@ -142,7 +142,8 @@ the suite) keep the agent to the folders you give it:
Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel`
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, and outside Windows a path that starts
with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same
with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its
file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`). 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 they
are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the
libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest