//! `vectorcraft-cli mcp --automation-read-root --automation-write-root ` (#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#""#; /// 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 = 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()); }