mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
MCP: confine the headless session to the automation roots
This commit is contained in:
@@ -13,6 +13,12 @@ pub trait Backend {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
||||
/// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work).
|
||||
fn has_ui(&self) -> bool;
|
||||
/// Write `bytes` to the file at `path` for a tool that was asked to keep a file (the PNG of
|
||||
/// `render_page`). A backend that confines file access (see [`crate::Headless`]) applies it
|
||||
/// here too.
|
||||
fn write_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
designcraft_engine::io::write_file(path, bytes)
|
||||
}
|
||||
/// Short human description ("headless", "connected to 127.0.0.1:7979").
|
||||
fn describe(&self) -> String;
|
||||
}
|
||||
|
||||
+33
-11
@@ -1,5 +1,8 @@
|
||||
//! An in-process engine session that answers the engine-level control-channel methods itself.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_engine::file_access::{self, AutomationRoots};
|
||||
use designcraft_engine::{Session, ViewInfo};
|
||||
use designcraft_geom::Point;
|
||||
use designcraft_render::{RenderOptions, Renderer};
|
||||
@@ -12,6 +15,7 @@ use crate::backend::Backend;
|
||||
pub struct Headless {
|
||||
pub session: Session,
|
||||
pub renderer: Renderer,
|
||||
roots: Option<Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
@@ -82,7 +86,14 @@ fn mods_from(p: &Value) -> Mods {
|
||||
impl Headless {
|
||||
/// A session with no document (the first `file.new` / `file.open` creates one).
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new(), renderer: Renderer::new() }
|
||||
Self { session: Session::new(), renderer: Renderer::new(), roots: None }
|
||||
}
|
||||
|
||||
/// Confine every file the session's commands read and write to `roots`
|
||||
/// (`designcraft-cli mcp --automation-read-root … --automation-write-root …`).
|
||||
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 lay out.
|
||||
@@ -201,7 +212,7 @@ impl Headless {
|
||||
let png = img.to_png();
|
||||
match s(p, "path") {
|
||||
Some(path) => {
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::io::write_file(path, &png)?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})),
|
||||
@@ -221,13 +232,32 @@ impl Headless {
|
||||
.ok_or_else(|| format!("no page {page}"))?;
|
||||
let lower = path.to_ascii_lowercase();
|
||||
let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::io::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let roots = self.roots.clone();
|
||||
file_access::confine(roots.as_ref(), || self.answer(method, params))
|
||||
}
|
||||
|
||||
fn write_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
file_access::confine(self.roots.as_ref(), || designcraft_engine::io::write_file(path, bytes))
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"headless (in-process engine, no window)".into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let p = ¶ms;
|
||||
match method {
|
||||
"engine.execute" | "ui.menu.invoke" | "command" => {
|
||||
@@ -254,14 +284,6 @@ impl Backend for Headless {
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"headless (in-process engine, no window)".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// A supplied page must be a representable unsigned index; only omission selects page zero.
|
||||
|
||||
@@ -479,7 +479,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let path = a.get("path").and_then(Value::as_str);
|
||||
if let Some(path) = path {
|
||||
let png = designcraft_engine::cmd::base64_decode(&b64);
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
b.write_file(path, &png)?;
|
||||
}
|
||||
Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path})))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`: through the MCP
|
||||
//! protocol, every file a command reads comes from inside the read root and every file it writes
|
||||
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
|
||||
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
|
||||
//! is written outside.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use designcraft_engine::file_access::AutomationRoots;
|
||||
use designcraft_mcp::{Headless, Server};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// What the secret files hold, so a test can look for it in everything the agent is told.
|
||||
const SECRET: &str = "TOP SECRET";
|
||||
|
||||
/// A 1x1 PNG, so `place_image` has something real to read.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
/// A `.designcraft` file of `pages` pages with `text` in a frame on the first, made outside any roots.
|
||||
fn designcraft_file(path: &Path, pages: u32, text: &str) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": pages})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 100], "content": "text", "text": text})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
}
|
||||
|
||||
/// A fresh folder holding `in` (the read root), `out` (the write root), `outside` and `in2` (a
|
||||
/// sibling whose name starts with `in`), and a server confined to `in` and `out` (either may be
|
||||
/// left out). `in` and `outside` each hold the same kinds of files, those outside carrying
|
||||
/// [`SECRET`]. The server starts with a new one-page document.
|
||||
fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
|
||||
let base = std::env::temp_dir().join(format!("dc-mcp-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
for (dir, text) in [("in", "Inside text"), ("outside", SECRET), ("in2", "Sibling")] {
|
||||
let dir = base.join(dir);
|
||||
designcraft_file(&dir.join("doc.designcraft"), if dir.ends_with("in") { 1 } else { 3 }, text);
|
||||
std::fs::write(dir.join("pic.png"), PNG).unwrap();
|
||||
std::fs::write(dir.join("data.csv"), format!("name,city\n{text},Oslo\n")).unwrap();
|
||||
std::fs::write(dir.join("swatches.ase"), b"not a swatch file").unwrap();
|
||||
std::fs::write(dir.join("profile.icc"), b"not a profile").unwrap();
|
||||
std::fs::write(dir.join("lib.json"), format!("{{\"items\": [], \"note\": \"{text}\"}}")).unwrap();
|
||||
std::fs::write(dir.join("shape.dtd"), format!("<!ELEMENT {} (#PCDATA)>", text.replace(' ', "_"))).unwrap();
|
||||
std::fs::write(dir.join("tags.xml"), format!("<root><p>{text}</p></root>")).unwrap();
|
||||
}
|
||||
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
|
||||
ok(&mut server, "new_document", json!({}));
|
||||
(server, base)
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `tools/call` -> (isError, every 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().filter_map(|c| c["text"].as_str()).collect::<Vec<_>>().join("\n");
|
||||
(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
|
||||
}
|
||||
|
||||
/// A command through the `execute` tool.
|
||||
fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) {
|
||||
call(server, "execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
fn run_ok(server: &mut Server, command: &str, params: Value) -> Value {
|
||||
let (error, text) = run(server, command, params.clone());
|
||||
assert!(!error, "{command} {params}: {text}");
|
||||
serde_json::from_str(&text).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// `command` is refused with `why` in its message, and the message says nothing of the file.
|
||||
fn refused(server: &mut Server, command: &str, params: Value, why: &str) {
|
||||
let (error, text) = run(server, command, params.clone());
|
||||
assert!(error && text.contains(why), "{command} {params} should be refused ({why}): {text}");
|
||||
assert!(!text.contains(SECRET), "{command}: {text}");
|
||||
}
|
||||
|
||||
/// The open document's page count, which tells the three-page files outside from the one-page one.
|
||||
fn pages(server: &mut Server) -> usize {
|
||||
let doc: Value = serde_json::from_str(&ok(server, "inspect_document", json!({}))).unwrap();
|
||||
doc["pageCount"].as_u64().unwrap() as usize
|
||||
}
|
||||
|
||||
fn outside_names(base: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_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_document", json!({"path": s(&base.join("in/doc.designcraft"))}));
|
||||
assert_eq!(pages(&mut server), 1, "the file inside the root is the open document");
|
||||
|
||||
let escape = format!("{}{sep}..{sep}outside{sep}doc.designcraft", s(&base.join("in")));
|
||||
let paths = [s(&base.join("outside/doc.designcraft")), escape, s(&base.join("in2/doc.designcraft")), s(&base.join("out/new.designcraft"))];
|
||||
for path in paths {
|
||||
let (error, text) = call(&mut server, "open_document", json!({"path": path}));
|
||||
assert!(error && text.contains("outside the read root"), "{path}: {text}");
|
||||
assert!(!text.contains(SECRET), "{text}");
|
||||
refused(&mut server, "file.open", json!({"path": path}), "outside the read root");
|
||||
refused(&mut server, "file.openIdml", json!({"path": path}), "outside the read root");
|
||||
}
|
||||
// Nothing from the refused files reached the session: the open document is still the one it was.
|
||||
assert_eq!(pages(&mut server), 1);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_that_reads_a_path_is_refused_outside_the_read_root() {
|
||||
let (mut server, base) = confined("reads", true, true);
|
||||
let outside = |name: &str| s(&base.join("outside").join(name));
|
||||
let inside = |name: &str| s(&base.join("in").join(name));
|
||||
|
||||
// A placed image to relink and a data source to update, both from inside the root.
|
||||
ok(&mut server, "place_image", json!({"path": inside("pic.png"), "x": 36, "y": 36, "width": 72}));
|
||||
let links = run_ok(&mut server, "links.list", json!({}));
|
||||
let asset = links[0]["asset"].clone();
|
||||
let fields = run_ok(&mut server, "data.source.select", json!({"path": inside("data.csv")}));
|
||||
assert!(fields["fields"].to_string().contains("city"), "{fields}");
|
||||
let before = ok(&mut server, "inspect_document", json!({}));
|
||||
|
||||
let reads: Vec<(&str, Value)> = vec![
|
||||
("file.place", json!({"path": outside("pic.png")})),
|
||||
("place.load", json!({"path": outside("pic.png")})),
|
||||
("place.styles", json!({"path": outside("tags.xml")})),
|
||||
("links.relink", json!({"asset": asset, "path": outside("pic.png")})),
|
||||
("links.relinkFolder", json!({"dir": s(&base.join("outside"))})),
|
||||
("media.options", json!({"poster": {"path": outside("pic.png")}})),
|
||||
("color.loadProfile", json!({"path": outside("profile.icc")})),
|
||||
("swatch.load", json!({"path": outside("swatches.ase")})),
|
||||
("library.open", json!({"path": outside("lib.json")})),
|
||||
("book.open", json!({"path": outside("lib.json")})),
|
||||
("data.source.select", json!({"path": outside("data.csv")})),
|
||||
("data.merge", json!({"path": outside("data.csv")})),
|
||||
("xml.loadDtd", json!({"path": outside("shape.dtd")})),
|
||||
("file.importXml", json!({"path": outside("tags.xml")})),
|
||||
("file.recovery.list", json!({"dir": s(&base.join("outside"))})),
|
||||
("file.recovery.open", json!({"dir": s(&base.join("outside"))})),
|
||||
];
|
||||
for (command, params) in reads {
|
||||
refused(&mut server, command, params, "outside the read root");
|
||||
}
|
||||
let (error, text) = call(&mut server, "place_image", json!({"path": outside("pic.png")}));
|
||||
assert!(error && text.contains("outside the read root") && !text.contains(SECRET), "{text}");
|
||||
// The control-channel method behind `open_document`.
|
||||
let (error, text) = call(&mut server, "execute", json!({"command": "app.open", "params": {"path": outside("doc.designcraft")}}));
|
||||
assert!(error && !text.contains(SECRET), "app.open: {text}");
|
||||
assert_eq!(ok(&mut server, "inspect_document", json!({})), before, "the refused reads changed nothing");
|
||||
assert_eq!(run_ok(&mut server, "data.source.select", json!({"path": inside("data.csv")}))["records"], 1);
|
||||
|
||||
// Inside the root the same commands get as far as reading the file, so the table above is not
|
||||
// refusing for some other reason: a file that isn't a profile or swatch file is reported as such.
|
||||
for (command, params) in [("color.loadProfile", json!({"path": inside("profile.icc")})), ("swatch.load", json!({"path": inside("swatches.ase")}))]
|
||||
{
|
||||
let (error, text) = run(&mut server, command, params);
|
||||
assert!(error && !text.contains("automation"), "{command}: {text}");
|
||||
}
|
||||
run_ok(&mut server, "xml.loadDtd", json!({"path": inside("shape.dtd")}));
|
||||
run_ok(&mut server, "file.importXml", json!({"path": inside("tags.xml")}));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_named_inside_a_document_are_read_through_the_roots_too() {
|
||||
let (mut server, base) = confined("stored", true, true);
|
||||
// A document in the read root whose picture is linked to a file outside it, and whose data
|
||||
// source is a file outside it, made by someone without roots.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
std::fs::write(base.join("outside/linked.png"), PNG).unwrap();
|
||||
s.execute("file.place", &json!({"path": self::s(&base.join("outside/linked.png")), "x": 36, "y": 36, "width": 72})).unwrap();
|
||||
s.execute("data.source.select", &json!({"path": self::s(&base.join("outside/data.csv"))})).unwrap();
|
||||
s.execute("file.save", &json!({"path": self::s(&base.join("in/linked.designcraft"))})).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
|
||||
ok(&mut server, "open_document", json!({"path": self::s(&base.join("in/linked.designcraft"))}));
|
||||
let links = run_ok(&mut server, "links.list", json!({}));
|
||||
assert_eq!(links[0]["status"], "missing", "a link outside the read root reads as a file that isn't there: {links}");
|
||||
refused(&mut server, "data.source.update", json!({}), "outside the read root");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_writing_command_is_refused_outside_the_write_root() {
|
||||
let (mut server, base) = confined("write", true, true);
|
||||
let outside = |name: &str| s(&base.join("outside").join(name));
|
||||
let inside = |name: &str| s(&base.join("out").join(name));
|
||||
let fixtures = outside_names(&base);
|
||||
// Something to export: a picture to package and a text frame, selected so `file.exportText`
|
||||
// and `snippet.export` have their input.
|
||||
ok(&mut server, "place_image", json!({"path": s(&base.join("in/pic.png")), "x": 36, "y": 200, "width": 72}));
|
||||
run_ok(&mut server, "frame.create", json!({"rect": [36, 300, 300, 100], "content": "text", "text": "Selected"}));
|
||||
run_ok(&mut server, "select.firstAbove", json!({}));
|
||||
|
||||
// Every command that writes a file, with the path parameter it takes.
|
||||
let writes: Vec<(&str, &str)> = vec![
|
||||
("file.save", "path"),
|
||||
("file.saveAs", "path"),
|
||||
("file.saveACopy", "path"),
|
||||
("file.exportPdf", "path"),
|
||||
("file.exportEpub", "path"),
|
||||
("file.exportFixedEpub", "path"),
|
||||
("file.exportHtml", "path"),
|
||||
("file.exportText", "path"),
|
||||
("file.exportXml", "path"),
|
||||
("file.exportIdml", "path"),
|
||||
("file.printBooklet", "path"),
|
||||
("snippet.export", "path"),
|
||||
("swatch.save", "path"),
|
||||
("library.new", "path"),
|
||||
("book.exportPdf", "path"),
|
||||
("book.new", "path"),
|
||||
("file.package", "dir"),
|
||||
("links.copyTo", "dir"),
|
||||
("file.recovery.save", "dir"),
|
||||
];
|
||||
// A book with a chapter to export, so `book.exportPdf` gets as far as its path.
|
||||
run_ok(&mut server, "book.new", json!({"path": inside("first.dcbook")}));
|
||||
run_ok(&mut server, "book.add", json!({"path": s(&base.join("in/doc.designcraft"))}));
|
||||
for (command, key) in &writes {
|
||||
let target = if *key == "dir" { outside("folder") } else { outside(&format!("out-{command}")) };
|
||||
refused(&mut server, command, json!({*key: target}), "outside the write root");
|
||||
}
|
||||
for tool in ["save_document", "export_png", "render_page"] {
|
||||
let (error, text) = call(&mut server, tool, json!({"path": outside("tool.out")}));
|
||||
assert!(error && text.contains("outside the write root"), "{tool}: {text}");
|
||||
}
|
||||
// The control-channel methods behind `save_document` and `export_png`.
|
||||
for method in ["app.save", "app.export"] {
|
||||
let (error, text) = call(&mut server, "execute", json!({"command": method, "params": {"path": outside("app.out")}}));
|
||||
assert!(error, "{method}: {text}");
|
||||
}
|
||||
// A sibling folder with the same prefix, `..` out of the root, and the read root.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
for path in [
|
||||
s(&base.join("out2/x.designcraft")),
|
||||
format!("{}{sep}..{sep}outside{sep}x.designcraft", s(&base.join("out"))),
|
||||
s(&base.join("in/x.designcraft")),
|
||||
] {
|
||||
let (error, text) = call(&mut server, "save_document", json!({"path": path}));
|
||||
assert!(error && text.contains("outside the write root"), "{path}: {text}");
|
||||
}
|
||||
assert_eq!(outside_names(&base), fixtures, "nothing was written outside beside the files made at the start");
|
||||
assert!(!base.join("out2").exists() && !base.join("in/x.designcraft").exists());
|
||||
|
||||
// Inside the write root every one of them works, and the files appear.
|
||||
for (command, key) in &writes {
|
||||
let target = if *key == "dir" { inside(&format!("dir-{command}")) } else { inside(&format!("out-{command}")) };
|
||||
run_ok(&mut server, command, json!({*key: target}));
|
||||
}
|
||||
for tool in ["export_png", "render_page"] {
|
||||
ok(&mut server, tool, json!({"path": inside(&format!("{tool}.png"))}));
|
||||
assert!(base.join("out").join(format!("{tool}.png")).exists(), "{tool}");
|
||||
}
|
||||
for file in ["out-file.save", "out-file.exportPdf", "out-file.exportIdml", "out-book.exportPdf", "out-swatch.save", "out-snippet.export"] {
|
||||
assert!(base.join("out").join(file).exists(), "{file}");
|
||||
}
|
||||
assert!(base.join("out/dir-file.package/Instructions.txt").exists() && base.join("out/dir-links.copyTo/pic.png").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_root_grants_only_its_own_access() {
|
||||
let (mut read_only, base) = confined("read-only", true, false);
|
||||
ok(&mut read_only, "open_document", json!({"path": s(&base.join("in/doc.designcraft"))}));
|
||||
run_ok(&mut read_only, "select.firstAbove", json!({}));
|
||||
for (command, key) in [("file.save", "path"), ("file.exportPdf", "path"), ("file.package", "dir"), ("file.exportText", "path")] {
|
||||
refused(&mut read_only, command, json!({key: s(&base.join("out/x"))}), "write authority is absent");
|
||||
}
|
||||
for tool in ["save_document", "export_png", "render_page"] {
|
||||
let (error, text) = call(&mut read_only, tool, json!({"path": s(&base.join("out/x.png"))}));
|
||||
assert!(error && text.contains("write authority is absent"), "{tool}: {text}");
|
||||
}
|
||||
// Calls that return bytes instead of writing a path keep working.
|
||||
ok(&mut read_only, "render_page", json!({"scale": 0.25}));
|
||||
run_ok(&mut read_only, "file.exportPdf", json!({}));
|
||||
assert!(std::fs::read_dir(base.join("out")).unwrap().next().is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
|
||||
let (mut write_only, base) = confined("write-only", false, true);
|
||||
for (command, key) in
|
||||
[("file.open", "path"), ("file.place", "path"), ("swatch.load", "path"), ("data.source.select", "path"), ("file.recovery.list", "dir")]
|
||||
{
|
||||
refused(&mut write_only, command, json!({key: s(&base.join("in/doc.designcraft"))}), "read authority is absent");
|
||||
}
|
||||
let (error, text) = call(&mut write_only, "place_image", json!({"path": s(&base.join("in/pic.png"))}));
|
||||
assert!(error && text.contains("read authority is absent"), "{text}");
|
||||
ok(&mut write_only, "save_document", json!({"path": s(&base.join("out/x.designcraft"))}));
|
||||
assert!(base.join("out/x.designcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_a_root_are_refused() {
|
||||
let (mut server, base) = confined("links", true, true);
|
||||
std::os::unix::fs::symlink(base.join("outside/doc.designcraft"), base.join("in/leak.designcraft")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside/data.csv"), base.join("in/leak.csv")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside/gone.designcraft"), base.join("out/dangling.designcraft")).unwrap();
|
||||
refused(&mut server, "file.open", json!({"path": s(&base.join("in/leak.designcraft"))}), "outside the read root");
|
||||
refused(&mut server, "data.source.select", json!({"path": s(&base.join("in/leak.csv"))}), "outside the read root");
|
||||
refused(&mut server, "file.saveAs", json!({"path": s(&base.join("out/away/x.designcraft"))}), "outside the write root");
|
||||
refused(&mut server, "file.exportPdf", json!({"path": s(&base.join("out/away/x.pdf"))}), "outside the write root");
|
||||
refused(&mut server, "file.saveAs", json!({"path": s(&base.join("out/dangling.designcraft"))}), "a link that leads nowhere");
|
||||
assert!(!base.join("outside/x.designcraft").exists() && !base.join("outside/x.pdf").exists() && !base.join("outside/gone.designcraft").exists());
|
||||
assert_eq!(pages(&mut server), 1, "nothing from the file the link led to reached the session");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_roots_nothing_is_confined() {
|
||||
let base = std::env::temp_dir().join(format!("dc-mcp-roots-none-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
designcraft_file(&base.join("a.designcraft"), 3, "Anywhere");
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
|
||||
ok(&mut server, "open_document", json!({"path": s(&base.join("a.designcraft"))}));
|
||||
assert_eq!(pages(&mut server), 3);
|
||||
ok(&mut server, "save_document", json!({"path": s(&base.join("b.designcraft"))}));
|
||||
ok(&mut server, "export_png", json!({"path": s(&base.join("b.png"))}));
|
||||
assert!(base.join("b.designcraft").exists() && base.join("b.png").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
Reference in New Issue
Block a user