mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
173 lines
9.0 KiB
Rust
173 lines
9.0 KiB
Rust
//! `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 (read, write): (Vec<&Path>, Vec<&Path>) =
|
||
(read.then_some(in_dir.as_path()).into_iter().collect(), write.then_some(out_dir.as_path()).into_iter().collect());
|
||
let roots = AutomationRoots::new(&read, &write).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",
|
||
);
|
||
// Data Recovery lists and restores what is in its folder too: write access alone isn't enough.
|
||
refused(
|
||
&mut server,
|
||
"run_command",
|
||
json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("out"))}}),
|
||
"outside the read root",
|
||
);
|
||
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());
|
||
}
|
||
|
||
/// The Place tool keeps its queue until a click, which no scope confines: a queued file outside the
|
||
/// read root is refused when the queue is set (whichever tool is active when it is).
|
||
#[test]
|
||
fn a_place_queue_names_only_files_inside_the_read_root() {
|
||
let (mut server, base) = confined("place-queue", true, true);
|
||
let queue = |file: &str| json!({"key": "queue", "value": {"entries": [{"params": {"path": s(&base.join(file))}, "name": "x", "width": 10, "height": 10}]}});
|
||
refused(&mut server, "run_command", json!({"command": "tool.setOption", "params": queue("outside/b.svg")}), "outside the read root");
|
||
}
|