Merge pull request #6699 from dotCooCoo/fix/links-read-roots

MCP: links.list, links.check, links.info and document.open leave out paths outside the read roots
This commit is contained in:
BFlat
2026-11-12 11:41:52 +07:00
committed by GitHub
7 changed files with 149 additions and 20 deletions
+48 -16
View File
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Links",
[],
None,
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}",
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots",
has_doc,
check
),
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Links",
[],
None,
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
has_doc,
list
),
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Link Info",
[],
None,
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size",
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size; under automation read roots, a link whose file is outside the roots has no link, path or location, and fileSize and modified come only from the file",
has_doc,
info
),
@@ -882,17 +882,35 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
}
/// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in
/// the layers), with `refused`: why the path isn't followed, when it isn't.
/// the layers), with `refused`: why the path isn't followed, when it isn't. `path` is left out
/// where [`path_shown`] says so.
fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<Value> {
gs.filter(|g| !g.ids.is_empty())
.map(|g| {
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) });
let mut row = json!({ "name": g.link.name(), "ids": ids_json(&g.ids) });
note_path(&mut row, "path", &g.link.path, doc_path);
note_refused(&mut row, &g.link, doc_path);
row
})
.collect()
}
/// May a reply to the caller asking now carry the link path `path` (of the document saved at
/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a
/// relative path taken from the document's folder) lies inside them.
fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
}
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
if path_shown(path, doc_path) {
row[key] = json!(path);
} else if let Some(o) = row.as_object_mut() {
o.remove(key);
}
}
/// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]).
fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) {
if let Some(r) = refused(link, doc_path) {
@@ -1188,10 +1206,12 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
let (status, found) = probe(g, LinkBase::of(st));
missing += usize::from(status == Status::Missing);
modified += usize::from(status == Status::Modified);
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
note_refused(&mut row, &g.link, LinkBase::of(st).doc);
let doc_path = LinkBase::of(st).doc;
let mut row = json!({ "name": g.link.name(), "status": status.as_str(), "ids": ids_json(&g.ids) });
note_path(&mut row, "path", &g.link.path, doc_path);
note_refused(&mut row, &g.link, doc_path);
if let Some(f) = found.filter(|f| f.path != g.link.path) {
row["found"] = json!(f.path);
note_path(&mut row, "found", &f.path, doc_path);
}
if stale(&st.doc, g) {
row["preview"] = json!(true);
@@ -1663,14 +1683,15 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
o.insert("linked".into(), json!(link.is_some()));
o.insert("status".into(), json!(status));
}
let doc_path = LinkBase::of(st).doc;
if let Some(l) = link {
row["path"] = json!(l.path);
note_path(&mut row, "path", &l.path, doc_path);
if let Some(page) = l.page {
row["page"] = json!(page);
}
}
if let Some(f) = found {
row["found"] = json!(f);
note_path(&mut row, "found", f, doc_path);
}
if preview {
row["preview"] = json!(true);
@@ -1811,13 +1832,17 @@ fn placement_json(o: PlacementOptions) -> Value {
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
// Whether the path is shown is judged for the caller asking, before the opener's roots apply.
let shown = path_shown(&l.path, LinkBase::of(st).doc);
// A confined caller reads within its own roots; anyone else within what the caller that
// opened the document may read (never wider than the caller asking now).
let roots = crate::file_access::current().or_else(|| st.opened_by.clone());
crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, out));
crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, shown, out));
}
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
/// [`file_fields`]; without `shown`, the link's path (`path`, `link`) and folder (`location`)
/// and the size and date the document keeps of its file are left out.
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, shown: bool, out: &mut Value) {
let base = LinkBase::of(st);
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0);
let doc_path = base.doc;
@@ -1825,13 +1850,20 @@ fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Va
let stamp = path.as_deref().and_then(file_stamp);
out["status"] = json!(status.as_str());
note_refused(out, l, doc_path);
out["path"] = json!(l.path);
if shown {
out["path"] = json!(l.path);
out["location"] = json!(l.folder());
} else if let Some(o) = out.as_object_mut() {
for k in ["path", "link", "location"] {
o.remove(k);
}
}
out["fileName"] = json!(l.name());
out["location"] = json!(l.folder());
let kept = |v: Option<u64>| v.filter(|_| shown);
for (k, v) in [
("page", l.page.map(u64::from)),
("fileSize", stamp.map(|s| s.0).or(l.size)),
("modified", stamp.and_then(|s| s.1).or(l.modified)),
("fileSize", stamp.map(|s| s.0).or(kept(l.size))),
("modified", stamp.and_then(|s| s.1).or(kept(l.modified))),
("created", path.as_deref().and_then(file_created)),
] {
if let Some(v) = v {
+53
View File
@@ -296,6 +296,59 @@ fn link_info_stays_within_the_asking_callers_roots() {
assert_ne!(i["status"], "ok", "{i}");
}
/// A confined caller's `links.list`, `links.check` and `links.info` replies, and its
/// `document.open` reply, leave out the path and folder of a linked file outside its read roots
/// and keep the row with the file's name. A file found by name inside the roots shows its path.
#[test]
fn link_paths_outside_the_read_roots_are_left_out() {
let away = Folder::new("lrr-away");
let home = Folder::new("lrr-home");
let pic = away.file("photo.png");
let bytes = png(60, 30, RED);
write(&pic, &bytes);
let mut s = session();
let id = place(&mut s, &pic);
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
let ask = |s: &mut Session, cmd: &str, p: Value| crate::file_access::confine(Some(&roots), || run(s, cmd, p));
let hidden = |r: &Value| assert!(!r.to_string().contains("lrr-away"), "{r}");
let list = ask(&mut s, "links.list", json!({}));
hidden(&list);
let row = &list["links"][0];
assert_eq!((row["id"].as_u64(), row["name"].as_str(), row["status"].as_str()), (Some(id.0), Some("photo.png"), Some("missing")), "{list}");
let check = ask(&mut s, "links.check", json!({}));
hidden(&check);
assert_eq!((check["links"][0]["name"].as_str(), check["missing"].as_u64()), (Some("photo.png"), Some(1)), "{check}");
let info = ask(&mut s, "links.info", json!({"id": id.0}));
hidden(&info);
assert_eq!(
(info["fileName"].as_str(), info["status"].as_str(), info["linked"].as_bool()),
(Some("photo.png"), Some("missing"), Some(true)),
"{info}"
);
for k in ["path", "link", "location", "fileSize", "modified", "created"] {
assert!(info.get(k).is_none(), "{k}: {info}");
}
// Unconfined, the same replies carry the paths.
assert_eq!(run(&mut s, "links.list", json!({}))["links"][0]["path"], json!(pic));
let i = run(&mut s, "links.info", json!({"id": id.0}));
assert_eq!((&i["path"], &i["link"], i["fileSize"].as_u64()), (&json!(pic), &json!(pic), Some(bytes.len() as u64)), "{i}");
// Saved inside the roots next to a copy of the file: the copy is found by name and shown.
save(&mut s, &home.file("poster.vectorcraft"));
let copy = home.file("photo.png");
write(&copy, &bytes);
let check = ask(&mut s, "links.check", json!({}));
hidden(&check);
assert_eq!((check["links"][0]["status"].as_str(), &check["links"][0]["found"]), (Some("ok"), &json!(copy)), "{check}");
let list = ask(&mut s, "links.list", json!({}));
hidden(&list);
assert_eq!(list["links"][0]["found"], json!(copy), "{list}");
// Opened again without the copy: the missing link's row has no path.
std::fs::remove_file(&copy).unwrap();
let opened = ask(&mut Session::new(), "document.open", json!({"path": home.file("poster.vectorcraft")}));
hidden(&opened);
assert_eq!(opened["missingLinks"][0]["name"], "photo.png", "{opened}");
}
/// A 600×300 pt image at the centre of the artboard, its placement options set to `options`,
/// relinked to a 300×100 px file → the image's id and session.
fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) {
@@ -1060,6 +1060,30 @@ fn link_info_gives_the_artboards_size_not_pixels() {
);
}
/// A confined caller's Link Info of a placed document whose file is outside its read roots has no
/// `link`, `path` or `location`, and keeps the file's name.
#[test]
fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() {
let away = Folder::new("placed-lrr-away");
let home = Folder::new("placed-lrr-home");
let src = away.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let id = place(&mut s, &src);
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
let i = crate::file_access::confine(Some(&roots), || s.execute("links.info", &json!({"id": id.0}))).unwrap();
assert!(!i.to_string().contains("placed-lrr-away"), "{i}");
assert_eq!(
(i["fileName"].as_str(), i["document"].as_bool(), i["status"].as_str()),
(Some("logo.vectorcraft"), Some(true), Some("missing")),
"{i}"
);
let l = crate::file_access::confine(Some(&roots), || s.execute("links.list", &json!({}))).unwrap();
assert!(!l.to_string().contains("placed-lrr-away") && l["links"][0]["name"] == "logo.vectorcraft", "{l}");
let i = s.execute("links.info", &json!({"id": id.0})).unwrap();
assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}");
}
/// 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() {