Merge pull request #6827 from dotCooCoo/fix/read-roots-document-replies

MCP: document.inspect and document.json leave out paths outside a confined client's read roots
This commit is contained in:
BFlat
2026-11-13 03:29:01 +07:00
committed by GitHub
6 changed files with 187 additions and 4 deletions
+6 -2
View File
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
doc_move
),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
cmd!(
@@ -50,7 +50,11 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
doc_compare
),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| {
let st = s.doc()?;
let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st));
Ok(serde_json::to_value(&*d).unwrap_or(Value::Null))
}),
cmd!(
"document.setUnits",
"Units",
+75 -1
View File
@@ -28,7 +28,9 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_doc::{
Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions,
};
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
@@ -1195,6 +1197,78 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
});
}
/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without
/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not
/// allow names its file alone, without its relative path or the size and date kept of its file,
/// and each data set's image value it does not allow names its file alone too. The folder Export
/// for Screens last wrote to is left out of the export settings when it is outside the read roots.
/// A link whose path is allowed but whose relative path leads outside the read roots loses its
/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited
/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored
/// brush entry that does not read as a brush is left out of the copy, since its links cannot be
/// checked, and a stored brush library that is not a list becomes an empty one.
pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> {
if crate::file_access::current().is_none() {
return Cow::Borrowed(d);
}
let doc_path = base.doc;
// A stored relative path is followed from the folder of the file the document was read from,
// and a link is also looked for from the document's own folder: it is shown only when both
// places it names are inside the read roots.
let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from));
let mut d = d.clone();
// The pattern being edited as it was before is checked as one more pattern, then put back.
let original = d.pattern_edit.as_mut().and_then(|e| e.original.take());
let edited = original.is_some();
d.patterns.extend(original);
// The art of each stored brush is checked as more layers, then put back.
let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default());
let mut brushes: Vec<vectorcraft_brush::Brush> =
stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect();
let layers = d.layers.len();
d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone())));
d.update_links_where(
|l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden),
|l| {
if path_shown(&l.path, base.doc) {
l.relative = None;
} else {
let name = l.name().to_string();
*l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() };
}
},
);
let mut checked = d.layers.split_off(layers).into_iter();
if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) {
e.original = Some(def);
}
if stored.is_some() {
// Each brush is written as it reads, so fields it does not read are left out too.
let mut kept = vec![];
for b in &mut brushes {
for (slot, art) in b.art_mut().into_iter().zip(&mut checked) {
*slot = Arc::unwrap_or_clone(art);
}
kept.extend(serde_json::to_value(&*b).ok());
}
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept));
}
if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) {
d.export_settings.remove("folder");
}
let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path));
if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) {
for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) {
if hidden(&*v)
&& let DataValue::Image(path) = v
{
*path = LinkInfo::new(path.as_str()).name().to_string();
}
}
}
Cow::Owned(d)
}
// ---------- commands ----------
fn check(s: &mut Session, p: &Value) -> Result<Value> {
+2 -1
View File
@@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value {
"title": st.title(),
// The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name.
"fileInfoTitle": d.info_title(),
"path": st.path,
// Null for a confined caller when the file is outside its read roots.
"path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()),
"dirty": st.is_dirty(),
"revision": st.revision,
"units": d.units.label(),
@@ -1084,6 +1084,102 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() {
assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}");
}
/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed
/// document and a linked image outside the read roots are not named by path, and the document
/// keeps its links.
#[test]
fn document_replies_leave_out_paths_outside_the_read_roots() {
let away = Folder::new("doc-lrr-away");
let home = Folder::new("doc-lrr-home");
let src = away.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let pic = away.file("photo.png");
write(&pic, &crate::tests_links::png(60, 30, BLUE));
let mut s = session();
let id = place(&mut s, &src);
let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap();
s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap();
let image = r["ids"][0].as_u64().unwrap();
let poster = away.file("poster.vectorcraft");
save(&mut s, &poster);
// The folder Export for Screens wrote to, and a pattern of the linked image being edited: its
// definition before editing is kept beside the tile art.
let out = away.file("out");
s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap();
let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone();
s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap();
assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some()));
let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| {
let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap();
crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap()
};
let inspect = ask(&mut s, &home.0, "document.inspect");
assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}");
assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}");
let model = ask(&mut s, &home.0, "document.json").to_string();
assert!(!model.contains("doc-lrr-away"), "{model}");
for name in ["logo.vectorcraft", "photo.png"] {
assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}");
}
assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}");
// The document keeps its links, and a caller whose read roots hold the files sees every path.
assert_eq!(placed(&s, id).link.path, src);
for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] {
let model = model.to_string();
for path in [&src, &pic, &out] {
assert!(model.contains(&json!(path).to_string()), "{path}: {model}");
}
}
assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster));
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster));
}
/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots
/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a
/// link inside them whose stored relative path leads outside them loses that relative path.
#[test]
fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() {
let away = Folder::new("doc-brr-away");
let home = Folder::new("doc-brr-home");
let pic = away.file("photo.png");
write(&pic, &crate::tests_links::png(60, 30, BLUE));
let near_pic = home.file("near.png");
write(&near_pic, &crate::tests_links::png(20, 10, RED));
let mut s = session();
let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
let image = r["ids"][0].as_u64().unwrap();
let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap();
save(&mut s, &home.file("poster.vectorcraft"));
{
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap();
lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic}));
d.update_links(|l| {
if l.name() == "near.png" {
l.relative = Some("../doc-brr-away/near.png".into());
}
});
}
let unconfined = s.execute("document.json", &json!({})).unwrap().to_string();
for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] {
assert!(unconfined.contains(&part), "{part}: {unconfined}");
}
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap();
let text = model.to_string();
assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}");
assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}");
assert!(text.contains(&json!(near_pic).to_string()), "{text}");
// The document keeps its brushes and links.
let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string();
assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}");
}
/// Save As reads its defaults from the catalog; using them must retain current editable links.
#[test]
fn native_save_catalog_defaults_keep_placed_documents_editable() {
+7
View File
@@ -222,6 +222,13 @@ the suite) keep the agent to the folders you give it:
`null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and
`prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the
read roots, and the preset stays. The preferences themselves are not changed.
- `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots.
`document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and
leaves out the link's `relative`, `size` and `modified`. This covers the art of the brushes in `unknown.brushes`
too. A link whose file is inside them keeps its `path` but leaves out a `relative` that leads outside them. A
stored brush entry that does not read as a brush is left out of `unknown.brushes`. `document.json` also names a data set's image
value outside them by its file name alone, and leaves out the `folder` of `export_settings` (the settings Export
for Screens last used) when that folder is outside them. The document itself is not changed.
- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths
outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in
folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source`
+1
View File
@@ -776,6 +776,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Errors under `--automation-read-root`: a refused save without a path and `file.revert` (also as an `action.batch` step), `script.listInstalled` and `links.relink {copy}` name the document's own file, the Scripts folder or the document's folder by its file name when it is outside the read roots, and keep the roots and the reason; `links.relink`'s copies name such a file by its file name too. A path the client gives stays whole. `crates/engine/src/tests/save.rs` (`pathless_save_rejection_hides_document_folder`), `crates/engine/src/cmd/fileio/action_batch.rs`, `crates/engine/src/tests/links.rs`, `crates/engine/src/tests/script_folder.rs`.
- `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`).
- Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`).
- Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, brush art included, leaves out a relative path that leads outside them and a stored brush entry that does not read as a brush, names a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`, `document_json_checks_brush_art_and_relative_paths_against_the_read_roots`).
- Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`.
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session unless a library loaded later without a file shows that id itself, which moves it to the next free `loaded/<file name> (N)`, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
- Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`.