diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 3974a574b..bcc4bcb17 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -647,7 +647,11 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { // A folder the client gave, as against the one its `exportPreset` keeps. let given = p.get("folder").is_some(); let p = with_preset(s, p)?; - let mut r = export(s, &p)?; + let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) }; + let mut r = export(s, &p).map_err(|e| match hidden { + Some(folder) => leave_out_folder_from_error(e, folder), + None => e, + })?; remember(s, &p); if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); @@ -655,6 +659,41 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// An error of an export into `folder`, a folder outside the read roots that the client did not +/// give: `folder` as the path the error is about names it by its own name +/// ([`super::access::shown_path`]), and the rest of the message stays. +fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { + let name = super::access::shown_path(folder); + match e { + EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, + e => e, + } +} + +/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start +/// of `msg` (`: `), after a `: ` (`... outside the write root : `) or +/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message +/// names stays whole, even one inside `folder`. +fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String { + const BEFORE: [&str; 2] = [": ", "cannot resolve "]; + let mut out = String::with_capacity(msg.len()); + let mut rest = msg; + loop { + if let Some(after) = rest.strip_prefix(folder) + && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) + { + out.push_str(name); + rest = after; + } + let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; + out.push_str(&rest[..i + len]); + rest = &rest[i + len..]; + } + out.push_str(rest); + out +} + /// The reply `r` of an export into `folder`, a folder the client did not give, as the request being /// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), @@ -1436,7 +1475,26 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { #[cfg(test)] mod tests { - use super::{Output, ScreenSize, safe_file_name}; + use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name}; + + /// A hidden export folder is named by its name where a message is about it, and a root that + /// starts with the same text, or a longer path, stays whole. + #[test] + fn a_hidden_folder_is_named_by_its_name_in_a_message() { + let f = "/work/out"; + let cases = [ + ( + "automation path rejected: outside the write root /work/out/in: /work/out", + "automation path rejected: outside the write root /work/out/in: out", + ), + ("automation path rejected: cannot resolve /work/out: denied", "automation path rejected: cannot resolve out: denied"), + ("/work/out/2x/a.png: disk full", "out/2x/a.png: disk full"), + ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"), + ]; + for (msg, want) in cases { + assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}"); + } + } /// Files given one name are numbered in turn, each numbering going on from the last one (so /// thousands of them don't each try every number used before), and a file asking again for diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index b3e064224..86ee16499 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -700,6 +700,41 @@ fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() { assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); } +/// An export into the folder an export preset keeps, refused because that folder is outside the +/// client's read and write roots: the error names the folder by its name only. A folder the client +/// gives stays whole in the error. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_name() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("preset-refused"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let mut run = |p: Value| confine(confined.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap_err().to_string(); + + let e = run(json!({"exportPreset": "Kept"})); + assert!(e.contains("outside the write root") && e.contains("kept-out") && !e.contains(&folder), "{e}"); + let e = run(json!({"exportPreset": "Kept", "folder": folder})); + assert!(e.contains(&folder), "{e}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // A preset folder that holds the write root: the root stays whole in the error. + let parent = tmp.0.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": parent}); + s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap(); + let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string(); + let root = inside.display().to_string(); + let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned(); + assert!(e.contains(&format!("root {root}: {name}")), "{e}"); +} + /// The export settings the document keeps and the saved presets, read by a client whose read roots /// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and /// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. diff --git a/docs/mcp.md b/docs/mcp.md index 5f02c87f2..52fd2247c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,7 +128,8 @@ the suite) keep the agent to the folders you give it: a `path` write the document's own file: the reply then has no `path`, and `files` and `linked` name the files written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` - of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder. + of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error + for that export names the folder by its name alone. `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as a file) leave out a kept `folder` outside the read roots. `file.package` names the document's file and the linked files outside the read roots by their file names in `warnings`, in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 56b960ded..680469b7f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -749,7 +749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - - 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 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`. + - 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`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_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`.