mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 23:05:05 +00:00
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.
This commit is contained in:
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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<String>, Option<String>) {
|
||||
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);
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<Value> {
|
||||
/// 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<String>> = 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<Value> {
|
||||
// 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::<Vec<_>>()
|
||||
// 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::<Vec<_>>();
|
||||
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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
});
|
||||
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.
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<String>,
|
||||
pub(crate) links: Vec<Sought>,
|
||||
/// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]).
|
||||
pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>,
|
||||
@@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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
|
||||
|
||||
@@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator<Item = &'a Group>, 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() {
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
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,
|
||||
|
||||
@@ -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<crate::file_access::AutomationRoots>, 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");
|
||||
|
||||
@@ -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<crate::file_access::AutomationRoots>, 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");
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user