This commit is contained in:
Brandon Thomas
2026-10-14 22:05:10 -04:00
35 changed files with 1907 additions and 146 deletions
@@ -0,0 +1,90 @@
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
//! flag must not silently remove a root and leave the server unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
let mut args = ["a folder".to_owned(), "doc.designcraft".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--sample", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("doc.designcraft"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}
+32 -7
View File
@@ -9,7 +9,8 @@
//! # run a command script (crates/engine/src/script.rs): `$N.path` references
//! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT)
//! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …)
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]
//! # MCP server over stdio (docs/mcp.md); the roots confine the files it reads and writes
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks
@@ -30,13 +31,14 @@ macro_rules! outln {
}};
}
mod automation_args;
mod perf;
use designcraft_engine::Session;
use serde_json::{Value, json};
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
@@ -160,16 +162,31 @@ fn validate_pdf(path: Option<&str>) -> Result<(), String> {
/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app
/// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr.
fn mcp(args: &[String]) -> Result<(), String> {
use designcraft_engine::file_access::{self, AutomationRoots};
use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
let mut connect: Option<String> = None;
let mut sample = false;
let mut it = args.iter();
let mut roots_args = automation_args::AutomationArgs::new(None, None);
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
match a.as_str() {
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
"--sample" => sample = true,
other => return Err(format!("unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample])")),
if roots_args.consume(&a, &mut it)? {
continue;
}
match a.as_str() {
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
"--sample" => sample = true,
other => {
return Err(format!(
"unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR])"
));
}
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
.into());
}
let backend: Box<dyn Backend> = match connect {
Some(c) => {
@@ -181,9 +198,17 @@ fn mcp(args: &[String]) -> Result<(), String> {
}
None => {
let mut h = Headless::with_document();
// `--sample` is the operator's own choice, made before the roots come into force.
if sample {
h.session.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?;
}
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<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("designcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
h = h.with_automation_roots(Some(roots));
}
Box::new(h)
}
};
+121
View File
@@ -169,3 +169,124 @@ fn raster_export_preserves_an_existing_reader_and_private_symlink_target() {
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}
/// Send `calls` (tool name, arguments) to `designcraft-cli mcp <args>`; returns whether it exited
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
fn mcp_session(args: &[&str], calls: &[(&str, serde_json::Value)]) -> (bool, String, Vec<serde_json::Value>) {
let mut child = cli().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
for (i, (name, arguments)) in calls.iter().enumerate() {
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
writeln!(stdin, "{call}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
}
fn text(reply: &serde_json::Value) -> &str {
reply["result"]["content"][0]["text"].as_str().unwrap()
}
/// A `.designcraft` file with one page, made with the CLI itself.
fn make_document(path: &std::path::Path) {
let out = cli().args(["run", "--cmd", "file.new={}", "--cmd"]).arg(format!("file.save={}", serde_json::json!({"path": path}))).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(path.exists());
}
#[test]
fn mcp_automation_roots_confine_the_server() {
let base = std::env::temp_dir().join(format!("dc-cli-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
std::fs::create_dir_all(&work).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
make_document(&work.join("in.designcraft"));
make_document(&elsewhere.join("secret.designcraft"));
let work_s = work.to_str().unwrap();
let secret = elsewhere.join("secret.designcraft").to_str().unwrap().to_string();
// Both spellings of the flags; inside works, outside is refused with a readable message.
let read_flag = format!("--automation-read-root={work_s}");
let calls = [
("open_document", serde_json::json!({"path": work.join("in.designcraft").to_str().unwrap()})),
("open_document", serde_json::json!({"path": secret})),
("save_document", serde_json::json!({"path": work.join("saved.designcraft").to_str().unwrap()})),
("save_document", serde_json::json!({"path": elsewhere.join("saved.designcraft").to_str().unwrap()})),
("export_png", serde_json::json!({"path": elsewhere.join("page.png").to_str().unwrap()})),
("render_page", serde_json::json!({"path": elsewhere.join("render.png").to_str().unwrap()})),
];
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
assert!(success, "{stderr}");
assert!(stderr.contains("files confined"), "{stderr}");
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
assert_eq!(replies[1]["result"]["isError"], true);
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
assert!(work.join("saved.designcraft").exists());
for reply in &replies[3..] {
assert_eq!(reply["result"]["isError"], true, "{reply}");
assert!(text(reply).contains("outside the write root"), "{}", text(reply));
}
for name in ["saved.designcraft", "page.png", "render.png"] {
assert!(!elsewhere.join(name).exists(), "{name} was written outside the write root");
}
// A root left out grants none of its access.
let save = [("new_document", serde_json::json!({})), calls[2].clone()];
let (_, _, replies) = mcp_session(&[&read_flag], &save);
assert_eq!(replies[1]["result"]["isError"], true);
assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1]));
let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[..1]);
assert_eq!(replies[0]["result"]["isError"], true);
assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0]));
// No flags: nothing is confined.
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
assert!(success);
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = std::env::temp_dir().join(format!("dc-cli-roots-args-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let dir = base.to_str().unwrap();
let missing = base.join("missing");
let fails = |args: &[&str], needle: &str| {
let mut full = vec!["mcp"];
full.extend_from_slice(args);
let o = cli().args(&full).output().unwrap();
assert!(!o.status.success(), "{args:?}");
let err = String::from_utf8_lossy(&o.stderr).into_owned();
assert!(err.contains(needle), "{args:?}: {err}");
};
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
fails(&["--automation-read-root", dir, "--automation-read-root=x"], "given twice");
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--sample"], "needs a folder");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root="], "needs a non-empty folder");
// A file is not a folder.
std::fs::write(base.join("f.txt"), "x").unwrap();
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn usage_lists_the_automation_root_flags() {
let out = cli().arg("--help").output().unwrap();
let usage = String::from_utf8_lossy(&out.stdout);
assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}");
}
-7
View File
@@ -230,13 +230,6 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
Ok(info)
}
/// Load a `.icc`/`.icm` file from disk (native only).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> {
let bytes = std::fs::read(path).map_err(|e| CmsError::Io(format!("{}: {e}", path.display())))?;
register_icc(&bytes, None)
}
/// A colour-management context: resolved working spaces plus conversion settings.
#[derive(Clone)]
pub struct Cms {
+2 -4
View File
@@ -188,10 +188,8 @@ fn bad_icc_rejected() {
#[test]
fn loads_system_cmyk_icc() {
let path = std::path::Path::new("/System/Library/ColorSync/Profiles/Generic CMYK Profile.icc");
if !path.exists() {
return;
}
let info = load_icc_file(path).unwrap();
let Ok(bytes) = std::fs::read(path) else { return };
let info = register_icc(&bytes, None).unwrap();
assert_eq!(info.kind, ProfileKind::Cmyk);
assert!(profiles().iter().any(|p| p.name == info.name && !p.builtin));
let c = Cms::new(&ColorSettings { cmyk: info.name.clone(), ..Default::default() }).unwrap();
+2 -2
View File
@@ -3257,7 +3257,7 @@ fn each_document_shapes_words_in_its_own_fonts() {
let folder = root.join(name);
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("word.ttf"), font_with_glyph(FAMILY, &['a', 'b', ' '], glyph).unwrap()).unwrap();
scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope, glyph));
scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope, glyph));
}
let text = "ab ab ab ab";
let mut faces = Vec::new();
@@ -3287,7 +3287,7 @@ fn composite_fonts_draw_from_the_documents_fonts() {
let folder = std::env::temp_dir().join(format!("dc-compose-composite-docfont-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("digits.ttf"), font_with_glyph(FAMILY, &['1'], 'X').unwrap()).unwrap();
let scope = FontDb::global().load_document_fonts(&folder).scope;
let scope = FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope;
let (mut d, sid, _) = doc_with("A1B", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
d.font_scope = scope;
std::sync::Arc::make_mut(&mut d.styles).composite_fonts.push(CompositeFont {
-5
View File
@@ -36,11 +36,6 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_with(path, |f| f.write_all(bytes))
}
/// [`write_atomic`] for a command's `path` parameter: the error names the path.
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> {
write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))
}
/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When
/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and
/// the error is returned.
+10 -16
View File
@@ -28,7 +28,7 @@ fn no_book() -> EngineError {
#[cfg(not(target_arch = "wasm32"))]
fn save(b: &Book) -> Result<()> {
let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?;
crate::atomic::write_file(&b.path, &bytes)
crate::io::write_file(&b.path, &bytes).map_err(EngineError::Other)
}
#[cfg(target_arch = "wasm32")]
@@ -38,14 +38,14 @@ fn save(_: &Book) -> Result<()> {
fn load(path: &str) -> Result<designcraft_doc::Document> {
#[cfg(not(target_arch = "wasm32"))]
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
#[cfg(target_arch = "wasm32")]
let bytes: Vec<u8> = {
let _ = path;
vec![]
};
if path.to_lowercase().ends_with(".idml") {
return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
return super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
}
super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -90,7 +90,7 @@ impl ChapterBatch {
}
fn commit(self) -> Result<()> {
self.commit_with(write_staged_chapter)
self.commit_with(|path, staged| crate::io::replace_with(path, staged))
}
fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> {
@@ -110,12 +110,6 @@ impl ChapterBatch {
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> {
let mut input = std::fs::File::open(staged)?;
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
}
/// Batch commands only write closed native chapters. IDML is an imported source, and an open
/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update.
#[cfg(not(target_arch = "wasm32"))]
@@ -125,16 +119,16 @@ fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()
if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
}
let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) {
let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| crate::io::canonicalize(open).is_ok_and(|open| open == target))) {
return Err(bad(command, format!("chapter {path} is open; close it before updating the book")));
}
if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) {
if !crate::io::is_file(&target) {
return Err(bad(command, format!("chapter {path} is not a regular file")));
}
// Check both the file and its resolved parent without truncating it. Replacement needs a
// writable directory even when an in-place write would be permitted.
std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::io::writable(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?;
tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(())
@@ -248,7 +242,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "book.open", "Open Book", [], None, "{path} → {documents}", always, |s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("book.open", "`path` required"))?.to_string();
#[cfg(not(target_arch = "wasm32"))]
let text = std::fs::read_to_string(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let text = crate::io::read_to_string(&path).map_err(EngineError::Other)?;
#[cfg(target_arch = "wasm32")]
let text = String::new();
let mut b: Book = serde_json::from_str(&text).map_err(|e| bad("book.open", format!("not a book: {e}")))?;
@@ -331,7 +325,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
crate::io::write_file(path, &bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
+2 -2
View File
@@ -1,4 +1,4 @@
use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter};
use super::{ChapterBatch, MAX_BATCH_CHAPTERS};
use crate::Session;
use serde_json::json;
@@ -26,7 +26,7 @@ fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() {
let error = batch
.commit_with(|path, staged| {
calls += 1;
if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) }
if path == second { Err(std::io::Error::other("synthetic disk full")) } else { crate::io::replace_with(path, staged) }
})
.unwrap_err()
.to_string();
+2 -1
View File
@@ -45,7 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
let path = str_param(p, "path").ok_or_else(|| bad("color.loadProfile", "`path` required"))?;
#[cfg(not(target_arch = "wasm32"))]
{
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad("color.loadProfile", e.to_string()))?;
let bytes = crate::io::read_file(path).map_err(|e| bad("color.loadProfile", e))?;
let info = cms::register_icc(&bytes, None).map_err(|e| bad("color.loadProfile", e.to_string()))?;
Ok(json!(info))
}
#[cfg(target_arch = "wasm32")]
+2 -2
View File
@@ -340,11 +340,11 @@ fn place_image(
let cands = image_candidates(cell, data_dir, doc_dir);
let found = cands.iter().find_map(|p| {
// Refuse huge files before reading them; a too-large image counts as missing.
let len = std::fs::metadata(p).ok()?.len();
let len = crate::io::metadata(p).ok()?.len();
if len == 0 || len > super::MAX_IMAGE_FILE_BYTES {
return None;
}
std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))
crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))
});
let Some((path, bytes)) = found else {
if let Some(it) = doc.item_mut(item) {
+6 -4
View File
@@ -619,7 +619,7 @@ pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) {
/// again as `relative_path` beside the document.
pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) {
for src in &mut doc.data_merge.sources {
let abs_exists = src.path.as_deref().is_some_and(|p| Path::new(p).is_file());
let abs_exists = src.path.as_deref().is_some_and(crate::io::is_file);
if abs_exists {
if let Some(path) = src.path.clone() {
src.status = status_of(Path::new(&path), src.fingerprint);
@@ -630,7 +630,7 @@ pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path
&& let Some(dir) = doc_path.parent()
{
let candidate = dir.join(&rel);
if candidate.is_file() {
if crate::io::is_file(&candidate) {
let saved = candidate.to_string_lossy().into_owned();
src.fingerprint = fingerprint_of(Path::new(&saved));
src.status = SourceStatus::Ok;
@@ -768,12 +768,14 @@ fn read_path(path: &str) -> std::result::Result<Vec<u8>, String> {
}
fn read_path_capped(path: &str, cap: u64) -> std::result::Result<Vec<u8>, String> {
match std::fs::metadata(path) {
match crate::io::metadata(path) {
Ok(m) if m.len() > cap => return Err(format!("{path}: the data file is larger than {} MiB", cap / (1024 * 1024))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the data file is missing")),
_ => {}
}
match std::fs::read(path) {
// The roots' own message when they refuse the path.
crate::file_access::check_read(path)?;
match crate::io::read(path) {
Ok(b) => Ok(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")),
Err(e) => Err(format!("{path}: {e}")),
+1 -1
View File
@@ -330,7 +330,7 @@ pub fn relative_between(from_dir: &Path, target: &Path) -> String {
}
pub fn fingerprint_of(path: &Path) -> Option<Fingerprint> {
let meta = std::fs::metadata(path).ok()?;
let meta = crate::io::metadata(path).ok()?;
let mtime = meta
.modified()
.ok()
+5 -5
View File
@@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &r.bytes)?;
crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings}))
}
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})),
@@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, text.as_bytes())?;
crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
@@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
match path {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, text.as_bytes())?;
crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
@@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
match p.get("path").and_then(Value::as_str) {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
@@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
crate::atomic::write_file(path, bytes)
crate::io::write_file(path, bytes).map_err(EngineError::Other)
}
#[cfg(target_arch = "wasm32")]
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
+48 -23
View File
@@ -29,20 +29,21 @@ pub fn specs() -> Vec<CommandSpec> {
super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path));
let bytes = to_bytes(&copy);
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(&path, &bytes)?;
crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert),
cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| {
let dir = recovery_dir(s, p)?;
let dir = recovery_dir(s, p, FolderAccess::Read)?;
Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect()))
}),
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
let dir = recovery_dir(s, p)?;
let dir = recovery_dir(s, p, FolderAccess::Write)?;
Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
}),
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
let dir = recovery_dir(s, p)?;
// Reopening removes the entries it read.
let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?;
Ok(json!({"opened": crate::recovery::open(s, &dir)?}))
}),
cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| {
@@ -164,7 +165,7 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
if path.to_ascii_lowercase().ends_with(".idml") {
return super::interchange::open_idml(s, p);
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
let mut d = from_bytes(&bytes)?;
super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent());
super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path)));
@@ -189,7 +190,10 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
{
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) };
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder);
// A document's folder is the agent's to name (a recovery entry or a book lists paths too):
// the fonts crate asks the automation roots about the folder and each font file, so fonts
// outside the read root stay unloaded.
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p));
d.font_scope = r.scope;
let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope)));
(scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect())
@@ -207,14 +211,14 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf {
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
let exact = dir.join(DOCUMENT_FONTS_FOLDER);
if exact.is_dir() {
if crate::io::is_dir(&exact) {
return exact;
}
std::fs::read_dir(dir)
crate::io::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && e.path().is_dir())
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path()))
.map_or(exact, |e| e.path())
}
@@ -239,6 +243,8 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
.map(str::to_string)
.or_else(|| st.path.clone())
.ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
// Before anything about the document changes: a refused save leaves it as it was.
crate::file_access::check_write(&path).map_err(EngineError::Other)?;
// Prepare paths for the destination without changing the live document until persistence succeeds.
let mut saved = (*st.doc).clone();
super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path));
@@ -246,7 +252,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() };
let bytes = to_bytes(&saved);
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(&path, &bytes)?;
crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?;
if paths_changed {
st.doc = saved;
st.revision = st.revision.saturating_add(1);
@@ -268,7 +274,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
(base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)
} else if let Some(path) = str_param(p, "path") {
#[cfg(not(target_arch = "wasm32"))]
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
#[cfg(target_arch = "wasm32")]
let b: Vec<u8> = vec![];
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into());
@@ -440,7 +446,7 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)
}
let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?;
#[cfg(not(target_arch = "wasm32"))]
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
#[cfg(target_arch = "wasm32")]
let b: Vec<u8> = vec![];
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into());
@@ -543,7 +549,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
crate::io::write_file(path, &bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
@@ -555,7 +561,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
const ID: &str = "file.place";
let src = if name.to_lowercase().ends_with(".idml") {
designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))?
super::interchange::import_idml_confined(bytes).map_err(|e| bad(ID, e.to_string()))?
} else {
from_bytes(bytes)?
};
@@ -633,17 +639,36 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn recovery_dir(s: &Session, p: &Value) -> Result<std::path::PathBuf> {
/// Which access a recovery command needs to the folder it is given.
#[derive(Clone, Copy)]
enum FolderAccess {
Read,
Write,
ReadWrite,
}
/// The recovery folder: `dir` when the command names one, else the session's own (set by the
/// host application, so not the automation roots' business). A named folder is checked against
/// the roots for `access`.
fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result<std::path::PathBuf> {
#[cfg(target_arch = "wasm32")]
{
let _ = (s, p);
let _ = (s, p, access);
Err(EngineError::Other("no recovery folder on the web".into()))
}
#[cfg(not(target_arch = "wasm32"))]
str_param(p, "dir")
.map(std::path::PathBuf::from)
.or_else(|| s.recovery_dir.clone())
.ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)"))
{
let Some(named) = str_param(p, "dir") else {
return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)"));
};
if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) {
crate::file_access::check_read(named).map_err(EngineError::Other)?;
}
if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) {
crate::file_access::check_write(named).map_err(EngineError::Other)?;
}
Ok(std::path::PathBuf::from(named))
}
}
fn can_revert(s: &Session) -> std::result::Result<(), String> {
@@ -660,10 +685,10 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
{
let mut d = if path.to_ascii_lowercase().ends_with(".idml") {
let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?;
super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
} else {
from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)?
from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)?
};
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
let (fonts, _, _) = load_document_fonts(&mut d, &path);
+11 -5
View File
@@ -25,13 +25,19 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
crate::io::write_file(path, &bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
}
}
/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`], so the
/// automation read root applies to the paths the package names.
pub(crate) fn import_idml_confined(bytes: &[u8]) -> designcraft_idml::Result<Document> {
designcraft_idml::import_idml_with(bytes, &|path| crate::io::read_file(path).ok())
}
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
import_report(bytes, dir).map(|(document, _)| document)
@@ -112,10 +118,10 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::
let Some(dir) = dir else { return };
for asset in document.assets.values_mut() {
let Some(link) = asset.link.as_deref() else { continue };
if link.len() > MAX_LINK_PATH_BYTES || std::path::Path::new(link).is_file() {
if link.len() > MAX_LINK_PATH_BYTES || crate::io::is_file(link) {
continue;
}
let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue };
let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| crate::io::is_file(p)) else { continue };
// Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI.
let path = std::path::absolute(&path).unwrap_or(path);
std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned());
@@ -125,7 +131,7 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::
#[cfg(not(target_arch = "wasm32"))]
fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> {
for path in packaged_link_candidates(link, dir) {
if let Ok(bytes) = std::fs::read(&path) {
if let Ok(bytes) = crate::io::read_file(&path) {
// Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize
// would introduce a verbatim prefix that is not an IDML file URI.
let path = std::path::absolute(&path).unwrap_or(path);
@@ -140,7 +146,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
(base64_decode(b), None, str_param(p, "name").map(|n| n.trim_end_matches(".idml").to_string()))
} else if let Some(path) = str_param(p, "path") {
#[cfg(not(target_arch = "wasm32"))]
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
#[cfg(target_arch = "wasm32")]
let b: Vec<u8> = Vec::new();
let pp = std::path::Path::new(path);
+2 -2
View File
@@ -29,7 +29,7 @@ impl Library {
#[cfg(not(target_arch = "wasm32"))]
if let Some(p) = &self.path {
let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?;
crate::atomic::write_file(p, &bytes)?;
crate::io::write_file(p, &bytes).map_err(crate::EngineError::Other)?;
}
Ok(())
}
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
(Some(j), _) => (j.to_string(), None),
(None, Some(path)) => {
#[cfg(not(target_arch = "wasm32"))]
let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?;
#[cfg(target_arch = "wasm32")]
let t = String::new();
(t, Some(path.to_string()))
+8 -4
View File
@@ -93,11 +93,11 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
let Some(path) = &a.link else { return "embedded" };
#[cfg(not(target_arch = "wasm32"))]
{
match std::fs::metadata(path) {
match crate::io::metadata(path) {
Err(_) => "missing",
Ok(m) if m.len() as usize != a.data.len() => "modified",
// Same size: compare the bytes (cheap for the sizes involved, and exact).
Ok(_) => match std::fs::read(path) {
Ok(_) => match crate::io::read_file(path) {
Ok(b) if b == *a.data => "ok",
Ok(_) => "modified",
Err(_) => "missing",
@@ -145,7 +145,7 @@ fn list(d: &Document) -> Vec<Value> {
#[cfg(not(target_arch = "wasm32"))]
fn reload(path: &str) -> Result<(Vec<u8>, (u32, u32))> {
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?;
Ok((b, px))
}
@@ -174,6 +174,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
let dir = str_param(p, "dir").map(std::path::PathBuf::from);
if let Some(dir) = &dir {
// Refused outright: candidates outside the automation read root would only look missing.
crate::file_access::check_read(dir).map_err(EngineError::Other)?;
}
let ext = str_param(p, "extension").map(|e| e.trim_start_matches('.').to_string());
if dir.is_none() && ext.is_none() {
return Err(bad("links.relinkFolder", "give `dir` and/or `extension`"));
@@ -194,7 +198,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
}
let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default();
let cand = folder.join(&name);
if cand.exists() {
if crate::io::exists(&cand) {
todo.push((a.id, cand));
} else {
not_found.push(name.to_string_lossy().to_string());
+2
View File
@@ -74,6 +74,8 @@ struct OutputRoot {
impl OutputRoot {
fn new(path: PathBuf) -> Result<Self> {
// Before anything is created: the folder must be inside the automation write root.
crate::file_access::check_write(&path).map_err(EngineError::Other)?;
ensure_real_directory(&path, false)?;
let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?;
Ok(Self { path, canonical })
+2 -6
View File
@@ -836,7 +836,7 @@ fn value_name(color: designcraft_color::Color) -> String {
fn load_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = match (str_param(p, "path"), str_param(p, "base64")) {
(_, Some(b)) => super::file::base64_decode(b),
(Some(path), None) => std::fs::read(path).map_err(|e| bad("swatch.load", format!("{path}: {e}")))?,
(Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?,
_ => return Err(bad("swatch.load", "give `path` or `base64`")),
};
let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?;
@@ -877,11 +877,7 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = designcraft_color::ase::write(&chosen);
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes);
#[cfg(target_arch = "wasm32")]
let written = std::fs::write(path, &bytes);
written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes)})),
+2 -2
View File
@@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, text.as_bytes())?;
crate::io::write_file(path, text.as_bytes()).map_err(crate::EngineError::Other)?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"text": text, "bytes": text.len()})),
@@ -385,7 +385,7 @@ fn text_or_path(p: &Value, id: &str) -> Result<String> {
(Some(t), _) => t.to_string(),
(None, Some(path)) => {
#[cfg(not(target_arch = "wasm32"))]
let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?;
#[cfg(target_arch = "wasm32")]
let t = {
let _ = path;
+439
View File
@@ -0,0 +1,439 @@
//! The folders automation may read and write.
//!
//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines 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`] (`designcraft-cli mcp`), except inside [`unconfined`]. 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: 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: impl AsRef<Path>, access: Access) -> Result<(), String> {
let path = path.as_ref();
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.display())),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())),
};
};
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.display())),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())),
}
}
}
/// 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)
}
/// 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(),
}
}
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
fn plain(path: PathBuf) -> PathBuf {
if !cfg!(windows) {
return path;
}
let text = path.to_string_lossy();
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
match text.strip_prefix(r"\\?\") {
Some(rest) => PathBuf::from(rest),
None => path,
}
}
/// May `path` be read here? Always, without roots in force.
pub fn check_read(path: impl AsRef<Path>) -> 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: impl AsRef<Path>) -> 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: &Path) -> Result<PathBuf, String> {
if path.as_os_str().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.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.display()));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {}: {e}", path.display()))?;
// 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.display()));
}
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())),
},
}
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.display()));
}
// 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 {}: {e}", path.display()));
}
}
}
}
/// 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!("dc-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.designcraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.designcraft"), "x").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.designcraft")), 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.designcraft")), 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.designcraft")), 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.designcraft")), 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.designcraft"), "x").unwrap();
assert!(roots.check(s(base.join("in2/b.designcraft")), Access::Read).is_err(), "in2 is not inside in");
assert!(roots.check(format!("{}2/c.designcraft", 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.designcraft", 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.designcraft", 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.designcraft", 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.designcraft", 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.designcraft"))).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.designcraft")), 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.designcraft"), &base.join("in/leak.designcraft")) {
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.designcraft")), 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.designcraft"), &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.designcraft")), 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.designcraft")), 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.designcraft"));
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
assert_eq!(roots.check(s(base.join("In/Sub/New.designcraft")), 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_stay_on_their_thread() {
let (base, roots) = folders("scopes");
let outside = s(base.join("outside/secret.designcraft"));
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 o = outside.clone();
let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap();
assert!(there, "another thread is not confined by this thread's 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.designcraft", "NUL "] {
assert!(is_device_name(d), "{d}");
}
for f in ["console", "com10", "COM0", "lpt", "a.designcraft", "nul-x"] {
assert!(!is_device_name(f), "{f}");
}
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Reading and writing the files a command names by path.
//!
//! Every such read and write goes through this module, so the automation roots
//! ([`crate::file_access`]) are checked in one place. A command module that called `std::fs`
//! itself would bypass them: a test in `file_access` keeps that from happening.
//!
//! A path the roots refuse reads as a file that isn't there where the caller only asks whether
//! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where
//! it asks for the bytes.
use std::fs::Metadata;
use std::path::Path;
/// The bytes of the file at `path`, inside the automation read root when there is one.
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>, String> {
let path = path.as_ref();
crate::file_access::check_read(path)?;
std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))
}
/// [`read_file`] with the file system's own error, for callers that tell "missing" from other
/// failures. A refused path is a `PermissionDenied` error carrying the roots' message.
pub fn read(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> {
let path = path.as_ref();
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::read(path)
}
/// The text of the file at `path`, inside the automation read root when there is one.
pub fn read_to_string(path: impl AsRef<Path>) -> Result<String, String> {
let path = path.as_ref();
crate::file_access::check_read(path)?;
std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
pub fn write_file(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), String> {
let path = path.as_ref();
crate::file_access::check_write(path)?;
// Replaced atomically where there is a real file system (see `atomic`).
#[cfg(not(target_arch = "wasm32"))]
let written = crate::atomic::write_atomic(path, bytes);
#[cfg(target_arch = "wasm32")]
let written = std::fs::write(path, bytes);
written.map_err(|e| format!("{}: {e}", path.display()))
}
/// Atomically replace the file at `path` with the contents of `staged`, a private temporary file
/// the engine made itself (so it is not checked), inside the automation write root when there is
/// one.
#[cfg(not(target_arch = "wasm32"))]
pub fn replace_with(path: impl AsRef<Path>, staged: impl AsRef<Path>) -> std::io::Result<()> {
let path = path.as_ref();
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
let mut input = std::fs::File::open(staged)?;
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
}
/// Could the file at `path` be opened for writing (without truncating it)? Inside the automation
/// write root when there is one.
pub fn writable(path: impl AsRef<Path>) -> std::io::Result<()> {
let path = path.as_ref();
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::OpenOptions::new().write(true).open(path).map(drop)
}
/// The canonical form of `path` (links resolved), inside the automation read root when there is
/// one.
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> {
let path = path.as_ref();
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::canonicalize(path)
}
/// What the file system says about `path` (links followed), inside the automation read root when
/// there is one. A refused path is a `PermissionDenied` error carrying the roots' message.
pub fn metadata(path: impl AsRef<Path>) -> std::io::Result<Metadata> {
let path = path.as_ref();
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::metadata(path)
}
/// Is there a file at `path` that automation may read?
pub fn is_file(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok_and(|m| m.is_file())
}
/// Is there a folder at `path` that automation may read?
pub fn is_dir(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok_and(|m| m.is_dir())
}
/// Is there anything at `path` that automation may read?
pub fn exists(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok()
}
/// The entries of the folder at `path`, inside the automation read root when there is one.
pub fn read_dir(path: impl AsRef<Path>) -> std::io::Result<std::fs::ReadDir> {
let path = path.as_ref();
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::read_dir(path)
}
+6
View File
@@ -11,7 +11,9 @@
pub mod atomic;
pub mod cmd;
pub mod dtd;
pub mod file_access;
pub mod guard;
pub mod io;
pub mod links;
pub mod math;
pub mod recovery;
@@ -592,6 +594,10 @@ pub(crate) use push_undo as record_undo;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_file_guard;
#[cfg(test)]
mod tests_idml;
#[cfg(test)]
mod tests_roots;
#[cfg(test)]
mod tests_table;
+250
View File
@@ -0,0 +1,250 @@
//! The guarantee behind `--automation-read-root` and `--automation-write-root`: every file a
//! command reads or writes by path goes through a checked reader, in every crate a command can
//! reach, not only this one.
//!
//! In the engine that reader is the [`crate::io`] module. The lower crates cannot depend on the
//! engine (`cargo xtask layers`), so they either have no file access at all or take a reader or
//! an access check from the caller (`designcraft_idml::import_idml_with`,
//! `designcraft_fonts::FontDb::load_document_fonts`).
//!
//! The scan reads the source of every crate under `crates/` and flags what touches the file
//! system, however it is imported or spelled: `std::fs`, a bare `fs::` after `use std::fs` or
//! `use std::{fs, ...}`, `File::open`/`File::create`, `OpenOptions`, and the `Path` probes
//! (`.metadata(`, `.exists(`, `.canonicalize(`, `.read_dir(`...), as methods or as
//! `Path::exists`. The host applications under `apps/` are out of scope: they run with the
//! operator's own authority, and the roots are applied inside the process they start.
use std::path::{Path, PathBuf};
/// Files that may touch the file system, with why. Paths are relative to `crates/`. A file not
/// listed here, and not a test file or test module, must not.
const ALLOWED: &[(&str, &str)] = &[
("engine/src/io.rs", "the checked gateway: every read and write a command makes"),
("engine/src/file_access.rs", "the root check itself (resolves links with the file system)"),
("engine/src/atomic.rs", "how a write reaches the disk; commands call it only through io::write_file and io::replace_with"),
(
"engine/src/recovery.rs",
"the unsaved documents live in a folder the host application chooses (a command's own `dir` is checked in cmd/file.rs before it gets here)",
),
(
"engine/src/cmd/package.rs",
"`OutputRoot` checks the folder against the write root before it creates anything and then only writes inside it. Its reads are of the font files that draw the document's text (installed fonts and the `Document Fonts` folder), never of a path a command names",
),
(
"fonts/src/fontdb.rs",
"the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either",
),
(
"mcp/src/tools.rs",
"`screenshot` reads back the PNG the connected app just wrote. Connected mode cannot be combined with the roots (the CLI refuses the pair), and the app's own engine checks the path it writes",
),
];
fn rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, files);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
/// Test files (`tests.rs`, `tests_*.rs`, `*_tests.rs`, anything under a `tests/` folder) build
/// their own fixtures on disk.
fn is_test_file(rel: &str) -> bool {
let name = rel.rsplit('/').next().unwrap_or(rel);
name.starts_with("tests") || name.ends_with("_tests.rs") || name == "testing.rs" || rel.split('/').any(|part| part == "tests")
}
/// The file's lines but its test modules: a module is skipped from its `#[cfg(test)]` (or
/// `#[cfg(all(test, ...))]`) to the closing brace at the same indent. A `mod x;` declaration
/// has no body to skip.
fn code_outside_tests(text: &str) -> Vec<(usize, &str)> {
let lines: Vec<&str> = text.lines().collect();
let mut code = vec![];
let mut i = 0;
while i < lines.len() {
let attr = lines[i].trim();
let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub "));
if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) {
let header = lines[i + 1];
i += 2;
if header.trim_end().ends_with('{') {
let indent = &header[..header.len() - header.trim_start().len()];
let close = format!("{indent}}}");
while i < lines.len() && lines[i].trim_end() != close {
i += 1;
}
i += 1;
}
continue;
}
code.push((i + 1, lines[i]));
i += 1;
}
code
}
/// `line` without its `//` comment (a `//` inside a string or char is rare enough in this code
/// to leave alone: it only hides the rest of that line from the scan).
fn without_comment(line: &str) -> &str {
line.find("//").map_or(line, |at| &line[..at])
}
fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Does `line` contain `word` not glued to a longer identifier on its left (`std::fs::` and
/// ` fs::` match `fs::`; `ofs::` does not)?
fn has_word(line: &str, word: &str) -> bool {
line.match_indices(word).any(|(at, _)| !line[..at].chars().next_back().is_some_and(is_ident))
}
/// What in `line` reaches the file system, or `None`.
fn file_system_use(line: &str) -> Option<String> {
let line = without_comment(line);
// `std::fs::read`, `fs::read` after `use std::fs;` or `use std::{fs, io};`, `std::os::unix::fs::...`.
for word in ["fs::", "File::open", "File::create", "File::options", "OpenOptions", "atomic::write_atomic"] {
if has_word(line, word) {
return Some(word.into());
}
}
// The probes on a path, as `p.exists()` or `Path::exists`.
for method in ["metadata", "symlink_metadata", "canonicalize", "read_dir", "try_exists", "exists", "is_file", "is_dir", "read_link"] {
if line.contains(&format!(".{method}(")) || line.contains(&format!("Path::{method}")) {
return Some(method.to_string());
}
}
None
}
/// `use` statements (which can span lines) that bring the file system module in under any name:
/// `use std::{fs as files, io};` leaves no `fs::` to find at the call.
fn imports_fs(text: &str) -> Vec<usize> {
let mut hits = vec![];
let mut statement = String::new();
let mut start = 0;
for (n, line) in code_outside_tests(text) {
let line = without_comment(line);
if statement.is_empty() {
if !line.trim_start().starts_with("use ") && !line.trim_start().starts_with("pub use ") {
continue;
}
start = n;
}
statement.push_str(line);
statement.push(' ');
if line.contains(';') {
let words: Vec<&str> = statement.split(|c: char| !is_ident(c)).collect();
if words.contains(&"fs") && (words.contains(&"std") || words.contains(&"tokio") || words.contains(&"async_std")) {
hits.push(start);
}
statement.clear();
}
}
hits
}
/// Every offending line of the crate sources under `crates_dir`, minus [`ALLOWED`] and test code.
fn offenders(crates_dir: &Path) -> (Vec<String>, usize) {
let mut files = vec![];
rust_files(crates_dir, &mut files);
let mut scanned = 0;
let mut found = vec![];
for path in files {
let rel = path.strip_prefix(crates_dir).unwrap_or(&path).to_string_lossy().replace('\\', "/");
// `crates/<name>/src/...` only: build scripts, benches and `tests/` are not shipped code.
if !rel.split('/').nth(1).is_some_and(|d| d == "src") || is_test_file(&rel) || ALLOWED.iter().any(|(allowed, _)| *allowed == rel) {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else { continue };
scanned += 1;
for (n, line) in code_outside_tests(&text) {
if let Some(what) = file_system_use(line) {
found.push(format!("crates/{rel}:{n}: `{what}`: {}", line.trim()));
}
}
for n in imports_fs(&text) {
found.push(format!("crates/{rel}:{n}: imports the file system module"));
}
}
(found, scanned)
}
fn crates_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
}
/// Every command reads and writes files through [`crate::io`], in the engine and in every crate
/// below it: none may reach the file system itself, so the automation roots cannot be walked
/// around by a path that one of them opens. The exceptions are [`ALLOWED`], each with its reason.
#[test]
fn no_crate_touches_the_file_system_outside_the_checked_readers() {
let (found, scanned) = offenders(&crates_dir());
assert!(scanned > 100, "the scan found only {scanned} source files: has the layout moved?");
assert!(
found.is_empty(),
"route these through crate::io (engine) or a reader the caller injects (lower crates), or add the file to ALLOWED with the reason it is safe: {found:#?}"
);
}
/// The allow list names files that exist, so a rename cannot leave a stale exception behind (or
/// quietly exempt a new file of the same name).
#[test]
fn every_allowed_file_exists_and_still_needs_its_exception() {
for (rel, reason) in ALLOWED {
let path = crates_dir().join(rel);
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{rel}: {e}"));
assert!(!reason.is_empty());
let touches = code_outside_tests(&text).iter().any(|(_, line)| file_system_use(line).is_some()) || !imports_fs(&text).is_empty();
assert!(touches, "{rel} no longer touches the file system: drop it from ALLOWED");
}
}
/// The scan must not be foolable by how a call is spelled.
#[test]
fn the_scan_sees_every_spelling() {
let flagged = [
"let b = std::fs::read(p)?;",
"let b = fs::read(p)?;",
" fs::write(p, b)",
"let f = File::open(p)?;",
"let f = std::fs::File::create(p)?;",
"let f = OpenOptions::new().write(true).open(p)?;",
"let m = path.metadata()?;",
"let m = path.symlink_metadata()?;",
"let c = path.canonicalize()?;",
"for e in path.read_dir()? {}",
"if path.try_exists()? {}",
"if p.exists() {}",
"if p.is_file() {}",
"if p.is_dir() {}",
"let l = p.read_link()?;",
"files.retain(Path::exists);",
"crate::atomic::write_atomic(p, b)?;",
"(a, fs::read(x))",
];
for line in flagged {
assert!(file_system_use(line).is_some(), "not flagged: {line}");
}
let fine = [
"let b = crate::io::read_file(p)?;",
"if crate::io::is_dir(p) {}",
"let m = crate::io::metadata(p)?;",
"// std::fs::read(p) is what io wraps",
"let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);",
"let ofs::x = 1;",
];
for line in fine {
assert!(file_system_use(line).is_none(), "wrongly flagged: {line}");
}
for import in ["use std::fs;", "use std::{fs, io};", "use std::{\n io,\n fs as files,\n};", "use std::fs::File;"] {
assert!(!imports_fs(import).is_empty(), "import not flagged: {import}");
}
assert!(imports_fs("use std::io::Read;\nuse std::path::Path;").is_empty());
assert!(imports_fs("#[cfg(test)]\nmod tests {\n use std::fs;\n}\n").is_empty(), "test modules are skipped");
}
+233
View File
@@ -0,0 +1,233 @@
//! The automation roots at the engine: the files a document names (links, data sources, book
//! chapters, packaged fonts) are read and written through the same checks as the paths a command
//! is given.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{Value, json};
use crate::file_access::{AutomationRoots, confine};
use crate::{Session, cmd};
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 with `in` (the read root), `out` (the write root) and `outside`, and the roots.
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
let base = std::env::temp_dir().join(format!("dc-engine-roots-{name}-{}", 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 roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
(base, roots)
}
fn p(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
/// A document with `pic.png` placed from `dir`, so the asset is embedded and linked to the file.
fn linked_picture(dir: &Path) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": p(&dir.join("pic.png")), "x": 36, "y": 36, "width": 72})).unwrap();
s
}
fn link_status(s: &mut Session) -> Value {
s.execute("links.list", &json!({})).unwrap()[0]["status"].clone()
}
#[test]
fn a_link_outside_the_read_root_reads_as_missing() {
let (base, roots) = folders("links");
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
let mut outside = linked_picture(&base.join("outside"));
let mut inside = linked_picture(&base.join("in"));
assert_eq!(link_status(&mut outside), "ok");
confine(Some(&roots), || {
assert_eq!(link_status(&mut outside), "missing", "the file is not the agent's to look at, not even to compare");
assert_eq!(link_status(&mut inside), "ok");
assert!(outside.execute("links.relinkFolder", &json!({"dir": p(&base.join("outside"))})).is_err());
// A relink to a link that leads out is refused too, and the asset keeps what it had.
let asset = outside.execute("links.list", &json!({})).unwrap()[0]["asset"].clone();
let e = outside.execute("links.relink", &json!({"asset": asset, "path": p(&base.join("outside/pic.png"))})).unwrap_err().to_string();
assert!(e.contains("outside the read root"), "{e}");
});
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn idml_links_are_read_through_the_roots() {
let (base, roots) = folders("idml");
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
let mut s = linked_picture(&base.join("outside"));
// Linked, not embedded: opening the package has to read the file its link names.
let idml = base64_idml(&mut s);
let linked = |doc: &designcraft_doc::Document| doc.assets.values().filter(|a| !a.data.is_empty()).count();
let unconfined = cmd::interchange::import_idml_confined(&idml).unwrap();
assert_eq!(linked(&unconfined), 1, "without roots the link is followed");
let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml)).unwrap();
assert_eq!(linked(&confined), 0, "with roots a link outside the read root is not read");
// The same through the commands that open or place a package.
let b64 = cmd::base64_encode(&idml);
let mut t = Session::new();
t.execute("file.new", &json!({})).unwrap();
confine(Some(&roots), || {
t.execute("file.openBytes", &json!({"name": "x.idml", "base64": b64})).unwrap();
let doc = &t.doc().unwrap().doc;
assert_eq!(linked(doc), 0);
t.execute("file.place", &json!({"base64": b64, "name": "x.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap();
assert_eq!(linked(&t.doc().unwrap().doc), 0);
});
let _ = std::fs::remove_dir_all(base);
}
fn base64_idml(s: &mut Session) -> Vec<u8> {
let r = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap();
cmd::base64_decode(r["base64"].as_str().unwrap())
}
#[test]
fn a_merge_does_not_read_image_files_outside_the_read_root() {
let (base, roots) = folders("merge");
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
std::fs::write(base.join("in/data.csv"), format!("@Photo\n{}\n", base.join("outside/pic.png").display())).unwrap();
let merge = |s: &mut Session| {
s.execute("file.new", &json!({})).unwrap();
s.execute("data.source.select", &json!({"path": p(&base.join("in/data.csv"))})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})).unwrap();
s.execute("data.placeholder.add", &json!({"field": "Photo", "item": frame["id"]})).unwrap();
s.execute("data.merge", &json!({})).unwrap()["missingImages"].as_array().unwrap().len()
};
assert_eq!(merge(&mut Session::new()), 0, "without roots the picture is found");
assert_eq!(confine(Some(&roots), || merge(&mut Session::new())), 1, "with roots it reads as missing");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_data_source_path_stored_in_a_document_is_read_through_the_roots() {
let (base, roots) = folders("source");
std::fs::write(base.join("outside/data.csv"), "name\nTOP SECRET\n").unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("data.source.select", &json!({"path": p(&base.join("outside/data.csv"))})).unwrap();
confine(Some(&roots), || {
let e = s.execute("data.source.update", &json!({})).unwrap_err().to_string();
assert!(e.contains("outside the read root") && !e.contains("TOP SECRET"), "{e}");
});
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_book_writes_its_chapters_only_inside_the_write_root() {
let (base, roots) = folders("book");
let chapter = base.join("in/a.designcraft");
let second = base.join("in/b.designcraft");
{
// Made by another session: a book only rewrites chapters that are not open.
let mut maker = Session::new();
maker.execute("file.new", &json!({})).unwrap();
maker.execute("file.save", &json!({"path": p(&chapter)})).unwrap();
maker.execute("file.saveAs", &json!({"path": p(&second)})).unwrap();
}
let mut s = Session::new();
let before = (std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap());
confine(Some(&roots), || {
s.execute("book.new", &json!({"path": p(&base.join("out/b.dcbook"))})).unwrap();
s.execute("book.add", &json!({"path": p(&chapter)})).unwrap();
s.execute("book.add", &json!({"path": p(&second)})).unwrap();
for command in ["book.paginate", "book.syncStyles", "book.exportPdf"] {
let r = s.execute(command, &json!({"path": p(&base.join("outside/book.pdf"))}));
let e = r.unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{command}: {e}");
}
let e = s.execute("book.new", &json!({"path": p(&base.join("outside/b.dcbook"))})).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{e}");
});
assert_eq!((std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()), before, "no chapter was rewritten");
assert!(!base.join("outside/b.dcbook").exists());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn package_and_copy_links_create_nothing_outside_the_write_root() {
let (base, roots) = folders("package");
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
let mut s = linked_picture(&base.join("in"));
confine(Some(&roots), || {
for command in ["file.package", "links.copyTo"] {
let e = s.execute(command, &json!({"dir": p(&base.join("outside/pkg"))})).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{command}: {e}");
// The read root is not writable either.
let e = s.execute(command, &json!({"dir": p(&base.join("in/pkg"))})).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{command}: {e}");
}
s.execute("file.package", &json!({"dir": p(&base.join("out/pkg"))})).unwrap();
s.execute("links.copyTo", &json!({"dir": p(&base.join("out/copies"))})).unwrap();
});
assert!(!base.join("outside/pkg").exists() && !base.join("in/pkg").exists());
assert!(base.join("out/pkg/Instructions.txt").exists() && base.join("out/copies/pic.png").exists());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn recovery_folders_named_by_a_command_are_checked() {
let (base, roots) = folders("recovery");
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
confine(Some(&roots), || {
let out = p(&base.join("outside"));
let inside = p(&base.join("in"));
let to_write = p(&base.join("out"));
let e = s.execute("file.recovery.save", &json!({"dir": out})).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{e}");
let e = s.execute("file.recovery.save", &json!({"dir": inside})).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{e}");
assert!(s.execute("file.recovery.list", &json!({"dir": out})).unwrap_err().to_string().contains("outside the read root"));
// Reopening removes what it read, so it needs both.
assert!(s.execute("file.recovery.open", &json!({"dir": inside})).unwrap_err().to_string().contains("outside the write root"));
assert!(s.execute("file.recovery.open", &json!({"dir": to_write})).unwrap_err().to_string().contains("outside the read root"));
assert_eq!(s.execute("file.recovery.save", &json!({"dir": to_write})).unwrap()["saved"], 1);
});
assert!(std::fs::read_dir(base.join("outside")).unwrap().next().is_none());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn fonts_beside_a_document_outside_the_read_root_are_not_loaded() {
let (base, roots) = folders("fonts");
std::fs::create_dir_all(base.join("outside/Document Fonts")).unwrap();
std::fs::write(base.join("outside/Document Fonts/x.ttf"), b"not a font").unwrap();
let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
let (_, faces, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft"))));
assert_eq!(faces, 0);
assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}");
let (_, _, warnings) = cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft")));
assert!(warnings.iter().any(|w| w.contains("x.ttf")), "without roots the folder is read and the bad font reported: {warnings:?}");
let _ = std::fs::remove_dir_all(base);
}
#[cfg(unix)]
#[test]
fn a_font_file_that_links_out_of_the_read_root_is_skipped() {
let (base, roots) = folders("fontlink");
std::fs::create_dir_all(base.join("in/Document Fonts")).unwrap();
std::fs::write(base.join("outside/secret.ttf"), b"not a font").unwrap();
std::os::unix::fs::symlink(base.join("outside/secret.ttf"), base.join("in/Document Fonts/link.ttf")).unwrap();
let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
let doc = p(&base.join("in/doc.designcraft"));
let (_, _, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &doc));
assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("not a font")), "the file was opened: {warnings:?}");
let (_, _, warnings) = cmd::load_document_fonts(&mut d, &doc);
assert!(warnings.iter().any(|w| w.contains("link.ttf")), "without roots the link is followed: {warnings:?}");
let _ = std::fs::remove_dir_all(base);
}
+24 -9
View File
@@ -1121,18 +1121,33 @@ impl FontDb {
/// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past
/// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A
/// missing folder loads nothing (scope 0).
///
/// The folder is named by a document, so this crate does not trust it: `allow` (`Ok`, or why a
/// path may not be read) is asked about
/// the folder and about every font file in it before either is opened, and a path it refuses
/// is skipped with its message (the engine passes its automation-root check; a host with no
/// roots passes `&|_| Ok(())`).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_document_fonts(&self, folder: &std::path::Path) -> DocumentFonts {
pub fn load_document_fonts(&self, folder: &std::path::Path, allow: &dyn Fn(&std::path::Path) -> Result<(), String>) -> DocumentFonts {
let mut out = DocumentFonts::default();
if let Err(why) = allow(folder) {
out.skipped.push(why);
return out;
}
let Ok(entries) = std::fs::read_dir(folder) else { return out };
let mut files: Vec<std::path::PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && p.is_file()
})
.collect();
let mut files: Vec<std::path::PathBuf> = Vec::new();
for path in entries.flatten().map(|e| e.path()) {
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
// Each file is vetted before it is touched at all, a link out of the folder included.
match allow(&path) {
Ok(()) if path.is_file() => files.push(path),
Ok(()) => {}
Err(why) => out.skipped.push(why),
}
}
files.sort();
let mut total = 0u64;
let mut own: Vec<Arc<FontFace>> = Vec::new();
+34 -10
View File
@@ -36,7 +36,7 @@ fn document_fonts_load_ahead_of_installed_ones() {
let installed = db.face("DocFont Precedence", "Regular");
assert!(installed.covers('a') && matches!(installed.source, FontSource::Installed(_)), "{:?}", installed.source);
let r = db.load_document_fonts(&folder);
let r = db.load_document_fonts(&folder, &|_| Ok(()));
assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped);
let face = db.scoped(r.scope).face("DocFont Precedence", "Regular");
assert!(face.covers('b'), "the document's face wins");
@@ -45,7 +45,7 @@ fn document_fonts_load_ahead_of_installed_ones() {
assert!(db.face("DocFont Precedence", "Regular").covers('a'), "outside the document the installed face stays");
// Loading the folder again (another document beside it) shares the face, in a scope of its own.
let again = db.load_document_fonts(&folder);
let again = db.load_document_fonts(&folder, &|_| Ok(()));
assert_eq!((again.faces, again.skipped.len()), (1, 0));
assert_ne!(again.scope, r.scope);
assert!(Arc::ptr_eq(&db.scoped(again.scope).face("DocFont Precedence", "Regular"), &face));
@@ -64,7 +64,7 @@ fn damaged_oversized_and_other_files_are_skipped() {
write_font(&folder.join("nested.ttf"), "inner.ttf", "DocFont Nested", &['n']);
let db = FontDb::with_font_dirs(vec![]);
let r = db.load_document_fonts(&folder);
let r = db.load_document_fonts(&folder, &|_| Ok(()));
assert_eq!(r.faces, 1);
assert!(db.scoped(r.scope).has_family("DocFont Good"));
assert!(!db.has_family("DocFont Good"), "the document's alone");
@@ -85,7 +85,7 @@ fn the_number_of_files_read_is_capped() {
write_font(&folder, "zz-real.ttf", "DocFont Past Cap", &['z']);
let db = FontDb::with_font_dirs(vec![]);
let r = db.load_document_fonts(&folder);
let r = db.load_document_fonts(&folder, &|_| Ok(()));
assert_eq!((r.faces, r.scope), (0, 0));
assert!(!db.scoped(r.scope).has_family("DocFont Past Cap"));
assert_eq!(r.skipped.len(), MAX_DOCUMENT_FONT_FILES + 1, "one note for each file read, one for the rest");
@@ -98,7 +98,7 @@ fn large_cjk_collections_are_within_the_limit() {
// macOS's Songti collection is 67 MB; full Noto/Source Han CJK collections are larger.
let folder = temp_dir("cjk-size");
std::fs::File::create(folder.join("cjk.ttc")).unwrap().set_len(200 << 20).unwrap();
let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder);
let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder, &|_| Ok(()));
assert_eq!(r.skipped.len(), 1, "{:?}", r.skipped);
assert!(!r.skipped[0].contains("larger than"), "skipped for not being a font, not for its size: {:?}", r.skipped);
let _ = std::fs::remove_dir_all(&folder);
@@ -107,7 +107,7 @@ fn large_cjk_collections_are_within_the_limit() {
#[test]
fn a_missing_folder_loads_nothing() {
let db = FontDb::with_font_dirs(vec![]);
let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER));
let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER), &|_| Ok(()));
assert_eq!((r.faces, r.scope, r.skipped.len()), (0, 0, 0));
}
@@ -118,7 +118,7 @@ fn each_scope_sees_its_own_fonts_only() {
write_font(&a, "a.ttf", FAMILY, &['a']);
write_font(&b, "b.ttf", FAMILY, &['b', '\u{E123}']);
let db = FontDb::with_font_dirs(vec![]);
let (ra, rb) = (db.load_document_fonts(&a), db.load_document_fonts(&b));
let (ra, rb) = (db.load_document_fonts(&a, &|_| Ok(())), db.load_document_fonts(&b, &|_| Ok(())));
let (in_a, in_b) = (db.scoped(ra.scope), db.scoped(rb.scope));
assert!(in_a.face(FAMILY, "Regular").covers('a') && in_b.face(FAMILY, "Regular").covers('b'));
@@ -139,16 +139,16 @@ fn a_closed_scope_finds_nothing_and_reopening_reuses_the_faces() {
let folder = temp_dir("reopen");
let file = write_font(&folder, "r.ttf", FAMILY, &['r']);
let db = FontDb::with_font_dirs(vec![]);
let r = db.load_document_fonts(&folder);
let r = db.load_document_fonts(&folder, &|_| Ok(()));
let face = db.scoped(r.scope).face(FAMILY, "Regular");
db.close_scope(r.scope);
assert!(!db.scoped(r.scope).has_family(FAMILY), "closed");
let again = db.load_document_fonts(&folder);
let again = db.load_document_fonts(&folder, &|_| Ok(()));
assert!(Arc::ptr_eq(&db.scoped(again.scope).face(FAMILY, "Regular"), &face), "an unchanged file isn't read again");
// A file changed since is read again.
std::fs::write(&file, font_with(FAMILY, &['r', 's']).unwrap()).unwrap();
let changed = db.load_document_fonts(&folder);
let changed = db.load_document_fonts(&folder, &|_| Ok(()));
let new_face = db.scoped(changed.scope).face(FAMILY, "Regular");
assert!(new_face.covers('s') && new_face.id() != face.id());
assert!(!db.scoped(again.scope).face(FAMILY, "Regular").covers('s'), "the document open before keeps what it opened with");
@@ -166,3 +166,27 @@ fn restricted_licence_reads_the_embedding_bits() {
assert!(!db.face(FALLBACK_FAMILY, "Regular").restricted_licence(), "the bundled fonts are OFL");
assert_eq!(db.face(FALLBACK_FAMILY, "Regular").source, FontSource::Bundled);
}
#[test]
fn the_access_check_vets_the_folder_and_every_file_before_it_is_opened() {
let folder = temp_dir("access");
write_font(&folder, "kept.ttf", "DocFont Kept", &['k']);
write_font(&folder, "refused.ttf", "DocFont Refused", &['r']);
let db = FontDb::with_font_dirs(vec![]);
let asked = std::sync::Mutex::new(Vec::new());
let allow = |p: &Path| {
asked.lock().unwrap().push(p.to_path_buf());
if p.ends_with("refused.ttf") { Err("refused.ttf: outside the read root".to_string()) } else { Ok(()) }
};
let r = db.load_document_fonts(&folder, &allow);
assert_eq!(r.faces, 1, "{:?}", r.skipped);
assert_eq!(r.skipped, vec!["refused.ttf: outside the read root".to_string()]);
let asked = asked.into_inner().unwrap();
assert_eq!(asked.first().map(PathBuf::as_path), Some(folder.as_path()), "the folder is asked about first");
assert!(asked.iter().any(|p| p.ends_with("kept.ttf")) && asked.iter().any(|p| p.ends_with("refused.ttf")));
// A refused folder is not even listed.
let r = db.load_document_fonts(&folder, &|_| Err("no".to_string()));
assert_eq!((r.faces, r.scope, r.skipped), (0, 0, vec!["no".to_string()]));
let _ = std::fs::remove_dir_all(&folder);
}
+7 -15
View File
@@ -29,21 +29,11 @@ fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option<
Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? })
}
/// Import an IDML package. Linked images are read from disk when available (not on wasm).
/// Import an IDML package without reading its linked images from disk: this crate never touches
/// the file system, so a caller that wants the links brings its own reader
/// ([`import_idml_with`]). The engine passes one that checks the automation roots.
pub fn import_idml(bytes: &[u8]) -> Result<Document> {
import_idml_with(bytes, &default_reader)
}
fn default_reader(path: &str) -> Option<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(path).ok()
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
None
}
import_idml_with(bytes, &|_| None)
}
/// Import with a custom reader for linked files (`path` → bytes).
@@ -68,7 +58,9 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u
let mut lenient: HashMap<String, String> = HashMap::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
if f.is_dir() {
// A folder entry of the archive (the zip crate's own `is_dir`; spelled out so no file-system
// probe appears in this crate).
if f.name().ends_with('/') {
continue;
}
let name = f.name().to_string();
+6
View File
@@ -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
View File
@@ -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.
@@ -202,7 +213,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)})),
@@ -222,13 +233,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 = &params;
match method {
"engine.execute" | "ui.menu.invoke" | "command" => {
@@ -255,14 +285,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.
+1 -1
View File
@@ -480,7 +480,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})))
}
+349
View File
@@ -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);
}
+4 -2
View File
@@ -195,8 +195,10 @@ pub fn scripts(app: &mut DesignApp, ui: &mut egui::Ui) {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load…"))).clicked()
&& let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("script"))
{
#[cfg(not(target_arch = "wasm32"))]
match std::fs::read_to_string(&path) {
// Through the host's reader, like every other file the UI opens: this crate has no
// file system of its own.
let read = app.services.read.as_mut().map_or_else(|| Err("no file access".to_string()), |r| r(&path));
match read.and_then(|bytes| String::from_utf8(bytes).map_err(|e| e.to_string())) {
Ok(text) => {
let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Script".into());
app.ui.scripts.push((name, text));
+68
View File
@@ -41,6 +41,74 @@ claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe"
claude mcp add designcraft -- designcraft-cli mcp
```
## Confining file access
```sh
designcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
```
```json
{"mcpServers": {"designcraft": {"command": "designcraft-cli",
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
```
The two flags (VectorCraft, 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_document`, `file.open`,
`file.openIdml`, `place_image` and `file.place` / `place.load` / `media.options` with a `path`,
`links.relink`, `links.relinkFolder`, `color.loadProfile`, `swatch.load`, `library.open`,
`book.open`, `data.source.select`, `data.merge` and `file.importXml` / `xml.loadDtd` with a `path`,
and `file.recovery.list` / `file.recovery.open` with a `dir`. A DesignCraft document names more
files than the agent does: linked images, data sources, book chapters, the `Document Fonts` folder
beside a document, an IDML package's linked images and the files an image cell of a merge names.
Those are read through the same checks, so a path in a document that leads outside the read root
reads as a file that isn't there.
- Every file a command writes must lie inside the **write root**: `save_document`, `export_png`,
`render_page` with a `path`, `file.save`, `file.saveAs`, `file.saveACopy`, `file.exportPdf`,
`file.exportEpub`, `file.exportFixedEpub`, `file.exportHtml`, `file.exportText`, `file.exportXml`,
`file.exportIdml`, `file.printBooklet`, `snippet.export`, `swatch.save`, `library.new` and the
library's saves, `book.new`, `book.add`, `book.paginate`, `book.syncStyles`, `book.exportPdf`,
`file.package`, `links.copyTo` and `file.recovery.save` / `file.recovery.open` with a `dir`
(reopening removes the entries it read). `file.package` and `links.copyTo` check their folder
before they create anything.
- 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. 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.designcraft` or
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.designcraft`.
- 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 in-process server, so `--connect` together with them is an error: the server
would otherwise talk to an app that isn't confined. DesignCraft's desktop app has no equivalent flags
yet, so a running app (`designcraft --control`) is not confined. `--sample` is the operator's own
choice and is opened before the roots apply.
What the roots do not cover: commands that take no path still read and change the open document,
and `inspect_document` returns it. The system font scan, the installed fonts that `file.package`
copies, and the app's own recovery folder are fixed paths, not agent input, and are not checked. A
`Document Fonts` folder and each font file in it are checked before they are opened, so a link that
leads out of the read root is skipped with a note. `file.print` hands a PDF to the system's print
spooler, which is not a path the agent names.
Anything that reads or writes a path goes through one place in the engine (the `io` module). The
crates below the engine have no file access of their own: the IDML import and the font folders take
a reader or an access check from the engine, and ICC profiles arrive as bytes. A test scans the
source of every crate for the file system, however it is imported or spelled, and lists the few
files it excludes and why.
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.
## Modes
| | Headless (`mcp`) | Connected (`mcp --connect PORT` or `HOST:PORT`) |