mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fix #832: --automation-read-root and --automation-write-root confine the MCP server's files
vectorcraft-cli mcp and vectorcraft --control take the flags PhotoCraft takes. While roots are in force, every path a command reads (open, place, relink, SVG image links, library and profile loads, plug-ins, preset imports, font searches) must lie inside the read root and every path it writes (save, export, package, Export for Screens and for Web, print to file, captures, library saves) inside the write root. Paths are made absolute, links are followed (a missing file's nearest folder's), and the result is compared by whole components, ignoring case on Windows; `..` below a missing folder, dangling links and device names are refused. A root left out grants none of its access; without the flags nothing changes. The checks live in one place, vectorcraft_engine::file_access, consulted by the engine's file helpers and the app's file services. The headless server confines its whole process; the app confines control requests, the frames carrying input they inject and the background saves they start, leaving the person at the keyboard and the app's own folders alone. The screenshot tool now has the backend write its file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ab69087d68
commit
9f875050ef
32 files changed
+1353
-136
No files matched your search
@@ -186,6 +186,12 @@ Register the MCP server with Claude Code:
|
||||
claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp
|
||||
```
|
||||
|
||||
To keep an agent to one project's files, give it folders to read and write (the same flags as PhotoCraft):
|
||||
|
||||
```sh
|
||||
claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project
|
||||
```
|
||||
|
||||
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
|
||||
|
||||
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -43,7 +43,7 @@ since 2026-10-01.
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
@@ -97,7 +97,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
|
||||
- Actions panel that records and plays back commands.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `vectorcraft-cli`: VectorCraft from the command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy]
|
||||
@@ -14,6 +14,7 @@
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// `println!` / `print!` that end the program quietly when stdout is closed
|
||||
@@ -50,6 +51,7 @@ mod prefs_dir;
|
||||
mod system_fonts;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
||||
|
||||
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
|
||||
@@ -66,9 +68,13 @@ const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
USAGE:
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless]
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
--automation-read-root / --automation-write-root confine the files the agent's commands read
|
||||
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
|
||||
DIR, links followed; a root left out grants none of its access. They imply --headless: a
|
||||
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
@@ -145,18 +151,42 @@ fn main() -> ExitCode {
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
|
||||
let (flag, inline) = match a.split_once('=') {
|
||||
Some((f, v)) if f.starts_with("--automation-") => (f, Some(v.to_string())),
|
||||
_ => (a.as_str(), None),
|
||||
};
|
||||
match flag {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),
|
||||
"--headless" => headless = true,
|
||||
"--automation-read-root" | "--automation-write-root" => {
|
||||
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };
|
||||
if slot.replace(dir).is_some() {
|
||||
return Err(format!("{flag} is given twice: it takes one folder"));
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
}
|
||||
if headless && connect.is_some() {
|
||||
return Err("use either --connect or --headless".into());
|
||||
}
|
||||
let backend: Box<dyn Backend> = if headless {
|
||||
let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
|
||||
.into());
|
||||
}
|
||||
let backend: Box<dyn Backend> = if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless {
|
||||
Box::new(Headless::with_document())
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
|
||||
@@ -83,6 +83,58 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the
|
||||
/// server starts, and the process then reads and writes only inside its roots.
|
||||
#[test]
|
||||
fn mcp_automation_roots() {
|
||||
let base = tmp("roots");
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
|
||||
std::fs::write(base.join("in/a.svg"), svg).unwrap();
|
||||
std::fs::write(base.join("outside/b.svg"), svg).unwrap();
|
||||
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
|
||||
let fails = |args: &[&str], why: &str| {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
|
||||
};
|
||||
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
|
||||
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice");
|
||||
fails(&["mcp", "--automation-write-root"], "needs a folder");
|
||||
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
|
||||
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
|
||||
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
for m in [
|
||||
call(1, "open_file", json!({"path": file("in", "a.svg")})),
|
||||
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
|
||||
call(3, "export", json!({"path": file("out", "a.png")})),
|
||||
call(4, "export", json!({"path": file("in", "a.png")})),
|
||||
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
|
||||
] {
|
||||
writeln!(stdin, "{m}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
|
||||
assert_eq!(errors, [false, true, false, true, true], "{replies:?}");
|
||||
assert!(replies[1]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
|
||||
assert!(base.join("out/a.png").is_file() && !base.join("in/a.png").exists() && !base.join("outside/a.vectorcraft").exists());
|
||||
}
|
||||
|
||||
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
|
||||
/// tests drive `Server::handle_line` directly, so the binary's own wiring (argument
|
||||
/// parsing, logger install, stdout pollution, notification order) is untested: this
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
//! VectorCraft desktop app.
|
||||
//!
|
||||
//! Usage: `vectorcraft [--control <port>] [--in-window-menus] [files…]`
|
||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
|
||||
//! [--in-window-menus] [files…]`
|
||||
//!
|
||||
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||
//! macOS instead of the macOS menu bar (`mac_menu`).
|
||||
//!
|
||||
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `vectorcraft_ui_egui::control` for the methods.
|
||||
//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and
|
||||
//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the
|
||||
//! files its requests read and write (`vectorcraft_engine::file_access`); the person at the
|
||||
//! keyboard isn't confined.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
|
||||
@@ -36,6 +40,7 @@ mod window;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::picks::PickRequest;
|
||||
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
||||
@@ -51,7 +56,39 @@ struct App {
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Each frame runs unconfined: it is the person at the keyboard's. The app confines what the
|
||||
// control channel asks for, and the frames carrying input it injected, to the automation roots
|
||||
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
file_access::unconfined(|| self.logic_frame(ctx, frame));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.services.native_menu.is_some() {
|
||||
mac_menu::raw_input_hook(raw);
|
||||
}
|
||||
file_access::unconfined(|| self.app.raw_input_hook(raw));
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
file_access::unconfined(|| save_prefs(&self.app));
|
||||
end_before_teardown(self.frames);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
file_access::unconfined(|| save_prefs(&self.app));
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.frames = ctx.cumulative_frame_nr();
|
||||
if let Some(why) = self.graphics_loss.take() {
|
||||
// eframe can't give a window a new device: the user saves and starts again.
|
||||
@@ -76,29 +113,6 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.services.native_menu.is_some() {
|
||||
mac_menu::raw_input_hook(raw);
|
||||
}
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.app);
|
||||
end_before_teardown(self.frames);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
save_prefs(&self.app);
|
||||
}
|
||||
}
|
||||
|
||||
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
|
||||
@@ -389,12 +403,19 @@ fn main() -> std::process::ExitCode {
|
||||
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
|
||||
prefs_dir::install_fonts();
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut read_root = root_env("VECTORCRAFT_AUTOMATION_READ_ROOT");
|
||||
let mut write_root = root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT");
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--automation-read-root" => read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => write_root = args.next().map(std::path::PathBuf::from),
|
||||
_ if a.starts_with("--automation-read-root=") => read_root = a.split_once('=').map(|(_, d)| d.into()),
|
||||
_ if a.starts_with("--automation-write-root=") => write_root = a.split_once('=').map(|(_, d)| d.into()),
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
@@ -417,6 +438,23 @@ fn main() -> std::process::ExitCode {
|
||||
None => logger.no_file(),
|
||||
}
|
||||
}
|
||||
// The roots confine the control channel: a bad one keeps the app from starting rather than
|
||||
// leaving agents unconfined. Without a control port there is nothing to confine.
|
||||
let roots = if control_port.is_none() {
|
||||
if read_root.is_some() || write_root.is_some() {
|
||||
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
|
||||
}
|
||||
None
|
||||
} else {
|
||||
match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) {
|
||||
Ok(roots) => roots,
|
||||
Err(e) => {
|
||||
log::error!("{e}");
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
};
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
#[cfg(feature = "wgpu")]
|
||||
@@ -532,7 +570,13 @@ fn main() -> std::process::ExitCode {
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
app = app.with_control(rx).with_automation_roots(roots.clone());
|
||||
if let Some(roots) = &roots {
|
||||
// Everywhere the app doesn't say whose work it is (worker threads), the
|
||||
// roots hold: unconfined is only what the person at the keyboard does.
|
||||
file_access::confine_process(roots.clone());
|
||||
log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root());
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -544,7 +588,7 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
open_files(&mut app, files);
|
||||
file_access::unconfined(|| open_files(&mut app, files));
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -181,6 +181,7 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.loadProfile";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
|
||||
Ok(json!({"name": info.name, "kind": info.kind}))
|
||||
}
|
||||
|
||||
@@ -708,20 +708,39 @@ fn formats(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
// ---------- the file system (none on the web, where commands take and return bytes) ----------
|
||||
//
|
||||
// Every path a command reads or writes goes through these, which check it against the automation
|
||||
// roots in force ([`crate::file_access`]): a path outside them is an error (`file_stamp` and
|
||||
// `file_created`: no file), as is any path once roots are in force without its kind of access.
|
||||
|
||||
/// `path` checked for reading against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn may_read(path: &str) -> Result<()> {
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn may_write(path: &str) -> Result<()> {
|
||||
crate::file_access::check_write(path).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
may_read(path)?;
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
may_write(path)?;
|
||||
// Never a half-written file: see [`write_atomic`].
|
||||
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
may_write(path)?;
|
||||
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
@@ -730,6 +749,7 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write as _;
|
||||
may_write(path)?;
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
|
||||
let written = f.write_all(bytes).and_then(|()| f.sync_all());
|
||||
@@ -742,17 +762,20 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
|
||||
/// keeps one); `None` when there is no file at `path`.
|
||||
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
may_read(path).ok()?;
|
||||
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
|
||||
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
Some((m.len(), modified))
|
||||
}
|
||||
|
||||
/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it.
|
||||
/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it
|
||||
/// (and automation may read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_created(path: &str) -> Option<u64> {
|
||||
may_read(path).ok()?;
|
||||
let t = std::fs::metadata(path).ok()?.created().ok()?;
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
@@ -257,6 +257,8 @@ pub(crate) fn start_in(
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err("searching a folder isn't available on the web".into());
|
||||
}
|
||||
// The walkers follow no links, so a folder automation may read holds all they read.
|
||||
crate::file_access::check_read(&folder.to_string_lossy())?;
|
||||
let seconds = if limits.seconds.is_finite() { limits.seconds.clamp(0.0, 3600.0) } else { 60.0 };
|
||||
let n = pool.reserve(threads.clamp(1, MAX_THREADS));
|
||||
if n == 0 {
|
||||
|
||||
@@ -345,7 +345,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = files.iter().find(|f| !found.contains(f)) {
|
||||
return Err(bad(C, format!("`{}` is not a font file the last text.findFontFiles search found", f.display())));
|
||||
}
|
||||
super::fileio::create_dir(&dir.to_string_lossy())?;
|
||||
for f in &files {
|
||||
crate::file_access::check_read(&f.to_string_lossy()).map_err(EngineError::Other)?;
|
||||
}
|
||||
// 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<_>>()
|
||||
|
||||
@@ -74,17 +74,18 @@ impl Libraries {
|
||||
self.dir.as_deref()
|
||||
}
|
||||
|
||||
/// Set the library folder and read its libraries (unreadable files are skipped).
|
||||
/// Set the library folder and read its libraries (unreadable files are skipped). The folder
|
||||
/// is the app's own: automation roots don't apply to it ([`crate::file_access`]).
|
||||
pub fn set_dir(&mut self, dir: Option<String>) {
|
||||
self.dir = dir;
|
||||
self.libs.clear();
|
||||
if let Some(dir) = self.dir.clone() {
|
||||
for path in library_paths(&dir) {
|
||||
let id = stem_of(&path);
|
||||
let lib = super::fileio::file_stamp(&path)
|
||||
.filter(|(len, _)| *len <= MAX_FILE)
|
||||
.and_then(|_| super::fileio::read_file(&path).ok())
|
||||
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
|
||||
let lib = crate::file_access::unconfined(|| {
|
||||
super::fileio::file_stamp(&path).filter(|(len, _)| *len <= MAX_FILE).and_then(|_| super::fileio::read_file(&path).ok())
|
||||
})
|
||||
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
|
||||
if let Some(mut lib) = lib {
|
||||
lib.cap();
|
||||
self.libs.push((id, lib));
|
||||
@@ -149,14 +150,17 @@ impl Libraries {
|
||||
Some(std::path::Path::new(dir).join(format!("{id}.{LIBRARY_EXT}")).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Write library `id` to its file (nothing to do without a folder).
|
||||
/// Write library `id` to its file (nothing to do without a folder). The folder is the app's
|
||||
/// own and ids are safe file names: automation roots don't apply ([`crate::file_access`]).
|
||||
fn save(&self, id: &str) -> Result<()> {
|
||||
let (Some(path), Some(lib)) = (self.path(id), self.get(id)) else { return Ok(()) };
|
||||
if let Some(dir) = self.dir.as_deref() {
|
||||
super::fileio::create_dir(dir)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec(lib).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
super::fileio::write_file(&path, &bytes)
|
||||
crate::file_access::unconfined(|| {
|
||||
if let Some(dir) = self.dir.as_deref() {
|
||||
super::fileio::create_dir(dir)?;
|
||||
}
|
||||
super::fileio::write_file(&path, &bytes)
|
||||
})
|
||||
}
|
||||
|
||||
/// Change library `id` with `f` and save it; on a failed save the change is undone.
|
||||
|
||||
@@ -110,6 +110,7 @@ fn install(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_path(path: &str) -> Result<Plugin> {
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
|
||||
}
|
||||
|
||||
@@ -137,7 +138,11 @@ pub(crate) fn sync_prefs(s: &Session) {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
match registry::load_folder(std::path::Path::new(folder)) {
|
||||
if let Err(e) = crate::file_access::check_read(folder) {
|
||||
return json!({"folder": folder, "error": e});
|
||||
}
|
||||
// A file in it may be a link to one elsewhere.
|
||||
match registry::load_folder_where(std::path::Path::new(folder), |f| crate::file_access::check_read(&f.to_string_lossy())) {
|
||||
Ok(r) => {
|
||||
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
@@ -449,6 +449,38 @@ impl Prefs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The folders the app reads (fonts, plug-ins) or writes (Data Recovery copies, the Templates
|
||||
/// folder it makes for its dialogs) on its own, with the access each needs.
|
||||
fn folders_mut(&mut self) -> [(&mut String, crate::file_access::Access); 4] {
|
||||
use crate::file_access::Access;
|
||||
[
|
||||
(&mut self.fonts_folder, Access::Read),
|
||||
(&mut self.plugins_folder, Access::Read),
|
||||
(&mut self.recovery_folder, Access::Write),
|
||||
(&mut self.templates_folder, Access::Write),
|
||||
]
|
||||
}
|
||||
|
||||
/// Put back the folders `next` sets in place of these preferences' that the automation roots
|
||||
/// in force refuse: automation may only point them inside its roots ([`crate::file_access`]).
|
||||
/// → why each was refused. Nothing is refused without roots, nor a folder cleared or kept.
|
||||
fn keep_folders(&self, next: &mut Prefs) -> Vec<String> {
|
||||
let Some(roots) = crate::file_access::current() else { return vec![] };
|
||||
let mut before = self.clone();
|
||||
let mut refused = vec![];
|
||||
for ((old, _), (new, access)) in before.folders_mut().into_iter().zip(next.folders_mut()) {
|
||||
let folder = new.trim();
|
||||
if folder.is_empty() || folder == old.trim() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = roots.check(folder, access) {
|
||||
refused.push(e);
|
||||
*new = old.clone();
|
||||
}
|
||||
}
|
||||
refused
|
||||
}
|
||||
|
||||
/// Values of one category (`None` = all) reset to defaults.
|
||||
pub fn reset(&mut self, category: Option<&str>) {
|
||||
let d = Prefs::default().to_json();
|
||||
@@ -470,7 +502,12 @@ impl Prefs {
|
||||
impl Session {
|
||||
/// Replace the preferences and push the ones with engine-side consumers into open documents
|
||||
/// and the renderer (grid, history depth, render threads).
|
||||
pub fn apply_prefs(&mut self, p: Prefs) {
|
||||
pub fn apply_prefs(&mut self, mut p: Prefs) {
|
||||
// However they are set (a dialog driven by automation too), the folders stay inside the
|
||||
// automation roots in force: a refused one keeps its value.
|
||||
for e in self.prefs.keep_folders(&mut p) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
|
||||
let history_changed = p.history_states != self.prefs.history_states;
|
||||
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
|
||||
@@ -554,6 +591,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut next = s.prefs.clone();
|
||||
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
|
||||
if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
s.apply_prefs(next);
|
||||
// Units ▸ General is also the open document's units (as Document Setup sets them).
|
||||
if values.contains_key("unitsGeneral") && s.active().is_some() {
|
||||
|
||||
@@ -151,13 +151,14 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.rescan();
|
||||
}
|
||||
|
||||
/// Re-read the library files of the user library folder (unreadable ones are skipped).
|
||||
/// Re-read the library files of the user library folder (unreadable ones are skipped). The
|
||||
/// folder is the app's own: automation roots don't apply to it ([`crate::file_access`]).
|
||||
pub fn rescan(&mut self) {
|
||||
self.extra.retain(|e| e.info.category != "user");
|
||||
let Some(dir) = self.user_dir.clone() else { return };
|
||||
for path in library_files(&dir, L::EXTS) {
|
||||
let file = file_name(&path);
|
||||
let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue };
|
||||
let Some(lib) = crate::file_access::unconfined(|| read_file(&path)).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue };
|
||||
let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" };
|
||||
self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) });
|
||||
}
|
||||
@@ -183,9 +184,10 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
/// The User Defined library whose file has the extension of file name `file` and holds `bytes`.
|
||||
fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra<L>> {
|
||||
let ext = extension(file);
|
||||
self.extra
|
||||
.iter()
|
||||
.find(|e| e.info.category == "user" && e.path.as_deref().is_some_and(|p| extension(p) == ext && read_file(p).is_ok_and(|b| b == bytes)))
|
||||
self.extra.iter().find(|e| {
|
||||
e.info.category == "user"
|
||||
&& e.path.as_deref().is_some_and(|p| extension(p) == ext && crate::file_access::unconfined(|| read_file(p)).is_ok_and(|b| b == bytes))
|
||||
})
|
||||
}
|
||||
|
||||
/// Write library file `data` as the save commands do: into the user library folder as
|
||||
@@ -203,8 +205,8 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
.to_string();
|
||||
let file = library_file_name(name, ext);
|
||||
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
|
||||
create_dir(&dir)?;
|
||||
write_file(&path, bytes)?;
|
||||
// The app's own folder, under a name made safe: no root applies.
|
||||
crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_file(&path, bytes)))?;
|
||||
self.rescan();
|
||||
out["path"] = json!(path);
|
||||
out["library"] = json!(format!("user/{file}"));
|
||||
@@ -277,11 +279,11 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
let (user_id, written) = match self.user_file(&file, &bytes).map(|e| e.info.id.clone()) {
|
||||
Some(same) => (same, None),
|
||||
None => {
|
||||
create_dir(&dir)?;
|
||||
let mut taken: HashSet<String> = library_files(&dir, L::EXTS).iter().map(|p| file_name(p).to_lowercase()).collect();
|
||||
let file = free_name(&file, &mut taken);
|
||||
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
|
||||
write_new_file(&path, &bytes)?;
|
||||
// The app's own folder, under a name made safe: no root applies.
|
||||
crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_new_file(&path, &bytes)))?;
|
||||
self.rescan();
|
||||
(format!("user/{file}"), Some(path))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
//! The folders automation may read and write (#832).
|
||||
//!
|
||||
//! `vectorcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`, and
|
||||
//! `vectorcraft --control <port>` with the same flags, confine the files an agent's commands
|
||||
//! reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
|
||||
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
||||
//! [`confine_process`] (`vectorcraft-cli mcp`, the desktop app with a control port), except inside
|
||||
//! [`unconfined`]: what the person at the keyboard does, and the app's own folders (preferences,
|
||||
//! Data Recovery, the library folders, VectorCraft's Fonts folder), which no agent names. Without
|
||||
//! roots nothing is checked.
|
||||
//!
|
||||
//! A path is checked, then opened: a process that can change the folders in between (swapping in
|
||||
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
|
||||
//! guard against other programs on the computer.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Read access or write access.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Root {
|
||||
/// The folder made absolute, links followed: what paths are compared with.
|
||||
resolved: PathBuf,
|
||||
/// The same without Windows' `\\?\` prefix, for people.
|
||||
shown: PathBuf,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The folder at `path`, which must exist.
|
||||
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
||||
let kind = match access {
|
||||
Access::Read => "read",
|
||||
Access::Write => "write",
|
||||
};
|
||||
let resolved = std::fs::canonicalize(path)
|
||||
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
||||
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
||||
Ok(Self { shown: crate::cmd::findfiles::plain(resolved.clone()), resolved })
|
||||
}
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
};
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
|
||||
#[derive(Clone, Debug)]
|
||||
enum Scope {
|
||||
Confined(Arc<AutomationRoots>),
|
||||
Unconfined,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
|
||||
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
|
||||
|
||||
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
|
||||
/// (they stay as they were).
|
||||
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
|
||||
PROCESS.set(roots).is_ok()
|
||||
}
|
||||
|
||||
/// Puts the scope it replaced back when dropped (also when `f` panics).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
SCOPE.with(|s| *s.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
|
||||
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
|
||||
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
|
||||
match roots {
|
||||
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
|
||||
None => f(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
|
||||
/// app's own folders.
|
||||
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
|
||||
with_scope(Some(Scope::Unconfined), f)
|
||||
}
|
||||
|
||||
/// This thread's scope, to carry to work it hands to another thread ([`ScopeHandle::run`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScopeHandle(Option<Scope>);
|
||||
|
||||
impl ScopeHandle {
|
||||
/// The scope of the calling thread.
|
||||
pub fn capture() -> Self {
|
||||
Self(SCOPE.with(|s| s.borrow().clone()))
|
||||
}
|
||||
|
||||
/// Run `f` in the captured scope.
|
||||
pub fn run<T>(self, f: impl FnOnce() -> T) -> T {
|
||||
with_scope(self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// The roots in force on this thread, if any.
|
||||
pub fn current() -> Option<Arc<AutomationRoots>> {
|
||||
match SCOPE.with(|s| s.borrow().clone()) {
|
||||
Some(Scope::Confined(r)) => Some(r),
|
||||
Some(Scope::Unconfined) => None,
|
||||
None => PROCESS.get().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// May `path` be read here? Always, without roots in force.
|
||||
pub fn check_read(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Read))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force.
|
||||
pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
found.extend(missing.iter().rev());
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
match (at.components().next_back(), parent) {
|
||||
(Some(Component::Normal(name)), Some(parent)) => {
|
||||
missing.push(name.to_os_string());
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
||||
/// Windows, whose file systems ignore it)?
|
||||
fn inside(path: &Path, root: &Path) -> bool {
|
||||
let mut path = path.components();
|
||||
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
||||
}
|
||||
|
||||
fn same_component(a: Component, b: Component) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
||||
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
|
||||
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
||||
let base = std::env::temp_dir().join(format!("vc-file-access-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in/sub", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.svg"), "<svg/>").unwrap();
|
||||
std::fs::write(base.join("outside/secret.svg"), "<svg/>").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
fn s(p: PathBuf) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_inside_the_roots_pass_and_others_dont() {
|
||||
let (base, roots) = folders("inside");
|
||||
assert_eq!(roots.check(&s(base.join("in/a.svg")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
|
||||
assert_eq!(roots.check(&s(base.join("in/sub/new.svg")), Access::Read), Ok(()), "missing files are checked by their folder");
|
||||
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
|
||||
let e = roots.check(&s(base.join("outside/secret.svg")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
|
||||
// Read and write access are separate.
|
||||
let e = roots.check(&s(base.join("in/a.svg")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
std::fs::write(base.join("in2/b.svg"), "<svg/>").unwrap();
|
||||
assert!(roots.check(&s(base.join("in2/b.svg")), Access::Read).is_err(), "in2 is not inside in");
|
||||
assert!(roots.check(&format!("{}2/c.svg", s(base.join("in"))), Access::Read).is_err());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_traversal_is_resolved_or_refused() {
|
||||
let (base, roots) = folders("dotdot");
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let up = format!("{}{sep}..{sep}outside{sep}secret.svg", s(base.join("in")));
|
||||
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
|
||||
let back = format!("{}{sep}..{sep}a.svg", s(base.join("in/sub")));
|
||||
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
|
||||
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.svg", s(base.join("out")));
|
||||
let e = roots.check(&missing, Access::Write).unwrap_err();
|
||||
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
|
||||
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_against_the_working_directory() {
|
||||
let (base, roots) = folders("relative");
|
||||
// The tests' working directory is the crate's folder: not inside the roots.
|
||||
assert!(roots.check("a.svg", Access::Read).unwrap_err().contains("outside the read root"));
|
||||
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.svg"))).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let e = read_only.check(&s(base.join("in/x.svg")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_followed_before_comparing() {
|
||||
let (base, roots) = folders("links");
|
||||
#[cfg(unix)]
|
||||
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let made = |target: &Path, link: &Path| {
|
||||
let made =
|
||||
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
|
||||
made.is_ok()
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let made = |_: &Path, _: &Path| false;
|
||||
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
|
||||
if !made(&base.join("outside/secret.svg"), &base.join("in/leak.svg")) {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
return;
|
||||
}
|
||||
assert!(roots.check(&s(base.join("in/leak.svg")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
|
||||
assert!(made(&base.join("outside"), &base.join("out/away")));
|
||||
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "a folder link out: {e}");
|
||||
assert!(made(&base.join("outside/gone.svg"), &base.join("out/dangling.png")));
|
||||
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
|
||||
// A link to a folder inside the root stays inside.
|
||||
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.svg")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
assert_eq!(linked.check(&s(base.join("in/a.svg")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
let (base, roots) = folders("case");
|
||||
let upper = s(base.join("IN/A.SVG"));
|
||||
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
||||
assert_eq!(roots.check(&s(base.join("In/Sub/New.svg")), Access::Read), Ok(()));
|
||||
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("reserved device names"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
|
||||
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_carry_to_other_threads() {
|
||||
let (base, roots) = folders("scopes");
|
||||
let outside = s(base.join("outside/secret.svg"));
|
||||
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
|
||||
confine(Some(&roots), || {
|
||||
assert!(check_read(&outside).is_err());
|
||||
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
|
||||
assert!(check_read(&outside).is_err(), "restored after unconfined");
|
||||
let handle = ScopeHandle::capture();
|
||||
let o = outside.clone();
|
||||
let there = std::thread::spawn(move || (check_read(&o).is_ok(), handle.run(|| check_read(&o).is_ok()))).join().unwrap();
|
||||
assert_eq!(there, (true, false), "another thread is confined only in the captured scope");
|
||||
});
|
||||
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
|
||||
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
|
||||
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_names() {
|
||||
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.svg", "NUL "] {
|
||||
assert!(is_device_name(d), "{d}");
|
||||
}
|
||||
for f in ["console", "com10", "COM0", "lpt", "a.svg", "nul-x"] {
|
||||
assert!(!is_device_name(f), "{f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod cmd;
|
||||
pub mod file_access;
|
||||
pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod steps;
|
||||
|
||||
+39
-13
@@ -1,6 +1,9 @@
|
||||
//! An in-process engine session that answers the control-channel methods itself.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
use vectorcraft_engine::{Session, UiRequest, ViewInfo};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_render::Renderer;
|
||||
@@ -14,6 +17,9 @@ pub struct Headless {
|
||||
pub renderer: Renderer,
|
||||
/// View state handed to tools (zoom 1, smart guides on).
|
||||
pub view: ViewInfo,
|
||||
/// The folders its methods may read and write (`--automation-read-root`,
|
||||
/// `--automation-write-root`); `None`: anywhere.
|
||||
roots: Option<Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
@@ -93,7 +99,13 @@ fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
|
||||
impl Headless {
|
||||
/// A session with no document (the first `file.new` / `app.open` creates one).
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default() }
|
||||
Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default(), roots: None }
|
||||
}
|
||||
|
||||
/// Confine the files every method reads and writes to `roots` ([`file_access`]).
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
|
||||
self.roots = roots;
|
||||
self
|
||||
}
|
||||
|
||||
/// A session with a fresh default (Letter) document, ready to draw into.
|
||||
@@ -227,13 +239,20 @@ impl Headless {
|
||||
vectorcraft_render::raster_size(r, scale)?;
|
||||
let img = self.renderer.render_region(&doc, r, scale, true);
|
||||
let png = img.to_png()?;
|
||||
let mut out = json!({"width": img.width, "height": img.height});
|
||||
match s(p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
file_access::check_write(path)?;
|
||||
vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
out["path"] = json!(path);
|
||||
// `data: true` asks for the image as well as the file.
|
||||
if p.get("data").and_then(Value::as_bool) == Some(true) {
|
||||
out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png));
|
||||
}
|
||||
}
|
||||
None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": vectorcraft_format::base64_encode(&png)})),
|
||||
None => out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `app.open {path}`: any readable file as a new active document (`document.open`).
|
||||
@@ -260,7 +279,22 @@ impl Headless {
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let p = ¶ms;
|
||||
let roots = self.roots.clone();
|
||||
file_access::confine(roots.as_ref(), || self.answer(method, ¶ms))
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"headless".into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
/// One control-channel method ([`Backend::call`]).
|
||||
fn answer(&mut self, method: &str, p: &Value) -> Result<Value, String> {
|
||||
match method {
|
||||
"engine.execute" | "ui.menu.invoke" | "command" => {
|
||||
let id = s(p, "command").or(s(p, "id")).ok_or("missing `command`")?.to_string();
|
||||
@@ -289,12 +323,4 @@ impl Backend for Headless {
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"headless".into()
|
||||
}
|
||||
}
|
||||
+42
-35
@@ -649,43 +649,50 @@ fn transform(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||||
|
||||
fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let path = a.get("path").and_then(Value::as_str);
|
||||
if a.get("window").and_then(Value::as_bool) == Some(true) {
|
||||
need_ui(b, "screenshot {window:true}")?;
|
||||
// The app writes the capture to disk (loopback: same machine), then we read it back.
|
||||
let tmp;
|
||||
let target = match path {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string();
|
||||
&tmp
|
||||
}
|
||||
};
|
||||
let r = b.call("ui.screenshot", json!({"path": target}))?;
|
||||
let png = std::fs::read(target).map_err(|e| format!("read {target}: {e}"))?;
|
||||
if path.is_none() {
|
||||
std::fs::remove_file(target).ok();
|
||||
}
|
||||
return Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path})));
|
||||
}
|
||||
let mut p = json!({});
|
||||
for k in ["scale", "artboard"] {
|
||||
if let Some(v) = a.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
let r = b.call("ui.render", p)?;
|
||||
let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
|
||||
let window = a.get("window").and_then(Value::as_bool) == Some(true);
|
||||
// The backend writes the file, where its automation roots allow, and sends the image back too
|
||||
// (`data`): this process writes nothing.
|
||||
let mut p = json!({"data": true});
|
||||
if let Some(path) = path {
|
||||
let png = vectorcraft_format::base64_decode(b64).ok_or("renderer returned bad base64")?;
|
||||
std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
p["path"] = json!(path);
|
||||
}
|
||||
Ok(ToolResult {
|
||||
content: vec![
|
||||
json!({"type": "image", "data": b64, "mimeType": "image/png"}),
|
||||
json!({"type": "text", "text": json!({"width": r.get("width"), "height": r.get("height"), "path": path}).to_string()}),
|
||||
],
|
||||
is_error: false,
|
||||
})
|
||||
let r = if window {
|
||||
need_ui(b, "screenshot {window:true}")?;
|
||||
b.call("ui.screenshot", p)?
|
||||
} else {
|
||||
for k in ["scale", "artboard"] {
|
||||
if let Some(v) = a.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
b.call("ui.render", p)?
|
||||
};
|
||||
let png = match r.get("pngBase64").and_then(Value::as_str) {
|
||||
Some(b64) => vectorcraft_format::base64_decode(b64).ok_or("the backend returned bad base64")?,
|
||||
// An app from before `data` wrote the file without sending the image: read it back
|
||||
// (loopback: the same machine).
|
||||
None => match path {
|
||||
Some(path) => std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?,
|
||||
None if window => return old_window_capture(b),
|
||||
None => return Err("the renderer returned no image".into()),
|
||||
},
|
||||
};
|
||||
let info = if window {
|
||||
json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path})
|
||||
} else {
|
||||
json!({"width": r.get("width"), "height": r.get("height"), "path": path})
|
||||
};
|
||||
Ok(image_result(&png, info))
|
||||
}
|
||||
|
||||
/// The window of an app from before `ui.screenshot {data}`, captured through a temporary file.
|
||||
fn old_window_capture(b: &mut dyn Backend) -> Result<ToolResult, String> {
|
||||
let tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string();
|
||||
let r = b.call("ui.screenshot", json!({"path": tmp}))?;
|
||||
let png = std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?;
|
||||
// Best effort: a temporary file left behind is harmless.
|
||||
std::fs::remove_file(&tmp).ok();
|
||||
Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null})))
|
||||
}
|
||||
|
||||
fn image_result(png: &[u8], info: Value) -> ToolResult {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! `vectorcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` (#832): through
|
||||
//! the MCP protocol, files open only from inside the read root and are saved, exported and
|
||||
//! captured only inside the write root; paths that leave a root (`..`, another folder, a sibling
|
||||
//! with the same prefix) are refused, and nothing is written outside.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::file_access::AutomationRoots;
|
||||
use vectorcraft_mcp::{Headless, Server};
|
||||
|
||||
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10" fill="red"/></svg>"#;
|
||||
|
||||
/// A folder holding `in` (the read root, with `a.svg`), `out` (the write root), `outside` (with
|
||||
/// `b.svg`) and `in2`, and a server confined to `in` and `out` (either may be left out).
|
||||
fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
|
||||
let base = vectorcraft_testkit::temp_dir(&format!("roots-{tag}"));
|
||||
for d in ["in", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.svg"), SVG).unwrap();
|
||||
std::fs::write(base.join("outside/b.svg"), SVG).unwrap();
|
||||
std::fs::write(base.join("in2/c.svg"), SVG).unwrap();
|
||||
let in_dir = base.join("in");
|
||||
let out_dir = base.join("out");
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
(Server::new(Box::new(Headless::with_document().with_automation_roots(roots))), base)
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `tools/call` → (isError, the first text block).
|
||||
fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string();
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap();
|
||||
let result = &reply["result"];
|
||||
let text = result["content"].as_array().unwrap().iter().find_map(|c| c["text"].as_str()).unwrap_or_default().to_string();
|
||||
(result["isError"] == true, text)
|
||||
}
|
||||
|
||||
fn ok(server: &mut Server, name: &str, args: Value) -> String {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(!error, "{name} {args}: {text}");
|
||||
text
|
||||
}
|
||||
|
||||
fn refused(server: &mut Server, name: &str, args: Value, why: &str) {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_open_only_from_inside_the_read_root() {
|
||||
let (mut server, base) = confined("open", true, true);
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
ok(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))}));
|
||||
ok(&mut server, "run_command", json!({"command": "file.place", "params": {"path": s(&base.join("in/a.svg"))}}));
|
||||
refused(&mut server, "open_file", json!({"path": s(&base.join("outside/b.svg"))}), "outside the read root");
|
||||
refused(&mut server, "open_file", json!({"path": format!("{}{sep}..{sep}outside{sep}b.svg", s(&base.join("in")))}), "outside the read root");
|
||||
refused(&mut server, "open_file", json!({"path": s(&base.join("in2/c.svg"))}), "outside the read root");
|
||||
refused(
|
||||
&mut server,
|
||||
"run_command",
|
||||
json!({"command": "file.place", "params": {"path": s(&base.join("outside/b.svg"))}}),
|
||||
"outside the read root",
|
||||
);
|
||||
// The write root isn't readable: the two kinds of access are separate.
|
||||
std::fs::write(base.join("out/d.svg"), SVG).unwrap();
|
||||
refused(&mut server, "open_file", json!({"path": s(&base.join("out/d.svg"))}), "outside the read root");
|
||||
// A relative path is the working directory's (the crate's folder here): outside.
|
||||
refused(&mut server, "open_file", json!({"path": "Cargo.toml"}), "outside the read root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_exports_and_captures_go_only_into_the_write_root() {
|
||||
let (mut server, base) = confined("write", true, true);
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
ok(&mut server, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 50, "height": 40, "fill": "#1e88e5"}));
|
||||
|
||||
ok(&mut server, "export", json!({"path": s(&base.join("out/art.png"))}));
|
||||
assert!(base.join("out/art.png").is_file());
|
||||
ok(&mut server, "export", json!({"path": s(&base.join("out/art.svg"))}));
|
||||
ok(&mut server, "save_file", json!({"path": s(&base.join("out/doc.vectorcraft"))}));
|
||||
ok(&mut server, "screenshot", json!({"path": s(&base.join("out/shot.png"))}));
|
||||
assert!(base.join("out/shot.png").is_file(), "the backend wrote the capture");
|
||||
|
||||
refused(&mut server, "export", json!({"path": s(&base.join("in/art.png"))}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": s(&base.join("outside/art.svg"))}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": format!("{}{sep}..{sep}outside{sep}up.png", s(&base.join("out")))}), "outside the write root");
|
||||
refused(&mut server, "save_file", json!({"path": s(&base.join("outside/doc.vectorcraft"))}), "outside the write root");
|
||||
refused(&mut server, "screenshot", json!({"path": s(&base.join("outside/shot.png"))}), "outside the write root");
|
||||
refused(
|
||||
&mut server,
|
||||
"run_command",
|
||||
json!({"command": "document.exportForScreens", "params": {"folder": s(&base.join("outside/screens"))}}),
|
||||
"outside the write root",
|
||||
);
|
||||
refused(
|
||||
&mut server,
|
||||
"run_command",
|
||||
json!({"command": "file.package", "params": {"folder": s(&base.join("outside/pkg"))}}),
|
||||
"outside the write root",
|
||||
);
|
||||
// Folders the app writes on its own later: only inside the roots.
|
||||
refused(
|
||||
&mut server,
|
||||
"run_command",
|
||||
json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("outside"))}}),
|
||||
"outside the write root",
|
||||
);
|
||||
ok(&mut server, "run_command", json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("out"))}}));
|
||||
refused(&mut server, "run_command", json!({"command": "plugin.reload", "params": {"path": s(&base.join("outside"))}}), "outside the read root");
|
||||
|
||||
// The document saved inside the write root saves again in place.
|
||||
ok(&mut server, "save_file", json!({}));
|
||||
let written: Vec<String> =
|
||||
std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(written, ["b.svg"], "nothing was written outside the roots");
|
||||
assert!(!base.join("in/art.png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_left_out_grants_none_of_its_access() {
|
||||
let (mut server, base) = confined("read-only", true, false);
|
||||
ok(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))}));
|
||||
refused(&mut server, "export", json!({"path": s(&base.join("out/art.png"))}), "write authority is absent");
|
||||
// Bytes that come back instead of a file are fine.
|
||||
ok(&mut server, "export", json!({"format": "svg"}));
|
||||
|
||||
let (mut server, base) = confined("write-only", false, true);
|
||||
refused(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))}), "read authority is absent");
|
||||
ok(&mut server, "export", json!({"path": s(&base.join("out/art.png"))}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_out_of_a_root_are_refused() {
|
||||
let (mut server, base) = confined("links", true, true);
|
||||
#[cfg(unix)]
|
||||
let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok();
|
||||
#[cfg(windows)]
|
||||
let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok();
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let linked = false;
|
||||
// Windows needs Developer Mode or elevation to make links.
|
||||
if !linked {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
return;
|
||||
}
|
||||
refused(&mut server, "export", json!({"path": s(&base.join("out/away/art.png"))}), "outside the write root");
|
||||
assert!(!base.join("outside/art.png").exists());
|
||||
}
|
||||
@@ -137,6 +137,55 @@ fn every_tool_survives_empty_and_junk_arguments() {
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Junk below a root: names, `.`, `..` (at most four, which stay inside the test's folder), a
|
||||
/// device name, a drive, either separator.
|
||||
fn junk_tail() -> impl Strategy<Value = String> {
|
||||
let seg =
|
||||
prop::sample::select(vec!["..", ".", "a", "b.svg", "x.png", "d.vectorcraft", "CON", "nul.txt", "", "c:", "~", "%2e%2e", "ü", "a b", "x."]);
|
||||
(prop::collection::vec(seg, 1..=4), prop::sample::select(vec!["/", "\\"])).prop_map(|(segs, sep)| segs.join(sep))
|
||||
}
|
||||
|
||||
/// Every file below `dir`.
|
||||
fn files_below(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = vec![];
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = e.path();
|
||||
if e.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
out.extend(files_below(&path));
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
|
||||
|
||||
/// Confined to one folder (#832), the server takes fuzzed paths, unlike the junk test above:
|
||||
/// every reply is well formed, and whatever it writes lands inside the root.
|
||||
#[test]
|
||||
fn a_confined_server_writes_only_inside_its_root(
|
||||
tail in junk_tail(),
|
||||
tool in prop::sample::select(vec!["open_file", "save_file", "export", "screenshot"]),
|
||||
) {
|
||||
let base = vectorcraft_testkit::temp_dir("mcp-confined");
|
||||
let root = base.join("a/b/c/root");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(base.join("outside")).unwrap();
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(Some(root.as_path()), Some(root.as_path())).unwrap();
|
||||
let mut s = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
|
||||
let path = format!("{}{}{tail}", root.display(), std::path::MAIN_SEPARATOR);
|
||||
let r = catch_quiet(|| call(&mut s, tool, json!({"path": path})));
|
||||
prop_assert!(r.is_ok(), "{tool} {path}: panicked");
|
||||
let real_root = std::fs::canonicalize(&root).unwrap();
|
||||
for f in files_below(&base) {
|
||||
let inside = std::fs::canonicalize(&f).map_or_else(|_| f.starts_with(&root), |real| real.starts_with(&real_root));
|
||||
prop_assert!(inside, "{tool} {path} wrote {}", f.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framing_never_panics_on_malformed_messages() {
|
||||
let mut s = server();
|
||||
|
||||
@@ -95,6 +95,13 @@ pub struct FolderReport {
|
||||
/// reported and skipped; the others still load.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
|
||||
load_folder_where(dir, |_| Ok(()))
|
||||
}
|
||||
|
||||
/// [`load_folder`], loading only the files `allow` accepts; the others are reported as failed
|
||||
/// with its reason.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>) -> Result<FolderReport> {
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
|
||||
let mut files: Vec<std::path::PathBuf> =
|
||||
rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect();
|
||||
@@ -102,6 +109,10 @@ pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
|
||||
files.truncate(1000);
|
||||
let mut report = FolderReport::default();
|
||||
for f in files {
|
||||
if let Err(why) = allow(&f) {
|
||||
report.failed.push((f.display().to_string(), why));
|
||||
continue;
|
||||
}
|
||||
match load_file(&f, Limits::default()) {
|
||||
Ok(p) => report.loaded.push(install(p).id().to_string()),
|
||||
Err(e) => report.failed.push((f.display().to_string(), e.to_string())),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::file_access::ScopeHandle;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
@@ -32,6 +33,8 @@ struct Task {
|
||||
id: u64,
|
||||
work: Work,
|
||||
write: SharedWriteFn,
|
||||
/// The automation roots in force where the work was started, in force where it runs.
|
||||
scope: ScopeHandle,
|
||||
}
|
||||
|
||||
/// A job in progress.
|
||||
@@ -65,8 +68,9 @@ impl Background {
|
||||
std::thread::Builder::new()
|
||||
.name("vectorcraft-save".into())
|
||||
.spawn(move || {
|
||||
while let Ok(Task { id, work, write }) = tasks.recv() {
|
||||
let r = vectorcraft_engine::guard::catch_panic(|| work(&mut |p: &str, b: &[u8]| write(p, b)))
|
||||
while let Ok(Task { id, work, write, scope }) = tasks.recv() {
|
||||
let r = scope
|
||||
.run(|| vectorcraft_engine::guard::catch_panic(|| work(&mut |p: &str, b: &[u8]| write(p, b))))
|
||||
.unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)")));
|
||||
if done_tx.send((id, r)).is_err() {
|
||||
break;
|
||||
@@ -101,7 +105,7 @@ pub fn run(
|
||||
if let Some(write) = shared {
|
||||
let id = app.background.next_id;
|
||||
if let Some(tx) = app.background.worker() {
|
||||
match tx.send(Task { id, work, write }) {
|
||||
match tx.send(Task { id, work, write, scope: ScopeHandle::capture() }) {
|
||||
Ok(()) => {
|
||||
let bg = &mut app.background;
|
||||
bg.next_id += 1;
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
//! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs)
|
||||
//! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}`
|
||||
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
|
||||
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
|
||||
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
|
||||
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG
|
||||
//! comes back as `pngBase64` as well)
|
||||
//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path,
|
||||
//! or with `data: true`, as `pngBase64`)
|
||||
//! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit`
|
||||
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
|
||||
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
|
||||
@@ -47,7 +49,12 @@ impl ControlRequest {
|
||||
|
||||
pub enum Outcome {
|
||||
Done(Value),
|
||||
Screenshot { path: Option<String> },
|
||||
/// A window capture, answered once a frame was presented: written to `path`, the PNG sent
|
||||
/// back too with `data`.
|
||||
Screenshot {
|
||||
path: Option<String>,
|
||||
data: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn ok(v: Value) -> Outcome {
|
||||
@@ -301,11 +308,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.screenshot" => {
|
||||
// Refused now rather than once the frame comes (it is checked again then).
|
||||
if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) {
|
||||
return err(e);
|
||||
}
|
||||
if p.get("focus").and_then(Value::as_bool).unwrap_or(true) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Outcome::Screenshot { path: s("path").map(str::to_string) }
|
||||
Outcome::Screenshot { path: s("path").map(str::to_string), data: p.get("data").and_then(Value::as_bool) == Some(true) }
|
||||
}
|
||||
"ui.render" => {
|
||||
let Some(st) = app.session.active() else { return err("no document") };
|
||||
@@ -320,12 +331,16 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
Ok(png) => png,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let data = p.get("data").and_then(Value::as_bool) == Some(true);
|
||||
match s("path") {
|
||||
Some(path) => match app.services.write.as_mut() {
|
||||
Some(w) => wrap(w(path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height}))),
|
||||
Some(w) => wrap(
|
||||
w(path, &png)
|
||||
.map(|_| with_data(json!({"path": path, "width": img.width, "height": img.height}), data.then_some(png.as_slice()))),
|
||||
),
|
||||
None => err("no writer"),
|
||||
},
|
||||
None => ok(json!({"width": img.width, "height": img.height, "pngBase64": vectorcraft_format::base64_encode(&png)})),
|
||||
None => ok(with_data(json!({"width": img.width, "height": img.height}), Some(&png))),
|
||||
}
|
||||
}
|
||||
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
|
||||
@@ -342,20 +357,34 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
/// `result` with the PNG as `pngBase64` when there is one.
|
||||
fn with_data(mut result: Value, png: Option<&[u8]>) -> Value {
|
||||
if let Some(png) = png {
|
||||
result["pngBase64"] = json!(vectorcraft_format::base64_encode(png));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The reply to `ui.screenshot`: the window capture `image` written to `path`, and sent back as
|
||||
/// `pngBase64` with `data`.
|
||||
pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>, data: bool) -> Value {
|
||||
let [w, h] = image.size;
|
||||
let Some(path) = path else {
|
||||
if path.is_none() && !data {
|
||||
return json!({"ok": true, "result": {"width": w, "height": h}});
|
||||
};
|
||||
}
|
||||
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||
let img = vectorcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba };
|
||||
let png = match img.to_png() {
|
||||
Ok(png) => png,
|
||||
Err(e) => return json!({"ok": false, "error": e}),
|
||||
};
|
||||
let data = data.then_some(png.as_slice());
|
||||
let Some(path) = path else {
|
||||
return json!({"ok": true, "result": with_data(json!({"width": w, "height": h}), data)});
|
||||
};
|
||||
match app.services.write.as_mut() {
|
||||
Some(wr) => match wr(path, &png) {
|
||||
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
|
||||
Ok(()) => json!({"ok": true, "result": with_data(json!({"path": path, "width": w, "height": h}), data)}),
|
||||
Err(e) => json!({"ok": false, "error": e}),
|
||||
},
|
||||
None => json!({"ok": false, "error": "no writer configured"}),
|
||||
|
||||
@@ -3827,3 +3827,12 @@ axis Both Ambos
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal)
|
||||
@msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path}
|
||||
@msg automation path rejected: outside the write root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de escritura {root}: {path}
|
||||
@msg automation path rejected: path is empty ruta de automatización rechazada: la ruta está vacía
|
||||
@msg automation path rejected: a link that leads nowhere: {path} ruta de automatización rechazada: un vínculo que no lleva a ninguna parte: {path}
|
||||
@msg automation path rejected: cannot resolve {path}: {e} ruta de automatización rechazada: no se puede resolver {path}: {e}
|
||||
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} ruta de automatización rechazada: sube (..) desde una carpeta que no existe: {path}
|
||||
@msg automation path rejected: reserved device names are not allowed: {path} ruta de automatización rechazada: no se permiten nombres de dispositivo reservados: {path}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3838,3 +3838,12 @@ axis Both Les deux
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral)
|
||||
@msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path}
|
||||
@msg automation path rejected: outside the write root {root}: {path} chemin d’automatisation refusé : hors du dossier racine d’écriture {root} : {path}
|
||||
@msg automation path rejected: path is empty chemin d’automatisation refusé : le chemin est vide
|
||||
@msg automation path rejected: a link that leads nowhere: {path} chemin d’automatisation refusé : un lien qui ne mène nulle part : {path}
|
||||
@msg automation path rejected: cannot resolve {path}: {e} chemin d’automatisation refusé : impossible de résoudre {path} : {e}
|
||||
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} chemin d’automatisation refusé : remonte (..) depuis un dossier qui n’existe pas : {path}
|
||||
@msg automation path rejected: reserved device names are not allowed: {path} chemin d’automatisation refusé : les noms de périphériques réservés ne sont pas autorisés : {path}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3827,3 +3827,12 @@ axis Both Entrambi
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale)
|
||||
@msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path}
|
||||
@msg automation path rejected: outside the write root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di scrittura {root}: {path}
|
||||
@msg automation path rejected: path is empty percorso di automazione rifiutato: il percorso è vuoto
|
||||
@msg automation path rejected: a link that leads nowhere: {path} percorso di automazione rifiutato: un collegamento che non porta da nessuna parte: {path}
|
||||
@msg automation path rejected: cannot resolve {path}: {e} percorso di automazione rifiutato: impossibile risolvere {path}: {e}
|
||||
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} percorso di automazione rifiutato: risale (..) da una cartella che non esiste: {path}
|
||||
@msg automation path rejected: reserved device names are not allowed: {path} percorso di automazione rifiutato: i nomi di dispositivo riservati non sono consentiti: {path}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3819,3 +3819,12 @@ theme Appearance 外観
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください)
|
||||
@msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません)
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path}
|
||||
@msg automation path rejected: outside the write root {root}: {path} 自動化のパスを拒否しました: 書き込みルート {root} の外です: {path}
|
||||
@msg automation path rejected: path is empty 自動化のパスを拒否しました: パスが空です
|
||||
@msg automation path rejected: a link that leads nowhere: {path} 自動化のパスを拒否しました: リンク先が存在しません: {path}
|
||||
@msg automation path rejected: cannot resolve {path}: {e} 自動化のパスを拒否しました: {path} を解決できません: {e}
|
||||
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} 自動化のパスを拒否しました: 存在しないフォルダーから上の階層 (..) へ移動しています: {path}
|
||||
@msg automation path rejected: reserved device names are not allowed: {path} 自動化のパスを拒否しました: 予約済みのデバイス名は使用できません: {path}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3825,3 +3825,12 @@ axis Both Оба
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$)
|
||||
@msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path}
|
||||
@msg automation path rejected: outside the write root {root}: {path} путь автоматизации отклонён: вне корневой папки для записи {root}: {path}
|
||||
@msg automation path rejected: path is empty путь автоматизации отклонён: путь пуст
|
||||
@msg automation path rejected: a link that leads nowhere: {path} путь автоматизации отклонён: ссылка, которая никуда не ведёт: {path}
|
||||
@msg automation path rejected: cannot resolve {path}: {e} путь автоматизации отклонён: не удаётся определить {path}: {e}
|
||||
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} путь автоматизации отклонён: переход вверх (..) из несуществующей папки: {path}
|
||||
@msg automation path rejected: reserved device names are not allowed: {path} путь автоматизации отклонён: зарезервированные имена устройств недопустимы: {path}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3810,3 +3810,12 @@ axis Both Обидві
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$)
|
||||
@msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path}
|
||||
@msg automation path rejected: outside the write root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для запису {root}: {path}
|
||||
@msg automation path rejected: path is empty шлях автоматизації відхилено: шлях порожній
|
||||
@msg automation path rejected: a link that leads nowhere: {path} шлях автоматизації відхилено: посилання, яке нікуди не веде: {path}
|
||||
@msg automation path rejected: cannot resolve {path}: {e} шлях автоматизації відхилено: не вдається визначити {path}: {e}
|
||||
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} шлях автоматизації відхилено: перехід угору (..) з папки, якої не існує: {path}
|
||||
@msg automation path rejected: reserved device names are not allowed: {path} шлях автоматизації відхилено: зарезервовані імена пристроїв не дозволені: {path}
|
||||
|
Can't render this file because it contains an unexpected character in line 634 and column 40.
|
+69
-13
@@ -62,6 +62,8 @@ mod tests_adjust;
|
||||
#[cfg(test)]
|
||||
mod tests_aisave;
|
||||
#[cfg(test)]
|
||||
mod tests_automation;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_clipboard;
|
||||
@@ -149,6 +151,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
@@ -319,8 +322,8 @@ pub struct VectorcraftApp {
|
||||
/// moves ([`native_menu::sync`]).
|
||||
run_count: u64,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
/// (token, path, reply, deadline ms): a window that isn't presented never delivers its frame.
|
||||
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>, f64)>,
|
||||
/// Window captures asked for and not answered yet.
|
||||
pending_screenshots: Vec<PendingScreenshot>,
|
||||
queued_screenshots: Vec<(u64, f64, u32)>,
|
||||
screenshot_token: u64,
|
||||
/// Synthetic input events (from the control channel) injected one step per frame.
|
||||
@@ -386,6 +389,25 @@ pub struct VectorcraftApp {
|
||||
/// A numeric field is being scrubbed: the document's edits meanwhile are one undo step
|
||||
/// ([`scrub::begin_frame`]).
|
||||
scrub_group: bool,
|
||||
/// The folders control requests, and the input they inject, may read and write
|
||||
/// (`--automation-read-root`, `--automation-write-root`); `None`: anywhere. See
|
||||
/// [`Self::with_automation_roots`].
|
||||
automation_roots: Option<Arc<AutomationRoots>>,
|
||||
/// This frame carries input the control channel injected ([`Self::raw_input_hook`]): it runs
|
||||
/// confined to [`Self::automation_roots`].
|
||||
synthetic_frame: bool,
|
||||
}
|
||||
|
||||
/// A window capture asked for through the control channel (`ui.screenshot`), answered once a
|
||||
/// frame is presented; a window that isn't presented never delivers one, hence the deadline.
|
||||
struct PendingScreenshot {
|
||||
token: u64,
|
||||
path: Option<String>,
|
||||
/// Send the PNG back as `pngBase64`.
|
||||
data: bool,
|
||||
reply: Sender<ControlResponse>,
|
||||
/// When to give up (ms, as [`now_ms`]).
|
||||
deadline: f64,
|
||||
}
|
||||
|
||||
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`].
|
||||
@@ -454,6 +476,8 @@ impl VectorcraftApp {
|
||||
ime_marked: None,
|
||||
ime_discard: false,
|
||||
scrub_group: false,
|
||||
automation_roots: None,
|
||||
synthetic_frame: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +486,33 @@ impl VectorcraftApp {
|
||||
self
|
||||
}
|
||||
|
||||
/// Confine what the control channel does to `roots` (#832): its requests, the input it
|
||||
/// injects (in the frames that carry it) and the saves and exports they start read and write
|
||||
/// only inside them ([`file_access`]). The host's file services check every path they're given
|
||||
/// against the roots in force, and so does the engine; outside those scopes the person at the
|
||||
/// keyboard works as usual.
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
|
||||
let Some(roots) = roots else { return self };
|
||||
self.automation_roots = Some(roots);
|
||||
let s = &mut self.services;
|
||||
if let Some(read) = s.read.take() {
|
||||
s.read = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| read(p))));
|
||||
}
|
||||
if let Some(mut write) = s.write.take() {
|
||||
s.write = Some(Box::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b))));
|
||||
}
|
||||
if let Some(write) = s.write_shared.take() {
|
||||
s.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b))));
|
||||
}
|
||||
// Edit Original and Show in Folder hand a file to another app: only one automation may read.
|
||||
for service in [&mut s.open_file, &mut s.reveal] {
|
||||
if let Some(mut open) = service.take() {
|
||||
*service = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| open(p))));
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Keep `views` aligned with the session's documents (a new one starts at its saved view).
|
||||
pub fn sync_views(&mut self) {
|
||||
let docs = self.session.documents();
|
||||
@@ -619,18 +670,19 @@ impl VectorcraftApp {
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
let reply = req.reply.clone();
|
||||
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
|
||||
let outcome = vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))
|
||||
let roots = self.automation_roots.clone();
|
||||
let outcome = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)))
|
||||
.unwrap_or_else(|msg| control::err(format!("internal error: {msg} (please report this bug)")));
|
||||
match outcome {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
}
|
||||
control::Outcome::Screenshot { path } => {
|
||||
control::Outcome::Screenshot { path, data } => {
|
||||
self.screenshot_token += 1;
|
||||
let token = self.screenshot_token;
|
||||
let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0;
|
||||
self.queued_screenshots.push((token, now_ms() + settle, 0));
|
||||
self.pending_screenshots.push((token, path, reply, now_ms() + settle + 8000.0));
|
||||
self.pending_screenshots.push(PendingScreenshot { token, path, data, reply, deadline: now_ms() + settle + 8000.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -671,17 +723,18 @@ impl VectorcraftApp {
|
||||
.collect()
|
||||
});
|
||||
for (token, image) in events {
|
||||
if let Some(i) = self.pending_screenshots.iter().position(|(t, ..)| *t == token) {
|
||||
let (_, path, reply, _) = self.pending_screenshots.remove(i);
|
||||
let _ = reply.send(control::save_screenshot(self, &image, path.as_deref()));
|
||||
if let Some(i) = self.pending_screenshots.iter().position(|p| p.token == token) {
|
||||
let p = self.pending_screenshots.remove(i);
|
||||
let roots = self.automation_roots.clone();
|
||||
let _ = p.reply.send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, p.path.as_deref(), p.data)));
|
||||
}
|
||||
}
|
||||
let now = now_ms();
|
||||
self.pending_screenshots.retain(|(_, _, reply, deadline)| {
|
||||
if now < *deadline {
|
||||
self.pending_screenshots.retain(|p| {
|
||||
if now < p.deadline {
|
||||
return true;
|
||||
}
|
||||
let _ = reply.send(serde_json::json!({
|
||||
let _ = p.reply.send(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "no frame was presented (screen locked, window minimized or fully covered); ui.render still renders the artboard"
|
||||
}));
|
||||
@@ -782,7 +835,8 @@ impl VectorcraftApp {
|
||||
/// Per-frame logic before layout (control channel, shortcuts, inbox). A bug that panics costs
|
||||
/// one frame and shows an error, instead of closing the app with unsaved work.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
if let Err(msg) = vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx)) {
|
||||
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
|
||||
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx))) {
|
||||
self.status(format!("Internal error: {msg} (please report this bug)"));
|
||||
}
|
||||
}
|
||||
@@ -964,6 +1018,7 @@ impl VectorcraftApp {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.synthetic_frame = !self.synthetic.is_empty();
|
||||
let Some(first) = self.synthetic.first() else {
|
||||
if std::mem::take(&mut self.synthetic_modifiers) {
|
||||
raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers));
|
||||
@@ -1006,7 +1061,8 @@ impl VectorcraftApp {
|
||||
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
if let Err(msg) = vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui)) {
|
||||
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
|
||||
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui))) {
|
||||
self.status(format!("Internal error: {msg} (please report this bug)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>` (#832):
|
||||
//! control requests read and write only inside the roots; the person at the keyboard is not
|
||||
//! confined.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::file_access::AutomationRoots;
|
||||
|
||||
use crate::control::ControlRequest;
|
||||
use crate::{Services, VectorcraftApp};
|
||||
|
||||
/// A folder holding `in` (the read root, with `a.svg`), `out` (the write root) and `outside`
|
||||
/// (with `b.svg`), and an app with the desktop's file services confined to `in` and `out`.
|
||||
fn confined(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>, PathBuf) {
|
||||
let base = std::env::temp_dir().join(format!("vc-ui-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
|
||||
std::fs::write(base.join("in/a.svg"), svg).unwrap();
|
||||
std::fs::write(base.join("outside/b.svg"), svg).unwrap();
|
||||
let services = Services {
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| vectorcraft_format::write_atomic(Path::new(p), b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
};
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let app = VectorcraftApp::new(Session::new(), services).with_control(rx).with_automation_roots(roots);
|
||||
(app, tx, base)
|
||||
}
|
||||
|
||||
fn s(p: PathBuf) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// One request through the control channel, as the app's frame drains it.
|
||||
fn request(app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender<ControlRequest>, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params);
|
||||
tx.send(req).unwrap();
|
||||
app.drain_control(&egui::Context::default());
|
||||
reply.try_recv().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_requests_stay_inside_the_roots() {
|
||||
let (mut app, tx, base) = confined("control");
|
||||
let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("outside/b.svg"))}));
|
||||
assert!(r["error"].as_str().unwrap().contains("outside the read root"), "{r}");
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.place", "params": {"path": s(base.join("outside/b.svg"))}}));
|
||||
assert!(r["error"].as_str().unwrap().contains("outside the read root"), "{r}");
|
||||
|
||||
let r = request(&mut app, &tx, "app.export", json!({"path": s(base.join("out/a.png")), "format": "png"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(base.join("out/a.png").is_file());
|
||||
for (method, params) in [
|
||||
("app.export", json!({"path": s(base.join("outside/a.png")), "format": "png"})),
|
||||
("app.save", json!({"path": s(base.join("outside/a.vectorcraft"))})),
|
||||
("ui.render", json!({"path": s(base.join("outside/r.png"))})),
|
||||
// Refused at once, not when the frame comes.
|
||||
("ui.screenshot", json!({"path": s(base.join("outside/w.png"))})),
|
||||
] {
|
||||
let r = request(&mut app, &tx, method, params);
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the write root")), "{method}: {r}");
|
||||
}
|
||||
let r = request(&mut app, &tx, "ui.render", json!({"path": s(base.join("out/r.png")), "data": true}));
|
||||
assert!(r["result"]["pngBase64"].is_string() && base.join("out/r.png").is_file(), "{r}");
|
||||
assert_eq!(std::fs::read_dir(base.join("outside")).unwrap().count(), 1, "nothing written outside");
|
||||
|
||||
// The person at the keyboard isn't confined.
|
||||
assert!(app.run("file.open", json!({"path": s(base.join("outside/b.svg"))})).is_ok());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_with_injected_input_are_confined() {
|
||||
let (mut app, tx, base) = confined("synthetic");
|
||||
let r = request(&mut app, &tx, "ui.key", json!({"key": "Escape"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let mut raw = egui::RawInput::default();
|
||||
app.raw_input_hook(&mut raw);
|
||||
assert!(app.synthetic_frame, "the frame carrying the key");
|
||||
let mut raw = egui::RawInput::default();
|
||||
app.raw_input_hook(&mut raw);
|
||||
assert!(!app.synthetic_frame, "the frames after it are the person's");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -13,6 +13,34 @@ its request line is rejected first. At most 16 connections are served at once; f
|
||||
line and are closed. Clients that get an error reply should reconnect. The port has no authentication,
|
||||
so only enable it while you use it. Transport: `apps/vectorcraft/src/control_server.rs`.
|
||||
|
||||
## Confining file access
|
||||
|
||||
```sh
|
||||
vectorcraft --control 7979 --automation-read-root /work/project --automation-write-root /work/project/out
|
||||
```
|
||||
|
||||
`--automation-read-root <dir>` and `--automation-write-root <dir>` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` /
|
||||
`VECTORCRAFT_AUTOMATION_WRITE_ROOT`; `--flag=<dir>` works too) confine what the control channel reads and
|
||||
writes, with the same flags as `vectorcraft-cli mcp` and the other Craft apps. Every path a request reads
|
||||
(open, place, relink, library and profile loads, plug-ins…) must lie inside the read root, every path it
|
||||
writes (save, export, package, captures, library saves…) inside the write root; anything else is an
|
||||
ordinary error reply. Paths are made absolute against the app's working directory, links are followed
|
||||
(for a file that doesn't exist yet, its folder's), and the comparison is by whole folder names (`/work2` is
|
||||
not inside `/work`), ignoring case on Windows; `..` below a folder that doesn't exist, a link that leads
|
||||
nowhere and Windows device names are refused. A root left out grants none of its access. The roots must be
|
||||
existing folders, or the app doesn't start; without `--control` they are ignored. See `docs/mcp.md`
|
||||
› Confining file access for the details.
|
||||
|
||||
What is confined: the requests themselves, the frames that carry input they inject (`ui.click`, `ui.key`,
|
||||
`ui.text`…, so a menu item an agent clicks is confined too), the background saves and exports they start,
|
||||
and window captures. The person at the keyboard is not: files opened from the command line, Finder or a
|
||||
file dialog, and everything done with the mouse and keyboard, work as usual. The preferences that move
|
||||
folders the app reads or writes on its own (Additional Fonts Folder, Additional Plug-ins Folder, Data
|
||||
Recovery's folder, the Templates folder) can only be pointed inside the roots by automation. The app's own folders
|
||||
(preferences, Data Recovery, the library folders, VectorCraft's Fonts folder) are not confined, since no
|
||||
agent names a path there. A path is checked, then opened: another program that swaps in a link between
|
||||
the two can still win that race.
|
||||
|
||||
| Method | Params | |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||
@@ -27,8 +55,8 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
|
||||
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
|
||||
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
|
||||
| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
|
||||
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||
| `ui.render` | `{path?, scale?, data?}` | render the artboard headlessly (PNG): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
|
||||
| `ui.resize` / `ui.focus` | | |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
|
||||
|
||||
+52
-1
@@ -49,6 +49,57 @@ claude mcp add vectorcraft -- "C:\Program Files\VectorCraft\vectorcraft-cli.exe"
|
||||
claude mcp add vectorcraft -- vectorcraft-cli mcp
|
||||
```
|
||||
|
||||
## Confining file access
|
||||
|
||||
```sh
|
||||
vectorcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
|
||||
```
|
||||
|
||||
```json
|
||||
{"mcpServers": {"vectorcraft": {"command": "/abs/path/target/release/vectorcraft-cli",
|
||||
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
|
||||
```
|
||||
|
||||
The two flags (PhotoCraft and the other Craft apps take the same ones, so one client config works across
|
||||
the suite) keep the agent to the folders you give it:
|
||||
|
||||
- Every file a command reads must lie inside the **read root**: `open_file`, `file.place`, relinking and
|
||||
updating links, the images an SVG links to, swatch library, graphic style library and colour-profile loads, plug-ins,
|
||||
`file.newFromTemplate`, preset imports, the folders `text.findFontFiles` searches.
|
||||
- Every file a command writes must lie inside the **write root**: `save_file`, `export`, the save and
|
||||
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
|
||||
library and preset saves to a path.
|
||||
- Read and write access are separate. Give the same folder twice for both; a root left out grants none
|
||||
of its access (with only `--automation-read-root`, nothing can be written to disk, though `export` and
|
||||
the save commands without a path still return the bytes). Without either flag nothing changes.
|
||||
- A path is made absolute against the server's working directory, then its links are followed: a file
|
||||
(or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, its nearest
|
||||
existing folder's links are followed and the rest must be plain names, so `..` below a folder that
|
||||
doesn't exist is refused, as is a link that leads nowhere (writing through it would create its target).
|
||||
The result is compared with the root by whole folder names: `/work2/x` is not inside `/work`. On
|
||||
Windows case is ignored, UNC paths (`\\server\share\…`) are resolved like drive-letter ones, and
|
||||
device names (`CON`, `NUL`, `COM1`…) are refused.
|
||||
- A refused path is an ordinary tool error the agent can read and correct, for example
|
||||
`automation path rejected: outside the write root /work/project/out: /etc/x.png` or
|
||||
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.png`.
|
||||
Links in an opened document that point outside the read root show as missing.
|
||||
- The preferences that move folders the app reads or writes on its own (`fontsFolder`,
|
||||
`pluginsFolder`, `recoveryFolder`, `templatesFolder`) can only be set inside the roots. The app's own folders
|
||||
(preferences, Data Recovery, the User Defined library folders, VectorCraft's Fonts folder) are not
|
||||
confined: no agent names a path there.
|
||||
- The roots must be existing folders; a missing one stops the server with an error. Each flag is
|
||||
given once (`--flag <dir>` or `--flag=<dir>`).
|
||||
|
||||
The flags confine the headless server and imply `--headless`; `--connect` with them is an error, since
|
||||
the server would otherwise talk to an app that isn't confined. A running app confines itself: start it
|
||||
with the same flags, `vectorcraft --control 7979 --automation-read-root <dir> --automation-write-root
|
||||
<dir>`, and connect as usual (see [Control protocol](control-protocol.md#confining-file-access)). The
|
||||
server then writes nothing itself: `screenshot {path}` asks the app to write the file.
|
||||
|
||||
The checks hold for every thread of the server process. A path is checked, then opened: another program
|
||||
that swaps a link in between can still win that race, so the roots keep an agent to the files it was
|
||||
given rather than guarding against other software on the computer.
|
||||
|
||||
## Protocol
|
||||
|
||||
Newline-delimited JSON-RPC 2.0 on stdio. The revision is **`2025-06-18`**; `2025-03-26` and `2024-11-05` are
|
||||
@@ -223,7 +274,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `type_text` | `{text}` | Remote only. |
|
||||
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
|
||||
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
|
||||
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). |
|
||||
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). With `path` the backend (the headless session or the app) writes the PNG there too. |
|
||||
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. |
|
||||
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
|
||||
Reference in new issue
Block a user