//! `deckcraft-cli mcp --automation-read-root
--automation-write-root `: through the MCP
//! protocol, files open and load only from inside the read root and are saved and exported only
//! inside the write root. Paths that leave a root (`..`, another folder, a sibling with the same
//! prefix, a link) are refused, and nothing is written outside.
use std::path::{Path, PathBuf};
use deckcraft_engine::file_access::AutomationRoots;
use deckcraft_mcp::{Headless, Server};
use serde_json::{Value, json};
/// A 1x1 PNG, so `insert_picture` 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 fresh folder holding `in` (the read root, with `a.txt` and `pic.png`), `out` (the write root),
/// `outside` (with `secret.txt` and `pic.png`) and `in2`, and a server confined to `in` and `out`
/// (either may be left out). The server starts with a new presentation.
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();
}
std::fs::write(base.join("in/a.txt"), "Inside text").unwrap();
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
std::fs::write(base.join("outside/secret.txt"), "TOP SECRET").unwrap();
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
std::fs::write(base.join("in2/c.txt"), "Sibling").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_presentation", json!({}));
(server, 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}");
}
fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) {
call(server, "run_command", json!({"command": command, "params": params}))
}
/// Everything the open presentation says, as one string.
fn deck_text(server: &mut Server) -> String {
format!("{} {}", ok(server, "inspect_document", json!({})), ok(server, "inspect_slide", json!({})))
}
#[test]
fn presentations_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_presentation", json!({"path": s(&base.join("in/a.txt"))}));
assert!(deck_text(&mut server).contains("Inside text"));
refused(&mut server, "open_presentation", json!({"path": s(&base.join("outside/secret.txt"))}), "outside the read root");
refused(
&mut server,
"open_presentation",
json!({"path": format!("{}{sep}..{sep}outside{sep}secret.txt", s(&base.join("in")))}),
"outside the read root",
);
refused(&mut server, "open_presentation", json!({"path": s(&base.join("in2/c.txt"))}), "outside the read root");
// The write root isn't readable: the two kinds of access are separate.
std::fs::write(base.join("out/d.txt"), "d").unwrap();
refused(&mut server, "open_presentation", json!({"path": s(&base.join("out/d.txt"))}), "outside the read root");
// The same through the engine command and the control-channel method.
let (error, text) = run(&mut server, "file.open", json!({"path": s(&base.join("outside/secret.txt"))}));
assert!(error && text.contains("outside the read root"), "{text}");
refused(&mut server, "call_app", json!({"method": "app.open", "params": {"path": s(&base.join("outside/secret.txt"))}}), "outside the read root");
// Nothing from the refused files reached the deck.
let text = deck_text(&mut server);
assert!(!text.contains("TOP SECRET") && !text.contains("Sibling"), "{text}");
assert!(text.contains("Inside text"), "the refused opens left the deck it had: {text}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn pictures_media_and_other_path_reads_are_confined() {
let (mut server, base) = confined("media", true, true);
ok(&mut server, "insert_picture", json!({"path": s(&base.join("in/pic.png"))}));
let before = ok(&mut server, "inspect_slide", json!({}));
refused(&mut server, "insert_picture", json!({"path": s(&base.join("outside/pic.png"))}), "outside the read root");
for command in ["insert.audio", "insert.video", "insert.picture", "picture.change"] {
let (error, text) = run(&mut server, command, json!({"path": s(&base.join("outside/pic.png"))}));
assert!(error && text.contains("outside the read root"), "{command}: {text}");
}
assert_eq!(ok(&mut server, "inspect_slide", json!({})), before, "refused reads change nothing");
// Media inside the root gets as far as being read: a refusal there would name the root.
for command in ["insert.audio", "insert.video"] {
let (_, text) = run(&mut server, command, json!({"path": s(&base.join("in/pic.png"))}));
assert!(!text.contains("automation"), "{command}: {text}");
}
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));
ok(&mut server, "add_slide", json!({"title": "Second"}));
refused(&mut server, "save_presentation", json!({"path": outside("x.deckcraft")}), "outside the write root");
refused(&mut server, "export", json!({"path": outside("x.pptx")}), "outside the write root");
refused(&mut server, "export", json!({"path": outside("x.pdf")}), "outside the write root");
refused(&mut server, "export", json!({"path": outside("x.png")}), "outside the write root");
refused(&mut server, "export", json!({"path": outside("x.txt")}), "outside the write root");
refused(&mut server, "export", json!({"path": outside("all.png"), "all": true}), "outside the write root");
refused(&mut server, "call_app", json!({"method": "ui.render", "params": {"path": outside("r.png")}}), "outside the write root");
refused(&mut server, "call_app", json!({"method": "app.save", "params": {"path": outside("y.deckcraft")}}), "outside the write root");
refused(&mut server, "call_app", json!({"method": "app.export", "params": {"path": outside("y.pdf")}}), "outside the write root");
for command in ["file.save", "file.saveAs", "file.export"] {
let (error, text) = run(&mut server, command, json!({"path": outside("z.deckcraft")}));
assert!(error && text.contains("outside the write root"), "{command}: {text}");
}
// A sibling folder with the same prefix, and `..` out of the root.
refused(&mut server, "save_presentation", json!({"path": s(&base.join("out2/x.deckcraft"))}), "outside the write root");
let sep = std::path::MAIN_SEPARATOR;
refused(
&mut server,
"save_presentation",
json!({"path": format!("{}{sep}..{sep}outside{sep}x.deckcraft", s(&base.join("out")))}),
"outside the write root",
);
// The read root isn't writable.
refused(&mut server, "save_presentation", json!({"path": s(&base.join("in/x.deckcraft"))}), "outside the write root");
let leftovers: Vec<_> = std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(leftovers.len(), 2, "nothing was written beside secret.txt and pic.png: {leftovers:?}");
assert!(!base.join("out2").exists() && !base.join("in/x.deckcraft").exists());
// Inside the write root all of it works.
ok(&mut server, "save_presentation", json!({"path": inside("x.deckcraft")}));
ok(&mut server, "export", json!({"path": inside("x.pptx")}));
ok(&mut server, "export", json!({"path": inside("x.pdf")}));
ok(&mut server, "export", json!({"path": inside("all.png"), "all": true}));
ok(&mut server, "call_app", json!({"method": "ui.render", "params": {"path": inside("r.png")}}));
for f in ["x.deckcraft", "x.pptx", "x.pdf", "all1.png", "all2.png", "r.png"] {
assert!(base.join("out").join(f).exists(), "{f}");
}
let temporaries: Vec<_> = std::fs::read_dir(base.join("out"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".saving"))
.collect();
assert!(temporaries.is_empty(), "the temporary file is renamed away");
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_presentation", json!({"path": s(&base.join("in/a.txt"))}));
refused(&mut read_only, "save_presentation", json!({"path": s(&base.join("out/x.deckcraft"))}), "write authority is absent");
refused(&mut read_only, "export", json!({"path": s(&base.join("out/x.pdf"))}), "write authority is absent");
refused(&mut read_only, "call_app", json!({"method": "ui.render", "params": {"path": s(&base.join("out/r.png"))}}), "write authority is absent");
// Calls that return bytes instead of writing a path keep working.
let (error, _) = call(&mut read_only, "render_slide", json!({"scale": 0.25}));
assert!(!error);
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);
refused(&mut write_only, "open_presentation", json!({"path": s(&base.join("in/a.txt"))}), "read authority is absent");
refused(&mut write_only, "insert_picture", json!({"path": s(&base.join("in/pic.png"))}), "read authority is absent");
ok(&mut write_only, "save_presentation", json!({"path": s(&base.join("out/x.deckcraft"))}));
assert!(base.join("out/x.deckcraft").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/secret.txt"), base.join("in/leak.txt")).unwrap();
std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).unwrap();
std::os::unix::fs::symlink(base.join("outside/gone.deckcraft"), base.join("out/dangling.deckcraft")).unwrap();
refused(&mut server, "open_presentation", json!({"path": s(&base.join("in/leak.txt"))}), "outside the read root");
refused(&mut server, "save_presentation", json!({"path": s(&base.join("out/away/x.deckcraft"))}), "outside the write root");
refused(&mut server, "save_presentation", json!({"path": s(&base.join("out/dangling.deckcraft"))}), "a link that leads nowhere");
assert!(!base.join("outside/x.deckcraft").exists() && !base.join("outside/gone.deckcraft").exists());
assert!(!deck_text(&mut server).contains("TOP SECRET"));
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();
std::fs::write(base.join("a.txt"), "Anywhere").unwrap();
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
ok(&mut server, "open_presentation", json!({"path": s(&base.join("a.txt"))}));
ok(&mut server, "save_presentation", json!({"path": s(&base.join("b.deckcraft"))}));
assert!(base.join("b.deckcraft").exists());
let _ = std::fs::remove_dir_all(base);
}