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.
This commit is contained in:
dotCooCoo
2026-11-13 21:16:40 -08:00
parent 1e4e3534f5
commit 6b9e7e9fa6
2 changed files with 60 additions and 2 deletions
+30 -2
View File
@@ -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<String>) {
+30
View File
@@ -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]