From c35d5f5b347c360e0cc2a33f6ad0082df7f7ec00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:03:41 -0800 Subject: [PATCH] Read roots: links.findFiles and image.info follow the configured read roots With automation read roots in force, links.findFiles and text.findFontFiles replies, including later polls and stops of a search another client started, leave out the files found outside the caller's read roots, a sought link's saved path outside them and the folder searched when it is outside them. A failed search then gives its reason without the path. The rows keep their names, ids and status, and the counts stay. image.info gives link: null for a linked file outside the read roots, as links.info does. text.addFontFiles gives folder: null when the Fonts folder is outside the read roots and names the copies there by their file names; the fonts are still copied. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/findfiles/tests.rs | 12 ++- crates/engine/src/cmd/findfiles/walk.rs | 66 ++++++++++--- crates/engine/src/cmd/fontfiles.rs | 28 ++++-- crates/engine/src/cmd/linkfiles.rs | 23 +++-- crates/engine/src/cmd/links.rs | 4 +- crates/engine/src/cmd/place.rs | 6 +- crates/engine/src/tests/fontfiles.rs | 37 ++++++++ crates/engine/src/tests/linkfiles.rs | 112 ++++++++++++++++++++++- crates/engine/tests/font_folder.rs | 7 ++ docs/mcp.md | 8 ++ docs/target-app-parity.md | 1 + 11 files changed, 266 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs index ec3ce57e3a..bf51ebdac7 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() { assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}"); let p = search(&root, "missing", Limits::default(), 1, &[]); assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}"); + // Each failure has the same reason without the folder's path. + let briefs = [ + ("missing", "the folder can't be read"), + ("file.ttf", "the path isn't a folder"), + ("refused", "VectorCraft doesn't search other apps' or the system's folders"), + ("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"), + ]; + for (picked, why) in briefs { + assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}"); + } } #[test] @@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() { let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); if enforced { - assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}"); + assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}"); } else { assert_eq!(found(&root, &p), ["locked/y.ttf"]); } diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs index 8d35515717..ae05a04838 100644 --- a/crates/engine/src/cmd/findfiles/walk.rs +++ b/crates/engine/src/cmd/findfiles/walk.rs @@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024; /// How a search ends when every walker thread stopped on a bug. const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)"; +/// Why a search couldn't start, without the folder's path ([`Progress::failure`]). +const UNREADABLE: &str = "the folder can't be read"; +const NOT_A_FOLDER: &str = "the path isn't a folder"; +const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users"; +const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders"; +const UNLISTED: &str = "the folder can't be listed"; + /// What a search looks for. pub trait Visitor: Send + Sync { /// Whether the regular file named `name` is to be read: asked of the files a folder lists until @@ -77,8 +84,9 @@ pub enum End { /// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item /// keeps at most [`Limits::per_item`] files, without ending the search. Limit, - /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. - Failed(String), + /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why, + /// and the same without the folder's path. + Failed(String, &'static str), } /// Where a search stands ([`Search::progress`]). @@ -114,7 +122,7 @@ impl Progress { None => "searching", Some(End::Done | End::Found) => "done", Some(End::Stopped | End::Time | End::Limit) => "stopped", - Some(End::Failed(_)) => "failed", + Some(End::Failed(..)) => "failed", } } @@ -131,10 +139,38 @@ impl Progress { /// Why a failed search failed. pub fn error(&self) -> Option<&str> { match &self.end { - Some(End::Failed(e)) => Some(e), + Some(End::Failed(e, _)) => Some(e), _ => None, } } + + /// Why a failed search failed, without the folder's path. + pub fn failure(&self) -> Option<&'static str> { + match &self.end { + Some(End::Failed(_, why)) => Some(why), + _ => None, + } + } + + /// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]), + /// for a reply to it: the files found inside them. Returns the folder searched (`given` until a + /// walker checked it) and why the search failed, when that folder lies inside them too; + /// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in + /// force, everything is kept. + pub fn keep_readable(&mut self, given: &str) -> (Option, Option) { + let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() }; + if crate::file_access::current().is_none() { + return (Some(folder), self.error().map(str::to_string)); + } + // A path that isn't valid text is never shown: its lossy form names another file. + let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + self.hits.retain(|(path, _)| readable(path)); + if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) { + (Some(folder), self.error().map(str::to_string)) + } else { + (None, self.failure().map(str::to_string)) + } + } } /// Walker threads shared out among searches: all of them together run at most `cap` threads. @@ -306,7 +342,7 @@ pub(crate) fn start_in( return Err("the search couldn't start: try again in a moment".into()); } if w.alive == 0 && w.end.is_none() { - shared.finish(&mut w, End::Failed(INTERNAL.into())); + shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } } Ok(Search { shared }) @@ -401,13 +437,13 @@ impl Shared { fn prepare(&self, root: &Path) { let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system)); let path = root.display(); - let checked = match std::fs::canonicalize(root) { - Err(e) => Err(format!("{path}: {e}")), - Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")), + let (checked, brief) = match std::fs::canonicalize(root) { + Err(e) => (Err(format!("{path}: {e}")), UNREADABLE), + Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER), Ok(folder) => match rules.refusal(&folder) { - None => Ok(folder), - Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), - Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), + None => (Ok(folder), INTERNAL), + Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED), + Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS), }, }; let mut w = self.lock(); @@ -416,7 +452,7 @@ impl Shared { w.folder = folder.clone(); w.stack.push((folder, 0)); } - Err(e) => self.finish(&mut w, End::Failed(e)), + Err(e) => self.finish(&mut w, End::Failed(e, brief)), } } @@ -430,7 +466,7 @@ impl Shared { let mut w = self.lock(); // The picked folder itself: the search can't run, and says why. if depth == 0 { - self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()))); + self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED)); } else { w.unreadable += 1; w.folders += 1; @@ -610,7 +646,7 @@ fn walker(s: &Shared) { if done.is_err() { let mut w = s.lock(); match job { - Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())), + Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)), Job::List(..) => w.unreadable += 1, } } @@ -640,7 +676,7 @@ impl Drop for Alive { let mut w = self.0.lock(); w.alive = w.alive.saturating_sub(1); if w.alive == 0 && w.end.is_none() { - self.0.finish(&mut w, End::Failed(INTERNAL.into())); + self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } drop(w); self.0.pool.release(1); diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index 309347985c..0ccf1ab94a 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { "Find Font Files", [], None, - "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Add Font Files", [], None, - "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", + "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", always, add ), @@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result { /// Where the session's last search stands (`text.findFontFiles` without `folder`). fn state(s: &Session) -> Value { let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) }; - let p = f.search.progress(); + let mut p = f.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&f.folder); let mut files: Vec> = vec![vec![]; f.fonts.len()]; for (path, items) in &p.hits { for i in items { @@ -293,16 +295,18 @@ fn state(s: &Session) -> Value { json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files }) }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": f.id, "state": p.state(), "folder": folder, "fonts": fonts, + "id": f.id, "state": p.state(), "fonts": fonts, "searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out @@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result { // VectorCraft's own Fonts folder: no automation root applies to it. crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?; let report = copy_into(&files, dir); - let pairs = |v: &[(PathBuf, PathBuf)]| { - v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::>() + // The caller sees the Fonts folder and the copies in it only where its read roots reach. + let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + let shown = |p: &Path| -> String { + let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() }; + shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() }; + let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::>(); let mut out = json!({ - "folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept), - "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::>(), + "folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept), + "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::>(), }); if !report.copied.is_empty() || !report.kept.is_empty() { // The fonts are cataloged and type set in them redraws, as Refresh Font List does it. diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index 5b5a6aa48a..f2ce9d4d12 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Find Linked Files", [], None, - "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find )] @@ -52,6 +52,8 @@ pub(crate) struct LinkSearch { pub(crate) id: u64, /// The folder as given. pub(crate) folder: String, + /// The path of the document the links were taken from, which a relative link path starts from. + pub(crate) doc: Option, pub(crate) links: Vec, /// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]). pub(crate) sizes: Arc>>, @@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result { if links.is_empty() { return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" })); } + let doc = super::links::LinkBase::of(st).doc.map(str::to_string); // A new search stops the last one. s.link_search = None; let threads = s.search_threads.unwrap_or_else(findfiles::threads); @@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result { let visitor = Arc::new(LinkFiles::new(&links, sizes.clone())); let search = findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?; - s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search }); + s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search }); Ok(state(s)) } @@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize { /// Where the session's last search stands (`links.findFiles` without `folder`). fn state(s: &Session) -> Value { let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) }; - let p = l.search.progress(); + let mut p = l.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&l.folder); let n = l.links.len(); let sizes = lock(&l.sizes).clone(); // Per linked file: the files found, each with whether it is the same file. @@ -267,19 +272,23 @@ fn state(s: &Session) -> Value { f }) .collect(); - json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files }) + let mut row = json!({ "name": link.name, "ids": link.ids, "files": files }); + super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref()); + row }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": l.id, "state": p.state(), "folder": folder, "links": links, + "id": l.id, "state": p.state(), "links": links, "searched": { "folders": p.folders, "files": p.files, "read": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..89c0afefcf 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator, doc_path: Option<&str>) -> Vec< /// 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 { +pub(crate) 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>) { +pub(crate) 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() { diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a6e7f67286..5d56a2b646 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Image Info", [], None, - "{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", + "{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", has_doc, image_info ), @@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { let [a, b, c, d, ..] = im.xf.as_coeffs(); let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l); let size = n.geometric_bounds().unwrap_or_default(); + // A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info. + let doc_path = super::links::LinkBase::of(st).doc; Ok(json!({ "id": id.0, "name": n.display_name(), "linked": im.link.is_some(), - "link": im.link.as_ref().map(|l| &l.path), + "link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)), "colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)), "pixelWidth": im.width, "pixelHeight": im.height, diff --git a/crates/engine/src/tests/fontfiles.rs b/crates/engine/src/tests/fontfiles.rs index f31df35eb1..8bef8733b5 100644 --- a/crates/engine/src/tests/fontfiles.rs +++ b/crates/engine/src/tests/fontfiles.rs @@ -194,6 +194,43 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() { assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}"); } +/// A caller confined by read roots sees, in the replies to a search another caller started, only +/// the files found inside its read roots, and the folder searched and a failed search's path only +/// when they are inside them. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3"); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + for dir in [&home, &wide] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("Findme.otf"), &font).unwrap(); + } + let mut s = session_for(&root); + let roots = |read: &[&Path]| crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap(); + let (narrow, broad) = (roots(&[&home]), roots(&[&root])); + let ask = |s: &mut Session, r: &Arc, p: Value| { + crate::file_access::confine(Some(r), || s.execute("text.findFontFiles", &p).unwrap()) + }; + // A font no file has keeps the search going through every folder. + let fonts = json!([{"family": "Findme Sans 3"}, {"family": "No Such Sans"}]); + ask(&mut s, &broad, json!({"folder": root, "fonts": fonts})); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!((&all["fonts"][0]["files"], &all["folder"]), (&json!([home.join("Findme.otf"), wide.join("Findme.otf")]), &json!(root)), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + assert!(!r.to_string().contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + assert_eq!((&r["fonts"][0]["files"], &r["fonts"][0]["family"]), (&json!([home.join("Findme.otf")]), &json!("Findme Sans 3")), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": wide.join("gone"), "fonts": fonts})); + assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide"))); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}"); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn junk_search_params_never_fail_internally() { let root = folder("junk"); diff --git a/crates/engine/src/tests/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs index 695ea41419..ef9127042a 100644 --- a/crates/engine/src/tests/linkfiles.rs +++ b/crates/engine/src/tests/linkfiles.rs @@ -271,7 +271,7 @@ fn stop_ends_only_the_search_it_names() { let (tx, rx) = mpsc::channel(); let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap(); let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }]; - s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search }); + s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), doc: None, links, sizes: Arc::default(), search }); let until = Instant::now() + WAIT; while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until { std::thread::sleep(Duration::from_millis(2)); @@ -284,6 +284,116 @@ fn stop_ends_only_the_search_it_names() { drop(tx); } +/// A caller confined by read roots sees, in the replies to a search another caller started and to +/// its own, only the paths inside its read roots: the files found outside them, a link's saved path +/// outside them and the folder searched are left out, and the rows, ids and counts stay. Without +/// roots, the replies are as before. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let mut s = session_for(&root); + let photo = png(40, 30, RED); + write(&root.join("lrf-away/photo.png"), &photo); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::remove_file(root.join("lrf-away/photo.png")).unwrap(); + // Files of the name with other contents: the search reads every folder. + let other = png(40, 30, BLUE); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + write(&home.join("photo.png"), &other); + write(&wide.join("photo.png"), &other); + let roots = |read: &[&Path], write: &[&Path]| crate::file_access::AutomationRoots::new(read, write).unwrap().unwrap(); + let (narrow, write_only, broad) = (roots(&[&home], &[]), roots(&[], &[&root]), roots(&[&root], &[])); + let ask = |s: &mut Session, r: &Arc, p: Value| { + crate::file_access::confine(Some(r), || s.execute("links.findFiles", &p).unwrap()) + }; + let hidden = |r: &Value| { + let text = r.to_string(); + assert!(!text.contains("lrf-away") && !text.contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + let row = &r["links"][0]; + assert_eq!((row["name"].as_str(), &row["ids"], row.get("path")), (Some("photo.png"), &json!([id]), None), "{r}"); + }; + // Started with the broad roots, over the whole folder. + let r = ask(&mut s, &broad, json!({"folder": root})); + assert_eq!(r["folder"], json!(root), "{r}"); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!(all["links"][0]["files"].as_array().map(Vec::len), Some(2), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([{"path": home.join("photo.png"), "size": other.len(), "match": "name"}]), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + let r = ask(&mut s, &write_only, json!({})); + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([]), "{r}"); + // Without roots, every path shows. + let r = s.execute("links.findFiles", &json!({})).unwrap(); + assert_eq!((&r["folder"], &r["links"][0]["path"]), (&json!(root), &json!(root.join("lrf-away/photo.png"))), "{r}"); + assert_eq!(r["links"][0]["files"].as_array().map(Vec::len), Some(2), "{r}"); + // The narrow caller's own search shows the folder it gave. + let r = ask(&mut s, &narrow, json!({"folder": home})); + assert_eq!(r["folder"], json!(home), "{r}"); + finished(&mut s); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((&r["folder"], r["links"][0].get("path")), (&json!(home), None), "{r}"); + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": root.join("lrf-wide/gone")})); + let r = finished(&mut s); + assert!(r["error"].as_str().is_some_and(|e| e.contains("lrf-wide")), "{r}"); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}"); + hidden(&r); + let _ = std::fs::remove_dir_all(&root); +} + +/// A link's saved path relative to the document is shown to a confined caller when it leads to a +/// file inside its read roots, and left out when `..` leads out of them. +#[test] +fn a_relative_link_path_is_judged_from_the_documents_folder() { + let root = folder("read-roots-relative"); + let mut s = session_for(&root); + let home = root.join("home"); + write(&root.join("lrf-away/photo.png"), &png(40, 30, RED)); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::create_dir_all(&home).unwrap(); + s.execute("document.save", &json!({"path": home.join("poster.vectorcraft")})).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&home], &[]).unwrap().unwrap(); + for (saved, shown) in [("sub/photo.png", true), ("../lrf-away/photo.png", false)] { + s.edit("relative path", |d, _| { + if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind { + im.link.as_mut().unwrap().path = saved.into(); + } + Ok(()) + }) + .unwrap(); + let r = crate::file_access::confine(Some(&narrow), || s.execute("links.findFiles", &json!({"folder": home, "ids": [id]})).unwrap()); + assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}"); + finished(&mut s); + } + let _ = std::fs::remove_dir_all(&root); +} + +/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link` +/// null), as `links.info` leaves it out, and keeps the other fields. +#[test] +fn image_info_leaves_out_a_link_outside_the_read_roots() { + let root = folder("read-roots-info"); + let mut s = session_for(&root); + let pic = root.join("lrf-away/photo.png"); + write(&pic, &png(40, 30, RED)); + let id = placed(&mut s, &pic); + std::fs::create_dir_all(root.join("home")).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&root.join("home")], &[]).unwrap().unwrap(); + let ask = |s: &mut Session, cmd: &str| crate::file_access::confine(Some(&narrow), || s.execute(cmd, &json!({"id": id})).unwrap()); + let r = ask(&mut s, "image.info"); + assert_eq!((&r["link"], &r["linked"], &r["pixelWidth"], &r["id"]), (&Value::Null, &json!(true), &json!(40), &json!(id)), "{r}"); + assert!(!r.to_string().contains("lrf-away"), "{r}"); + let info = ask(&mut s, "links.info"); + assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}"); + assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic)); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn relinking_with_copy_puts_the_file_in_the_documents_folder() { let root = folder("copy"); diff --git a/crates/engine/tests/font_folder.rs b/crates/engine/tests/font_folder.rs index b58e79fc17..11d152fb4d 100644 --- a/crates/engine/tests/font_folder.rs +++ b/crates/engine/tests/font_folder.rs @@ -71,6 +71,13 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { assert!(st.revision > revision && !st.is_dirty()); assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0); + // A caller whose read roots hold the file but not the Fonts folder: the file is kept, and the + // reply names the copy by its file name only, without the folder. + let downloads = vectorcraft_engine::file_access::AutomationRoots::new(&[&root.join("Downloads")], &[]).unwrap(); + let r = vectorcraft_engine::file_access::confine(downloads.as_ref(), || s.execute("text.addFontFiles", &json!({"files": [file]}))).unwrap(); + assert_eq!((&r["folder"], &r["kept"]), (&Value::Null, &json!([{"from": file, "to": "Findme 2.otf"}])), "{r}"); + assert!(!r.to_string().contains("App Fonts"), "{r}"); + // The same file again is kept, not copied a third time. let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap(); assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}"); diff --git a/docs/mcp.md b/docs/mcp.md index 62b8052a2a..0a2b114487 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -201,6 +201,14 @@ the suite) keep the agent to the folders you give it: and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other fields. This applies to every document, also one the person or a client with wider roots opened. The paths a client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. +- `image.info` gives `link: null` for a linked file outside the read roots, as `links.info` leaves it out; its id, + name, `linked` and the other fields stay. `links.findFiles` and `text.findFontFiles` replies, including later + `{}` and `{stop: true}` calls on a search another client started, leave out the files found outside the read + roots (with their `size` and `match`), a sought link's `path` outside them, and `folder` when the folder searched + is outside them; `error` then gives the reason without the path (for example `the folder can't be read`). The + rows keep their `name`, `ids`, family, style and status, and the counts stay. `text.addFontFiles` gives + `folder: null` when VectorCraft's Fonts folder is outside the read roots, and only the file name in `copied` and + `kept` `to` and in `skipped` `file` for a path outside them; the fonts are still copied. - `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show a folder preference outside the read roots (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`) as `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2a1eb83381..405a4fee2d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -763,6 +763,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.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`). + - 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/` and keeps that id for the session, 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`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`.