From 6b9e7e9fa6390f1d75b53db0181f1ca61d627171 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:16:40 -0800 Subject: [PATCH] Automation: replies hide a file or missing folder on the way to the recovery folder When a recovery copy fails because a folder on the way to the recovery folder is a file or is missing, the error names that folder, not the recovery folder. Replies to a client confined to read roots hid only the recovery folder itself, so the full path of that ancestor stayed visible. A reply now also hides each ancestor of a recovery folder that it writes as a whole path. The check compares text only and does not look anything up on disk. Follows #7287. --- crates/ui-egui/src/control.rs | 32 +++++++++++++++++++++++++++-- crates/ui-egui/src/control/tests.rs | 30 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2779bc7f6d..26f67a0365 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1274,7 +1274,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques /// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that /// `sent`, the request's params, holds ([`handle`]), and the recovery folders outside the read root -/// that are in use or that a failure message named. The `path` and `files` entries of the reply's +/// that are in use or that a failure message named, with each folder on the way to one that the reply +/// writes as a whole path ([`writes_whole_path`]): a failure names a file or a missing folder there. +/// The `path` and `files` entries of the reply's /// `result`, at any depth, that name a file the request gave inside the write roots, the file it /// wrote, stay as given ([`written_paths`]). fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { @@ -1288,16 +1290,42 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: hiding.merge(before); let mut paths = Vec::new(); sent_paths(sent, &mut paths); - // The status text of a recovery copy that failed names the recovery folder it went to. + // The status text of a recovery copy that failed names the recovery folder it went to, or the + // first folder on the way to it that is a file or is missing. let current = vectorcraft_engine::cmd::recovery::folder(&app.session).map(|(f, _)| f); for folder in app.recovery.named.iter().chain(current.as_ref()) { collect_sent_paths(&json!(folder), true, &mut paths); + for on_the_way in std::path::Path::new(folder).ancestors().skip(1).map(|p| p.to_string_lossy()) { + if !on_the_way.is_empty() && writes_whole_path(reply, &on_the_way) { + collect_sent_paths(&json!(on_the_way), true, &mut paths); + } + } } hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); restore_written_paths(reply, kept); } +/// Does `v` write `path` in a string or a key at any depth with no path text before it (a letter, a +/// digit, `_`, `-`, `.`, `~` or a separator) or after it ([`continues_path`]), a Windows path +/// ([`splits_at_backslash`]) with either separator and in any case of ASCII letters too? Only the text +/// is compared: nothing on disk is looked up. +fn writes_whole_path(v: &Value, path: &str) -> bool { + let windows = splits_at_backslash(path); + let path_before = + |before: &str| before.chars().next_back().is_some_and(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')); + let whole = |s: &str| { + let spellings = if windows { vec![path.replace('\\', "/"), path.replace('/', "\\")] } else { vec![path.to_string()] }; + spellings.iter().any(|p| occurrences(s, p, windows).into_iter().any(|at| !path_before(&s[..at]) && !continues_path(&s[at + p.len()..]))) + }; + match v { + Value::String(s) => whole(s), + Value::Array(items) => items.iter().any(|item| writes_whole_path(item, path)), + Value::Object(fields) => fields.iter().any(|(key, item)| whole(key) || writes_whole_path(item, path)), + _ => false, + } +} + /// The strings that `v`'s path params hold at any depth ([`vectorcraft_engine::file_access::is_path_key`]) /// that name a file inside the write roots; `v` is a path param's value when `path_param` is set. fn written_paths(v: &Value, path_param: bool, out: &mut Vec) { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index b60b1adfdc..d914c3624d 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -474,6 +474,36 @@ fn a_recovery_folder_outside_the_read_root_is_hidden_in_the_status() { assert_eq!(inspect(&mut app, format!("Copies go to {inside}")), json!(format!("Copies go to {inside}"))); } +/// `ui.inspect` shows a file on the way to a recovery folder outside the read root, which a failed +/// recovery copy names, by its name, and a missing folder on the way too. A path that only begins or +/// only ends with a folder on the way stays. +#[test] +fn a_file_on_the_way_to_a_recovery_folder_outside_the_read_root_is_hidden_in_the_status() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let outside = crate::tests_folder::Folder::new("recovery-in-the-way"); + let file = outside.file("private", b"a file"); + let folder = std::path::Path::new(&file).join("recovery").to_string_lossy().into_owned(); + app.session.recovery.set_default_folder(Some(folder)); + let root = crate::tests_folder::Folder::new("recovery-in-the-way-root"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); + let inspect = |app: &mut VectorcraftApp, status: String| { + app.ui.status = status; + let reply = vectorcraft_engine::file_access::confine(roots.as_ref(), || input_request(app, "ui.inspect", json!({}))); + reply["result"]["ui"]["status"].clone() + }; + let status = format!("Couldn't save recovery data: {file} is not a folder"); + assert_eq!(inspect(&mut app, status), "Couldn't save recovery data: private is not a folder"); + let missing = outside.join("gone"); + let folder = missing.join("recovery").to_string_lossy().into_owned(); + app.session.recovery.set_default_folder(Some(folder)); + let status = format!("Couldn't save recovery data: couldn't make {}", missing.display()); + assert_eq!(inspect(&mut app, status), "Couldn't save recovery data: couldn't make gone"); + let under = format!("Copies go near {}", outside.join("other.svg").display()); + assert_eq!(inspect(&mut app, under.clone()), json!(under)); + let longer = format!("Copies go near /work{}", missing.display()); + assert_eq!(inspect(&mut app, longer.clone()), json!(longer)); +} + /// A path outside the read root that a request sent is shown by its file name in a reply that succeeded /// as in one that failed. #[test]