mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 04:16:46 -06:00
Merge fix/749
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options, and boolean `--flag`s. Unknown options are usage errors.
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -8,7 +9,7 @@ use crate::{CliError, CliResult};
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Args {
|
||||
pub positional: Vec<String>,
|
||||
values: BTreeMap<String, String>,
|
||||
values: BTreeMap<String, Vec<String>>,
|
||||
flags: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ impl Args {
|
||||
Some(v) => v,
|
||||
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
|
||||
};
|
||||
a.values.insert(name.to_string(), v);
|
||||
a.values.entry(name.to_string()).or_default().push(v);
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
@@ -45,7 +46,12 @@ impl Args {
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
self.values.get(k).cloned()
|
||||
self.values.get(k).and_then(|v| v.last()).cloned()
|
||||
}
|
||||
|
||||
/// Every value of an option that may be given more than once, in order.
|
||||
pub fn values(&self, k: &str) -> &[String] {
|
||||
self.values.get(k).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn flag(&self, k: &str) -> bool {
|
||||
@@ -62,9 +68,9 @@ impl Args {
|
||||
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
|
||||
/// `--bleed-marks`, `--cmyk`.
|
||||
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
|
||||
let bleed = match self.values.get("bleed") {
|
||||
let bleed = match self.value("bleed") {
|
||||
None => 0.0,
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(s)
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
|
||||
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
|
||||
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
|
||||
};
|
||||
@@ -72,7 +78,7 @@ impl Args {
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> CliResult<f64> {
|
||||
match self.values.get("scale") {
|
||||
match self.value("scale") {
|
||||
None => Ok(1.0),
|
||||
Some(s) => s
|
||||
.trim_end_matches('x')
|
||||
@@ -85,7 +91,7 @@ impl Args {
|
||||
|
||||
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
|
||||
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
|
||||
match self.values.get(k) {
|
||||
match self.value(k) {
|
||||
None => Ok(default.to_vec()),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
@@ -110,6 +116,10 @@ mod tests {
|
||||
assert_eq!(a.value("o").as_deref(), Some("out"));
|
||||
assert_eq!(a.scale().unwrap(), 2.0);
|
||||
assert!(a.flag("json"));
|
||||
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
|
||||
assert_eq!(a.values("root"), ["a", "b"]);
|
||||
assert_eq!(a.value("root").as_deref(), Some("b"));
|
||||
assert!(a.values("none").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,7 +23,7 @@ USAGE:
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version
|
||||
@@ -42,6 +42,10 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
|
||||
writes only inside a write root, everything else refused. A running app confines itself: start it
|
||||
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -133,11 +137,23 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let a = Args::parse(rest, &["attach"], &[])?;
|
||||
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
mcp(a.value("attach").as_deref())
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
|
||||
(true, _) => None,
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
(false, Some(_)) => {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
|
||||
};
|
||||
mcp(a.value("attach").as_deref(), roots)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
@@ -205,7 +221,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
|
||||
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
|
||||
Some(p) => {
|
||||
let addr = if p.contains(':') {
|
||||
@@ -220,7 +236,10 @@ fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
None => Box::new(figcraft_mcp::Headless::new()),
|
||||
};
|
||||
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
|
||||
let mut server = figcraft_mcp::Server::new(backend);
|
||||
if let Some(r) = &roots {
|
||||
eprintln!("file access confined to {}", r.to_json());
|
||||
}
|
||||
let mut server = figcraft_mcp::Server::new(backend).with_roots(roots);
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
|
||||
|
||||
@@ -25,6 +25,9 @@ fn bad_arguments_exit_2_without_panicking() {
|
||||
&["render", "a", "-o"],
|
||||
&["perf", "--shapes", "lots"],
|
||||
&["mcp", "--attach", "notaport"],
|
||||
// An attached app confines itself: roots go to `figcraft --control`.
|
||||
&["mcp", "--attach", "7979", "--automation-read-root", "."],
|
||||
&["mcp", "--automation-write-root"],
|
||||
&["convert", "a"],
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
@@ -120,6 +123,49 @@ fn mcp_over_stdio() {
|
||||
assert_eq!(lines[2]["result"]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio_confined_to_automation_roots() {
|
||||
let d = tmp("roots");
|
||||
for sub in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
// A root that isn't a folder stops the server before it serves.
|
||||
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
|
||||
let mut child = bin()
|
||||
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
let call = |id: u32, tool: &str, args: serde_json::Value| {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
|
||||
};
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
|
||||
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 6);
|
||||
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
|
||||
let error = |i: usize| lines[i]["result"]["isError"] == true;
|
||||
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
|
||||
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
|
||||
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_json_lists_everything() {
|
||||
let out = bin().args(["commands", "--json"]).output().unwrap();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
|
||||
//! The command line: `figcraft [--control <port>] [--automation-read-root <dir>]…
|
||||
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
@@ -11,6 +12,11 @@ pub struct Args {
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
pub join: Option<String>,
|
||||
/// `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable; `=` works
|
||||
/// too): the folders the app's file access is confined to. A flag without a value gives `""`,
|
||||
/// which is refused at startup.
|
||||
pub read_roots: Vec<String>,
|
||||
pub write_roots: Vec<String>,
|
||||
}
|
||||
|
||||
/// An invite link rather than a file (`figcraft:` links, in any case).
|
||||
@@ -49,7 +55,15 @@ pub fn parse(args: &[String]) -> Args {
|
||||
let mut out = Args::default();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--control" {
|
||||
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => Some(&mut out.read_roots),
|
||||
"--automation-write-root" => Some(&mut out.write_roots),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(roots) = roots {
|
||||
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
|
||||
} else if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
|
||||
@@ -10,12 +10,22 @@ fn args(a: &[&str]) -> Vec<String> {
|
||||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_repeat_and_are_never_files() {
|
||||
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
|
||||
assert_eq!(a.read_roots, args(&["in", "more"]));
|
||||
assert_eq!(a.write_roots, args(&["out"]));
|
||||
assert_eq!(a.files, args(&["a.figcraft"]));
|
||||
// A flag without its folder is kept as an empty one, which startup refuses.
|
||||
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_links_join_instead_of_opening() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
|
||||
//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
|
||||
//!
|
||||
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
|
||||
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
|
||||
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
|
||||
//! the command line are not. A root that isn't an existing folder stops the app at startup.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
@@ -121,6 +126,17 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
let args = args::parse(&args);
|
||||
let roots = match (
|
||||
args.read_roots.is_empty() && args.write_roots.is_empty(),
|
||||
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
|
||||
) {
|
||||
(true, _) => None,
|
||||
(false, Ok(r)) => Some(Arc::new(r)),
|
||||
(false, Err(e)) => {
|
||||
eprintln!("FigCraft: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
|
||||
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
|
||||
// to lay text out again with them.
|
||||
@@ -176,6 +192,7 @@ fn main() -> eframe::Result {
|
||||
// open (no frame then).
|
||||
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.automation_roots = roots;
|
||||
fig.ui.home = false;
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's
|
||||
# own rule: shipped engine code reaches the file system only through `file_access`, which enforces
|
||||
# the automation roots (#715), so every command is confined without a check of its own.
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
too-many-arguments-threshold = 10
|
||||
disallowed-methods = [
|
||||
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" },
|
||||
{ path = "std::fs::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::copy", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::rename", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" },
|
||||
{ path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" },
|
||||
{ path = "std::fs::remove_file", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::remove_dir", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::hard_link", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::File::open", reason = "use file_access::open (automation roots)" },
|
||||
{ path = "std::fs::File::create", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" },
|
||||
{ path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" },
|
||||
{ path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" },
|
||||
{ path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" },
|
||||
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
|
||||
]
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Frame presets shown by the Frame tool (category, name, width, height).
|
||||
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
|
||||
@@ -313,12 +313,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
Err(EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
}
|
||||
|
||||
/// Place an image as a rectangle with an image fill (Figma's image layer).
|
||||
|
||||
@@ -435,7 +435,7 @@ pub(crate) fn recovered(path: &str) -> Result<DocState> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -445,11 +445,7 @@ fn read(path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
/// The bytes of file `path`, refused when it's larger than `max`.
|
||||
pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes")));
|
||||
}
|
||||
read(path)
|
||||
crate::file_access::read_capped(path, max)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
|
||||
@@ -457,7 +453,7 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
/// truncates `path`, and no file or symlink at a staging name is ever written through.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::write(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -577,11 +573,9 @@ fn rest_images(path: &str) -> impl Fn(&str) -> Option<Vec<u8>> {
|
||||
return None;
|
||||
}
|
||||
let dir = dir.as_ref()?;
|
||||
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| {
|
||||
let p = dir.join(format!("{r}{e}"));
|
||||
let meta = std::fs::metadata(&p).ok()?;
|
||||
if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None }
|
||||
})
|
||||
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"]
|
||||
.iter()
|
||||
.find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,11 +598,7 @@ fn sibling_variables(path: &str) -> Option<Value> {
|
||||
let file = std::path::Path::new(path);
|
||||
let dir = file.parent()?;
|
||||
[format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| {
|
||||
let meta = std::fs::metadata(&c).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE {
|
||||
return None;
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?;
|
||||
let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?;
|
||||
figcraft_format::rest::is_variables_json(&v).then_some(v)
|
||||
})
|
||||
}
|
||||
@@ -646,20 +636,29 @@ fn remember(s: &mut Session, path: &str) {
|
||||
}
|
||||
|
||||
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use crate::file_access::{Access, allow, allowed};
|
||||
const CMD: &str = "file.openRecent";
|
||||
// Confined to automation roots, the recent files outside them are never listed, opened, removed
|
||||
// or named: `index` counts the files listed.
|
||||
let listed = |r: &String| allowed(r, Access::Read);
|
||||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||||
s.prefs.recent_files.clear();
|
||||
s.prefs.recent_files.retain(|r| !listed(r));
|
||||
}
|
||||
if let Some(gone) = string(p, "remove") {
|
||||
s.prefs.recent_files.retain(|r| *r != gone);
|
||||
s.prefs.recent_files.retain(|r| *r != gone || !listed(r));
|
||||
}
|
||||
let path = match (string(p, "path"), num(p, "index")) {
|
||||
(Some(path), _) => Some(path),
|
||||
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
|
||||
(Some(path), _) => {
|
||||
allow(&path, Access::Read)?;
|
||||
Some(path)
|
||||
}
|
||||
(None, Some(i)) => {
|
||||
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(path) = path {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
if !crate::file_access::exists(&path) {
|
||||
s.prefs.recent_files.retain(|r| *r != path);
|
||||
return Err(bad(CMD, format!("{path} no longer exists")));
|
||||
}
|
||||
@@ -669,9 +668,10 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.prefs
|
||||
.recent_files
|
||||
.iter()
|
||||
.filter(|r| listed(r))
|
||||
.map(|r| {
|
||||
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
|
||||
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
|
||||
json!({"path": r, "name": name, "exists": crate::file_access::exists(r)})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"files": files}))
|
||||
@@ -1183,7 +1183,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = std::path::Path::new(d).join(&job.file);
|
||||
// A layer name with slashes exports into folders.
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
let _ = crate::file_access::create_dir_all(parent);
|
||||
}
|
||||
let path = path.to_string_lossy().to_string();
|
||||
write(&path, &bytes)?;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Automation roots (#715): the folders the files an agent's commands read and write must lie in.
|
||||
//!
|
||||
//! `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` and the desktop app
|
||||
//! started with the same flags run their work [`confined`] to [`Roots`]: while a scope is active on
|
||||
//! the thread, every file the engine reads must lie inside a read or a write root and every file it
|
||||
//! writes inside a write root; anything else is an ordinary error. The engine reaches the file
|
||||
//! system only through this module (`crates/engine/clippy.toml` denies `std::fs` and friends
|
||||
//! anywhere else), so every command, present or future, is covered. Without a scope nothing changes.
|
||||
//!
|
||||
//! A path is made absolute against the working directory and resolved before it is compared: its
|
||||
//! links are followed (`std::fs::canonicalize`), and for a file that doesn't exist yet its nearest
|
||||
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
|
||||
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
|
||||
//! checked is what is opened.
|
||||
#![allow(clippy::disallowed_methods)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// What a command does with a file.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read (read and write roots) and write (write roots), resolved.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Roots {
|
||||
read: Vec<PathBuf>,
|
||||
write: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Roots {
|
||||
/// Roots from the folders given (relative ones against the working directory). Each must be an
|
||||
/// existing folder.
|
||||
pub fn new(read: &[String], write: &[String]) -> std::result::Result<Self, String> {
|
||||
let resolve = |flag: &str, dirs: &[String]| {
|
||||
dirs.iter()
|
||||
.map(|d| match std::fs::canonicalize(d) {
|
||||
Ok(p) if p.is_dir() => Ok(p),
|
||||
Ok(_) => Err(format!("{flag} {d}: not a folder")),
|
||||
Err(e) => Err(format!("{flag} {d}: {e}")),
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, String>>()
|
||||
};
|
||||
Ok(Self { read: resolve("--automation-read-root", read)?, write: resolve("--automation-write-root", write)? })
|
||||
}
|
||||
|
||||
/// The resolved path to use for `access` to `path`, or why it is refused.
|
||||
pub fn check(&self, path: &Path, access: Access) -> std::result::Result<PathBuf, String> {
|
||||
let asked = path.display();
|
||||
let (roots, what) = match access {
|
||||
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
|
||||
Access::Write => (self.write.clone(), "written"),
|
||||
};
|
||||
match resolve(path) {
|
||||
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
|
||||
Ok(_) => {}
|
||||
// Why a path can't be resolved says what exists there: only told for paths inside a root.
|
||||
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
|
||||
Err(_) => {}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
|
||||
}
|
||||
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
|
||||
Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
|
||||
}
|
||||
|
||||
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
let list = |v: &[PathBuf]| v.iter().map(|p| shown(p)).collect::<Vec<_>>();
|
||||
serde_json::json!({"read": list(&[self.read.as_slice(), self.write.as_slice()].concat()), "write": list(&self.write)})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
|
||||
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
|
||||
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
let Ok(abs) = std::path::absolute(path) else { return false };
|
||||
let mut lexical = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
match c {
|
||||
Component::ParentDir => {
|
||||
lexical.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
c => lexical.push(c),
|
||||
}
|
||||
}
|
||||
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
|
||||
}
|
||||
|
||||
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
|
||||
/// false when either can't be resolved.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool {
|
||||
matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f))
|
||||
}
|
||||
|
||||
/// `p` without Windows' `\\?\` prefix, for messages.
|
||||
fn shown(p: &Path) -> String {
|
||||
let s = p.to_string_lossy();
|
||||
match s.strip_prefix(r"\\?\UNC\") {
|
||||
Some(unc) => format!(r"\\{unc}"),
|
||||
None => s.strip_prefix(r"\\?\").unwrap_or(&s).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` made absolute and resolved: links followed, and for a path that doesn't exist yet its
|
||||
/// nearest existing folder resolved with the rest appended (plain names only).
|
||||
fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("not a file path".into());
|
||||
}
|
||||
let abs = std::path::absolute(path).map_err(|e| e.to_string())?;
|
||||
let mut rest: Vec<OsString> = Vec::new();
|
||||
let mut at = abs.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut base) => {
|
||||
base.extend(rest.iter().rev());
|
||||
return Ok(base);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Writing through a link that leads nowhere would create its target, wherever it is.
|
||||
if std::fs::symlink_metadata(at).is_ok() {
|
||||
return Err("a link that leads nowhere".into());
|
||||
}
|
||||
match at.components().next_back() {
|
||||
Some(Component::Normal(name)) => {
|
||||
plain_name(name)?;
|
||||
rest.push(name.to_os_string());
|
||||
}
|
||||
Some(Component::CurDir) => {}
|
||||
_ => return Err("`..` (or a drive) below a folder that doesn't exist".into()),
|
||||
}
|
||||
at = at.parent().ok_or("no existing folder")?;
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A name for a file or folder that doesn't exist yet: on Windows no device (`CON`, `NUL`, `COM1`…)
|
||||
/// and no alternate data stream (`a:b`).
|
||||
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
|
||||
if cfg!(windows) {
|
||||
let n = name.to_string_lossy();
|
||||
let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
|
||||
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|
||||
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.chars().count() == 4
|
||||
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
|
||||
if device || n.contains(':') {
|
||||
return Err(format!("`{n}` is a device or stream name, not a file"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ROOTS: RefCell<Option<Arc<Roots>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` with this thread's file access confined to `roots` (unchanged when `None`).
|
||||
pub fn confined<R>(roots: Option<&Arc<Roots>>, f: impl FnOnce() -> R) -> R {
|
||||
match roots {
|
||||
Some(roots) => scoped(Some(roots.clone()), f),
|
||||
None => f(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` unconfined: only for the app's own files, never for a path a command was given.
|
||||
// Only plugins read from disk use it, and the browser has no disk.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn app_owned<R>(f: impl FnOnce() -> R) -> R {
|
||||
scoped(None, f)
|
||||
}
|
||||
|
||||
fn scoped<R>(roots: Option<Arc<Roots>>, f: impl FnOnce() -> R) -> R {
|
||||
/// Puts the outer scope's roots back, also when `f` panics.
|
||||
struct Restore(Option<Arc<Roots>>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let outer = self.0.take();
|
||||
ROOTS.with(|r| *r.borrow_mut() = outer);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(ROOTS.with(|r| std::mem::replace(&mut *r.borrow_mut(), roots)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// The roots this thread's file access is confined to now, if any.
|
||||
pub fn current() -> Option<Arc<Roots>> {
|
||||
ROOTS.with(|r| r.borrow().clone())
|
||||
}
|
||||
|
||||
/// The path to use for `access` to `path`: resolved when confined, else as given.
|
||||
pub fn allow(path: impl AsRef<Path>, access: Access) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
match current() {
|
||||
Some(roots) => roots.check(path, access).map_err(EngineError::Other),
|
||||
None => Ok(path.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` may be accessed so now.
|
||||
pub fn allowed(path: impl AsRef<Path>, access: Access) -> bool {
|
||||
allow(path, access).is_ok()
|
||||
}
|
||||
|
||||
fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ {
|
||||
move |e| EngineError::Other(format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The bytes of file `path`.
|
||||
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
std::fs::read(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// The bytes of file `path`, refused when it isn't a file or is larger than `max`.
|
||||
pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
let p = allow(path, Access::Read)?;
|
||||
let meta = std::fs::metadata(&p).map_err(io(path))?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display())));
|
||||
}
|
||||
std::fs::read(p).map_err(io(path))
|
||||
}
|
||||
|
||||
/// File `path` opened for reading.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<std::fs::File> {
|
||||
let path = path.as_ref();
|
||||
std::fs::File::open(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// What the file system says about `path` (links followed).
|
||||
pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
|
||||
let path = path.as_ref();
|
||||
std::fs::metadata(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// Whether `path` is a folder (false when it may not be read).
|
||||
pub fn is_dir(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok_and(|m| m.is_dir())
|
||||
}
|
||||
|
||||
/// Whether `path` exists (false when it may not be read).
|
||||
pub fn exists(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok()
|
||||
}
|
||||
|
||||
/// The paths of the entries of folder `path`.
|
||||
pub fn read_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
|
||||
let path = path.as_ref();
|
||||
let dir = allow(path, Access::Read)?;
|
||||
Ok(std::fs::read_dir(&dir).map_err(io(path))?.flatten().map(|e| e.path()).collect())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a uniquely named staging file and one atomic rename (see
|
||||
/// [`figcraft_format::write_file_atomic`]).
|
||||
pub fn write(path: impl AsRef<Path>, bytes: &[u8]) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
write_atomic(&allow(path, Access::Write)?, bytes).map_err(io(path))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
figcraft_format::write_file_atomic(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system in the browser"))
|
||||
}
|
||||
|
||||
/// Create folder `path` and the folders above it that are missing.
|
||||
pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
std::fs::create_dir_all(allow(path, Access::Write)?).map_err(io(path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_access_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Automation roots: how paths are resolved and compared, and that commands run confined can't
|
||||
//! reach past them.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Access, Roots, allow, confined, current};
|
||||
use crate::Session;
|
||||
|
||||
/// A fresh folder for one test: `<tmp>/figcraft-roots-<name>-<pid>`, holding `root/`, `root2/`
|
||||
/// (a sibling whose name starts like the root's) and `outside/`.
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-roots-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["root/sub", "root2", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
std::fs::write(d.join("root/in.txt"), b"in").unwrap();
|
||||
std::fs::write(d.join("outside/secret.txt"), b"secret").unwrap();
|
||||
Self(d)
|
||||
}
|
||||
|
||||
fn p(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The root as both read and write root.
|
||||
fn rw(&self) -> Roots {
|
||||
Roots::new(&[], &[self.p("root")]).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
std::os::windows::fs::symlink_dir(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_file(target: &Path, link: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink_file(target: &Path, link: &Path) -> bool {
|
||||
std::os::windows::fs::symlink_file(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_is_allowed_and_resolved_outside_is_refused() {
|
||||
let f = Fixture::new("inside");
|
||||
let r = f.rw();
|
||||
let got = r.check(Path::new(&f.p("root/in.txt")), Access::Read).unwrap();
|
||||
assert_eq!(got, std::fs::canonicalize(f.p("root/in.txt")).unwrap(), "the I/O uses the resolved path");
|
||||
assert!(r.check(Path::new(&f.p("root/new.png")), Access::Write).is_ok());
|
||||
let e = r.check(Path::new(&f.p("outside/secret.txt")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected:") && e.contains("outside the folders that may be read"), "{e}");
|
||||
assert!(r.check(Path::new(&f.p("outside/new.png")), Access::Write).is_err());
|
||||
// The root itself is inside.
|
||||
assert!(r.check(Path::new(&f.p("root")), Access::Read).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_whose_name_starts_like_the_root_is_outside() {
|
||||
let f = Fixture::new("prefix");
|
||||
let r = f.rw();
|
||||
for p in ["root2/x.png", "root2", "root.png"] {
|
||||
assert!(r.check(Path::new(&f.p(p)), Access::Write).is_err(), "{p}");
|
||||
assert!(r.check(Path::new(&f.p(p)), Access::Read).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_is_resolved_before_comparing() {
|
||||
let f = Fixture::new("dots");
|
||||
let r = f.rw();
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../../outside/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../../outside/new.txt")), Access::Write).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../in.txt")), Access::Read).is_ok());
|
||||
assert!(r.check(Path::new(&f.p("root/./sub/../new.txt")), Access::Write).is_ok());
|
||||
// `..` below a folder that doesn't exist can't be resolved (on Windows it is taken lexically,
|
||||
// as the system does, and stays inside).
|
||||
let deep = f.p("root/missing/../../outside/x.txt");
|
||||
assert!(r.check(Path::new(&deep), Access::Write).is_err(), "{deep}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_dont_exist_yet_resolve_through_their_nearest_folder() {
|
||||
let f = Fixture::new("missing");
|
||||
let r = f.rw();
|
||||
let got = r.check(Path::new(&f.p("root/a/b/c.png")), Access::Write).unwrap();
|
||||
assert_eq!(got, std::fs::canonicalize(f.p("root")).unwrap().join("a").join("b").join("c.png"));
|
||||
assert!(r.check(Path::new(&f.p("outside/a/b/c.png")), Access::Write).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_are_resolved_against_the_working_directory() {
|
||||
// `cargo test` runs in the crate's folder, which holds Cargo.toml.
|
||||
let cwd = std::env::current_dir().unwrap().to_string_lossy().into_owned();
|
||||
let here = Roots::new(std::slice::from_ref(&cwd), &[]).unwrap();
|
||||
assert_eq!(here.check(Path::new("Cargo.toml"), Access::Read).unwrap(), std::fs::canonicalize("Cargo.toml").unwrap());
|
||||
assert!(here.check(Path::new("src/../Cargo.toml"), Access::Read).is_ok());
|
||||
let f = Fixture::new("relative");
|
||||
assert!(f.rw().check(Path::new("Cargo.toml"), Access::Read).is_err());
|
||||
// Relative roots too.
|
||||
assert_eq!(Roots::new(&[".".into()], &[]).unwrap(), here);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_write_authority_are_separate() {
|
||||
let f = Fixture::new("modes");
|
||||
let read_only = Roots::new(&[f.p("root")], &[]).unwrap();
|
||||
assert!(read_only.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
|
||||
let e = read_only.check(Path::new(&f.p("root/out.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("no folder may be written"), "{e}");
|
||||
// A write root may be read too; a read root elsewhere doesn't make it writable.
|
||||
let both = Roots::new(&[f.p("outside")], &[f.p("root")]).unwrap();
|
||||
assert!(both.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
|
||||
assert!(both.check(Path::new(&f.p("outside/secret.txt")), Access::Read).is_ok());
|
||||
assert!(both.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_err());
|
||||
// Several roots of each kind.
|
||||
let many = Roots::new(&[f.p("root"), f.p("root2")], &[f.p("root2"), f.p("outside")]).unwrap();
|
||||
assert!(many.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_ok());
|
||||
assert!(many.check(Path::new(&f.p("root/new.txt")), Access::Write).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roots_must_be_existing_folders() {
|
||||
let f = Fixture::new("bad-roots");
|
||||
assert!(Roots::new(&[f.p("nope")], &[]).unwrap_err().contains("--automation-read-root"));
|
||||
assert!(Roots::new(&[], &[f.p("root/in.txt")]).unwrap_err().contains("not a folder"));
|
||||
assert!(Roots::new(&[], &[String::new()]).is_err());
|
||||
assert!(f.rw().check(Path::new(""), Access::Read).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_that_lead_out_of_a_root_are_outside() {
|
||||
let f = Fixture::new("links");
|
||||
let r = f.rw();
|
||||
if !symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) || !symlink_file(&f.0.join("outside/secret.txt"), &f.0.join("root/secret.txt")) {
|
||||
eprintln!("skipped: this system doesn't let tests make symbolic links");
|
||||
return;
|
||||
}
|
||||
assert!(r.check(Path::new(&f.p("root/escape/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/escape/new.txt")), Access::Write).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Write).is_err());
|
||||
// A link that leads nowhere: writing through it would create its target.
|
||||
assert!(symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")));
|
||||
let e = r.check(Path::new(&f.p("root/dangling.txt")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("leads nowhere"), "{e}");
|
||||
// A link inside the root to the root is fine.
|
||||
assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias")));
|
||||
assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_case_devices_and_streams() {
|
||||
let f = Fixture::new("windows");
|
||||
let r = f.rw();
|
||||
// Case is taken from the disk: another spelling of the root is the root.
|
||||
let upper = f.0.join("ROOT").join("IN.TXT");
|
||||
assert!(r.check(&upper, Access::Read).is_ok());
|
||||
for name in ["CON", "nul.txt", "Com1.png", "LPT9", "a.txt:stream", "conout$"] {
|
||||
assert!(r.check(&f.0.join("root").join(name), Access::Write).is_err(), "{name}");
|
||||
}
|
||||
assert!(r.check(&f.0.join("root").join("console.png"), Access::Write).is_ok());
|
||||
// The `\\?\` form of a path inside is inside; of a path outside, outside.
|
||||
let verbatim = std::fs::canonicalize(f.p("root")).unwrap().join("v.png");
|
||||
assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
|
||||
assert!(r.check(&verbatim, Access::Write).is_ok());
|
||||
assert!(r.check(&std::fs::canonicalize(f.p("outside")).unwrap().join("v.png"), Access::Write).is_err());
|
||||
// Messages don't show the prefix.
|
||||
let e = r.check(Path::new(&f.p("outside/x.png")), Access::Write).unwrap_err();
|
||||
assert!(!e.contains(r"\\?\"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scope_confines_the_thread_and_ends_with_it() {
|
||||
let f = Fixture::new("scope");
|
||||
let roots = Arc::new(f.rw());
|
||||
assert!(current().is_none());
|
||||
confined(Some(&roots), || {
|
||||
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err());
|
||||
assert!(super::read(f.p("outside/secret.txt")).is_err());
|
||||
assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in");
|
||||
// Not confined: the app's own files.
|
||||
assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret");
|
||||
assert!(current().is_some(), "back in the scope after app_owned");
|
||||
// Another thread isn't confined by this one's scope.
|
||||
let p = f.p("outside/secret.txt");
|
||||
assert!(std::thread::spawn(move || current().is_none() && allow(p, Access::Read).is_ok()).join().unwrap());
|
||||
});
|
||||
assert!(current().is_none());
|
||||
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_ok());
|
||||
// A panic inside the scope still ends it.
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(Some(&roots), || panic!("boom"))));
|
||||
assert!(r.is_err());
|
||||
assert!(current().is_none());
|
||||
// `None` changes nothing.
|
||||
assert!(confined(None, || current().is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_run_confined_read_and_write_only_inside() {
|
||||
let f = Fixture::new("commands");
|
||||
std::fs::create_dir_all(f.0.join("outside/readable")).unwrap();
|
||||
let roots = Arc::new(Roots::new(&[f.p("outside/readable")], &[f.p("root")]).unwrap());
|
||||
// Unconfined, the outside is writable: the bait files a confined command must not reach.
|
||||
let bait = f.p("outside/bait.figcraft");
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
|
||||
std::fs::copy(&bait, f.0.join("outside/readable/ok.figcraft")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.prefs.recent_files = vec![bait.clone(), f.p("root/in.txt")];
|
||||
confined(Some(&roots), || {
|
||||
let e = s.execute("file.export", &json!({"format": "png", "path": f.p("outside/x.png")})).unwrap_err().to_string();
|
||||
assert!(e.contains("automation path rejected"), "{e}");
|
||||
assert!(s.execute("file.export", &json!({"format": "png", "path": f.p("root/x.png")})).is_ok());
|
||||
assert!(s.execute("file.exportLayers", &json!({"dir": f.p("outside/layers")})).is_err());
|
||||
assert!(s.execute("file.open", &json!({"path": bait})).is_err());
|
||||
assert!(s.execute("file.import", &json!({"path": bait})).is_err());
|
||||
assert!(s.execute("file.placeImage", &json!({"path": f.p("outside/secret.txt")})).is_err());
|
||||
assert!(s.execute("file.open", &json!({"path": f.p("outside/readable/ok.figcraft")})).is_ok(), "read roots may be read");
|
||||
assert!(s.execute("file.save", &json!({})).is_err(), "but not written");
|
||||
assert!(s.execute("file.saveAs", &json!({"path": f.p("root/ok.figcraft")})).is_ok());
|
||||
// Recent files outside the roots aren't listed.
|
||||
let recent = s.execute("file.openRecent", &json!({})).unwrap().to_string();
|
||||
assert!(!recent.contains("bait") && recent.contains("in.txt"), "{recent}");
|
||||
assert!(s.execute("file.openRecent", &json!({"path": bait})).is_err());
|
||||
});
|
||||
assert!(f.0.join("root/x.png").is_file() && !f.0.join("outside/x.png").exists() && !f.0.join("outside/layers").exists());
|
||||
}
|
||||
|
||||
/// Every command, given paths outside the roots in its usual path parameters, writes nothing
|
||||
/// there and opens nothing from there: they all reach files through `file_access`.
|
||||
#[test]
|
||||
fn no_command_reaches_outside_the_roots() {
|
||||
let f = Fixture::new("sweep");
|
||||
let roots = Arc::new(f.rw());
|
||||
let outside = f.0.join("outside");
|
||||
// Bait the sweep could pick up: a document, an SVG, a PNG and variables JSON, each holding a
|
||||
// name that must never show up in a document or a result.
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "BAIT-LAYER"})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": f.p("outside/bait.figcraft")})).unwrap();
|
||||
s.execute("file.export", &json!({"format": "png", "path": f.p("outside/bait.png")})).unwrap();
|
||||
std::fs::write(
|
||||
outside.join("bait.svg"),
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9"><rect id="BAIT-LAYER" width="9" height="9"/></svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let before: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
// The bait is what an unconfined command finds.
|
||||
for (cmd, bait) in [("file.open", "bait.figcraft"), ("file.import", "bait.svg")] {
|
||||
let mut s = Session::new();
|
||||
s.execute(cmd, &json!({"path": f.p(&format!("outside/{bait}"))})).unwrap();
|
||||
assert!(crate::inspect::document(&s, 64, true).to_string().contains("BAIT-LAYER"), "{cmd} {bait}");
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.placeImage", &json!({"path": f.p("outside/bait.png")})).unwrap();
|
||||
assert!(!s.doc().unwrap().images.is_empty());
|
||||
for bait in ["bait.figcraft", "bait.svg", "bait.png", "new.figcraft", "new.png"] {
|
||||
let path = f.p(&format!("outside/{bait}"));
|
||||
let params = json!({"path": path, "dir": f.p("outside"), "file": path, "variables": path, "folder": f.p("outside"), "confirm": true});
|
||||
for c in crate::all_commands() {
|
||||
let mut s = Session::new();
|
||||
let r = confined(Some(&roots), || s.execute(c.id, ¶ms));
|
||||
let shown = format!("{r:?}");
|
||||
assert!(!shown.contains("BAIT-LAYER"), "{} read {bait}: {shown}", c.id);
|
||||
let docs = crate::inspect::document(&s, 64, true).to_string();
|
||||
assert!(!docs.contains("BAIT-LAYER"), "{} opened {bait}", c.id);
|
||||
assert!(s.docs.iter().all(|ds| ds.doc.images.is_empty()), "{} placed {bait}", c.id);
|
||||
let now: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
assert_eq!(now, before, "{} wrote outside the roots with {bait}", c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_outside_the_roots_are_never_opened_removed_or_named() {
|
||||
let f = Fixture::new("recents");
|
||||
let roots = Arc::new(f.rw());
|
||||
let (bait, gone, ok) = (f.p("outside/bait.figcraft"), f.p("outside/gone.figcraft"), f.p("root/ok.figcraft"));
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": ok})).unwrap();
|
||||
let mut s = Session::new();
|
||||
let recents = vec![bait.clone(), gone.clone(), ok.clone()];
|
||||
s.prefs.recent_files = recents.clone();
|
||||
let quiet = |r: &crate::Result<serde_json::Value>| {
|
||||
let shown = format!("{r:?}");
|
||||
assert!(!shown.contains("bait") && !shown.contains("gone"), "an outside recent file was named: {shown}");
|
||||
};
|
||||
confined(Some(&roots), || {
|
||||
let listing = s.execute("file.openRecent", &json!({}));
|
||||
quiet(&listing);
|
||||
assert_eq!(listing.unwrap()["files"].as_array().unwrap().len(), 1);
|
||||
// `index` counts the files listed: 0 is the first one inside the roots.
|
||||
let opened = s.execute("file.openRecent", &json!({"index": 0}));
|
||||
quiet(&opened);
|
||||
assert!(opened.is_ok(), "{opened:?}");
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), Some(ok.as_str()));
|
||||
for i in [1, 2] {
|
||||
let r = s.execute("file.openRecent", &json!({"index": i}));
|
||||
quiet(&r);
|
||||
assert!(r.is_err());
|
||||
}
|
||||
// A path outside is refused like any outside path, and never dropped from the list.
|
||||
let e = s.execute("file.openRecent", &json!({"path": gone})).unwrap_err().to_string();
|
||||
assert!(e.contains("is outside the folders that may be read") && !e.contains("no longer exists"), "{e}");
|
||||
quiet(&s.execute("file.openRecent", &json!({"remove": bait})));
|
||||
assert!(s.prefs.recent_files.contains(&bait) && s.prefs.recent_files.contains(&gone));
|
||||
// Clear clears what is listed.
|
||||
quiet(&s.execute("file.openRecent", &json!({"clear": true})));
|
||||
});
|
||||
assert_eq!(s.prefs.recent_files, vec![bait, gone]);
|
||||
// Unconfined, a recent file that is gone is still dropped.
|
||||
s.execute("file.openRecent", &json!({"index": 1})).unwrap_err();
|
||||
assert_eq!(s.prefs.recent_files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_paths_are_refused_alike_whatever_exists_there() {
|
||||
let f = Fixture::new("oracle");
|
||||
let r = f.rw();
|
||||
let refusal = |p: &str| r.check(Path::new(p), Access::Write).unwrap_err();
|
||||
let standard = |p: &str| format!("automation path rejected: {} is outside the folders that may be written:", Path::new(p).display());
|
||||
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
|
||||
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
|
||||
outside.push(f.p("outside/dangling.txt"));
|
||||
}
|
||||
for p in &outside {
|
||||
let e = refusal(p);
|
||||
assert!(e.starts_with(&standard(p)), "{p}: {e}");
|
||||
}
|
||||
// Inside a root the reason is given.
|
||||
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
|
||||
assert!(refusal(&f.p("root/dangling.txt")).contains("leads nowhere"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_resolved_paths_count_as_the_apps_own_folder() {
|
||||
let f = Fixture::new("own");
|
||||
let install = f.0.join("root");
|
||||
assert!(super::within_app_folder(&install.join("in.txt"), &install));
|
||||
assert!(!super::within_app_folder(&install.join("..").join("outside").join("secret.txt"), &install), "`..` leaves it");
|
||||
assert!(!super::within_app_folder(&f.0.join("root2"), &install), "a folder named like it is another");
|
||||
assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));
|
||||
assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder")));
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document
|
||||
//! edits and are journaled the same way.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
// Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml).
|
||||
#![cfg_attr(test, allow(clippy::disallowed_methods))]
|
||||
|
||||
pub mod al_keys;
|
||||
pub mod annotations;
|
||||
@@ -25,6 +27,7 @@ pub mod devcompare;
|
||||
pub mod devinspect;
|
||||
pub mod distance;
|
||||
pub mod drop;
|
||||
pub mod file_access;
|
||||
pub mod guard;
|
||||
pub mod guides;
|
||||
pub mod inspect;
|
||||
|
||||
@@ -541,7 +541,7 @@ fn now() -> Option<std::time::SystemTime> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stat(path: &str) -> std::result::Result<Stamp, String> {
|
||||
let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
|
||||
if !m.is_file() {
|
||||
return Err(format!("{path} is not a file"));
|
||||
}
|
||||
@@ -555,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
|
||||
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
|
||||
match figcraft_format::read_library(std::io::BufReader::new(f)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(path) = string(p, "path") {
|
||||
let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0));
|
||||
let png = crate::cmd::file::export_bytes(©, &[inst], ExportFormat::Png, scale)?;
|
||||
std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::file_access::write(&path, &png)?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -147,6 +147,9 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Keep an installed plugin's files in its own folder under the install folder (named by the hash
|
||||
/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without
|
||||
/// an install folder (the browser, the CLI and tests) the plugin lasts for the session.
|
||||
// The install folder is the app's own (set by the desktop app, never by a command), so automation
|
||||
// roots don't apply to it.
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option<String>, String> {
|
||||
let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -189,6 +192,7 @@ fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option
|
||||
}
|
||||
|
||||
/// Delete the folder of a plugin installed from a directory (only one inside the install folder).
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir)
|
||||
@@ -208,7 +212,7 @@ pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
|
||||
fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
|
||||
use crate::plugins::{module_file, read_file};
|
||||
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
||||
if file.is_dir() {
|
||||
if crate::file_access::is_dir(&file) {
|
||||
file.push(MANIFEST_FILE);
|
||||
}
|
||||
let text =
|
||||
@@ -217,9 +221,12 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
|
||||
let wasm = module_file(&file, &m)?;
|
||||
let mut assets = Vec::new();
|
||||
let dir = file.parent().map(|d| d.join(ASSETS));
|
||||
if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) {
|
||||
let mut names: Vec<(String, std::path::PathBuf)> =
|
||||
entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
|
||||
if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) {
|
||||
let mut names: Vec<(String, std::path::PathBuf)> = entries
|
||||
.into_iter()
|
||||
.filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
|
||||
.map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p))
|
||||
.collect();
|
||||
names.sort();
|
||||
if names.len() > MAX_ASSETS {
|
||||
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
|
||||
|
||||
@@ -251,12 +251,11 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
|
||||
/// Read a file of at most `max` bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
let shown = path.display();
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let meta = crate::file_access::metadata(path)?;
|
||||
if !meta.is_file() || meta.len() > max as u64 {
|
||||
return Err(EngineError::Other(format!("{shown}: not a file of at most {} KB", max / 1024)));
|
||||
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
|
||||
@@ -264,7 +263,7 @@ pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_from_disk(cmd: &str, path: &str) -> Result<(Manifest, String, Vec<u8>)> {
|
||||
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
||||
if file.is_dir() {
|
||||
if crate::file_access::is_dir(&file) {
|
||||
file.push(MANIFEST_FILE);
|
||||
}
|
||||
let text = read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?;
|
||||
@@ -295,10 +294,22 @@ pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>
|
||||
let Some(path) = &p.path else {
|
||||
return Err(EngineError::Other(format!("{} was imported without a manifest file: import it again", p.manifest.name)));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return module_file(std::path::Path::new(path), &p.manifest).map(Arc::new);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
|
||||
module_from_disk(s, p, path)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn module_from_disk(s: &Session, p: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
|
||||
let file = std::path::Path::new(path);
|
||||
let read = || module_file(file, &p.manifest).map(Arc::new);
|
||||
// A plugin installed from a directory lives in the app's own install folder, not among the
|
||||
// files automation roots confine.
|
||||
let own = p.source.is_some() && s.plugins.install_dir.as_deref().is_some_and(|d| crate::file_access::within_app_folder(file, d));
|
||||
if own { crate::file_access::app_owned(read) } else { read() }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn module_from_disk(_: &Session, _: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
|
||||
Err(EngineError::Other(format!("{path}: there are no files in the browser")))
|
||||
}
|
||||
|
||||
/// The sandbox limits for a run, with `fuel` lowering the instruction budget.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
|
||||
|
||||
use figcraft_doc::{NodeId, StyleKind};
|
||||
use figcraft_engine::Session;
|
||||
|
||||
@@ -22,10 +22,9 @@ pub trait Backend {
|
||||
fn describe(&self) -> String;
|
||||
|
||||
/// PNG bytes of the document: `ids` (in page coordinates, their union) or, when empty, the
|
||||
/// current page's content. The default goes through `ui.render` and a temporary file.
|
||||
/// current page's content. The default goes through `ui.render`, the PNG coming back as data.
|
||||
fn render_png(&mut self, ids: &[u64], scale: f64) -> Result<Vec<u8>, String> {
|
||||
let path = temp_png("render");
|
||||
let mut params = json!({"path": path, "scale": scale});
|
||||
let mut params = json!({"scale": scale});
|
||||
if !ids.is_empty() {
|
||||
params["ids"] = json!(ids);
|
||||
} else {
|
||||
@@ -36,8 +35,7 @@ pub trait Backend {
|
||||
}
|
||||
params["ids"] = json!(kids);
|
||||
}
|
||||
self.call("ui.render", params)?;
|
||||
read_and_remove(&path)
|
||||
decode_data(&self.call("ui.render", params)?)
|
||||
}
|
||||
|
||||
/// PNG bytes of what the user sees: the app window when attached, the current viewport when
|
||||
@@ -52,11 +50,10 @@ pub trait Backend {
|
||||
}
|
||||
}
|
||||
|
||||
fn capture<B: Backend + ?Sized>(b: &mut B, mut params: Value) -> Result<Vec<u8>, String> {
|
||||
let path = temp_png("screenshot");
|
||||
params["path"] = json!(path);
|
||||
b.call("ui.screenshot", params)?;
|
||||
read_and_remove(&path)
|
||||
/// A window capture, the PNG coming back as data (no file: an app confined to automation roots
|
||||
/// writes none outside them).
|
||||
fn capture<B: Backend + ?Sized>(b: &mut B, params: Value) -> Result<Vec<u8>, String> {
|
||||
decode_data(&b.call("ui.screenshot", params)?)
|
||||
}
|
||||
|
||||
/// The ids of the current page's top-level layers.
|
||||
@@ -72,17 +69,6 @@ pub fn page_children<B: Backend + ?Sized>(b: &mut B) -> Result<Vec<u64>, String>
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn temp_png(what: &str) -> String {
|
||||
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
||||
std::env::temp_dir().join(format!("figcraft-mcp-{what}-{}-{n}.png", std::process::id())).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn read_and_remove(path: &str) -> Result<Vec<u8>, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Decode a `data` field (base64, optionally a data: URL) from an export result.
|
||||
pub fn decode_data(v: &Value) -> Result<Vec<u8>, String> {
|
||||
let d = v.get("data").and_then(Value::as_str).ok_or("the export returned no data")?;
|
||||
|
||||
@@ -215,7 +215,7 @@ impl Headless {
|
||||
let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0));
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
figcraft_engine::file_access::write(path, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(json!({"path": path, "width": w, "height": h, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
|
||||
@@ -278,7 +278,7 @@ impl Backend for Headless {
|
||||
let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0));
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
figcraft_engine::file_access::write(path, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(json!({"path": path, "width": w, "height": h}))
|
||||
}
|
||||
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
|
||||
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_engine::file_access::{Roots, confined};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -42,6 +45,9 @@ Resources: figcraft://document, figcraft://node/{id}, figcraft://command/{id}. p
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
initialized: bool,
|
||||
/// The automation roots every request's file access is confined to (`--automation-read-root`,
|
||||
/// `--automation-write-root`), if any.
|
||||
roots: Option<Arc<Roots>>,
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
@@ -58,7 +64,14 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false }
|
||||
Self { backend, initialized: false, roots: None }
|
||||
}
|
||||
|
||||
/// Confine the files every request reads and writes to `roots` (see
|
||||
/// [`figcraft_engine::file_access`]).
|
||||
pub fn with_roots(mut self, roots: Option<Roots>) -> Self {
|
||||
self.roots = roots.map(Arc::new);
|
||||
self
|
||||
}
|
||||
|
||||
/// A server over a fresh headless session.
|
||||
@@ -150,15 +163,23 @@ impl Server {
|
||||
return Some(error(id, INVALID_PARAMS, "`params` must be an object"));
|
||||
}
|
||||
// A bug that panics fails this request only; the server keeps serving.
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.request(method, ¶ms))).unwrap_or_else(|p| {
|
||||
Err((INTERNAL_ERROR, format!("internal error in `{method}`: {} (please report this bug)", panic_message(p.as_ref()))))
|
||||
});
|
||||
let roots = self.roots.clone();
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(roots.as_ref(), || self.request(method, ¶ms)))).unwrap_or_else(
|
||||
|p| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {} (please report this bug)", panic_message(p.as_ref())))),
|
||||
);
|
||||
Some(match r {
|
||||
Ok(r) => response(id, r),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
|
||||
/// What `initialize` says about the automation roots.
|
||||
fn roots_note(&self) -> String {
|
||||
let Some(r) = &self.roots else { return String::new() };
|
||||
let j = r.to_json();
|
||||
format!(" Files: only these folders may be read: {}; written: {} (any other path is refused).", j["read"], j["write"])
|
||||
}
|
||||
|
||||
fn notification(&mut self, method: &str) {
|
||||
match method {
|
||||
"notifications/initialized" => self.initialized = true,
|
||||
@@ -180,7 +201,7 @@ impl Server {
|
||||
"prompts": {"listChanged": false},
|
||||
},
|
||||
"serverInfo": {"name": "figcraft", "title": "FigCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.{}", self.backend.describe(), self.roots_note()),
|
||||
}))
|
||||
}
|
||||
"ping" => Ok(json!({})),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! `--automation-read-root` / `--automation-write-root` over MCP (#715): a tool call reads only
|
||||
//! inside the read and write roots and writes only inside the write roots.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::file_access::Roots;
|
||||
use figcraft_mcp::Server;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `<tmp>/figcraft-mcp-roots-<name>-<pid>` with `in/` (read root), `out/` (write root),
|
||||
/// `outside/` and `out2/` (named like the write root, outside it).
|
||||
struct Dirs(PathBuf);
|
||||
|
||||
impl Dirs {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-mcp-roots-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["in", "out", "outside", "out2"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
Self(d)
|
||||
}
|
||||
|
||||
fn p(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn server(&self) -> Server {
|
||||
Server::headless().with_roots(Some(Roots::new(&[self.p("in")], &[self.p("out")]).unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Dirs {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn call(s: &mut Server, tool: &str, args: Value) -> Value {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string();
|
||||
let r: Value = serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap();
|
||||
r["result"].clone()
|
||||
}
|
||||
|
||||
fn text(r: &Value) -> String {
|
||||
r["content"][0]["text"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_go_only_into_the_write_root() {
|
||||
let d = Dirs::new("write");
|
||||
let mut s = d.server();
|
||||
assert_eq!(call(&mut s, "create_node", json!({"type": "frame", "width": 40, "height": 20}))["isError"], false);
|
||||
assert_eq!(call(&mut s, "run_command", json!({"command": "export.add"}))["isError"], false);
|
||||
for (tool, args) in [
|
||||
("save_file", json!({"path": d.p("outside/a.figcraft")})),
|
||||
("save_file", json!({"path": d.p("out2/a.figcraft")})),
|
||||
("save_file", json!({"path": d.p("out/../outside/a.figcraft")})),
|
||||
("save_file", json!({"path": d.p("in/a.figcraft")})),
|
||||
("export", json!({"format": "png", "path": d.p("outside/a.png")})),
|
||||
("run_command", json!({"command": "file.export", "params": {"format": "svg", "path": d.p("outside/a.svg")}})),
|
||||
("run_command", json!({"command": "file.exportLayers", "params": {"dir": d.p("outside")}})),
|
||||
("batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": d.p("outside/b.figcraft")}}]})),
|
||||
] {
|
||||
let r = call(&mut s, tool, args.clone());
|
||||
assert_eq!(r["isError"], true, "{tool} {args}: {r}");
|
||||
assert!(text(&r).contains("automation path rejected"), "{tool}: {r}");
|
||||
}
|
||||
for sub in ["outside", "out2", "in"] {
|
||||
assert_eq!(std::fs::read_dir(d.0.join(sub)).unwrap().count(), 0, "nothing written in {sub}");
|
||||
}
|
||||
assert_eq!(call(&mut s, "save_file", json!({"path": d.p("out/a.figcraft")}))["isError"], false);
|
||||
assert_eq!(call(&mut s, "export", json!({"format": "png", "path": d.p("out/a.png")}))["isError"], false);
|
||||
assert!(d.0.join("out/a.figcraft").is_file() && d.0.join("out/a.png").is_file());
|
||||
// Without a path the bytes come back, written nowhere.
|
||||
let r = call(&mut s, "export", json!({"format": "png"}));
|
||||
assert_eq!(r["content"][0]["type"], "image", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_go_only_into_the_read_and_write_roots() {
|
||||
let d = Dirs::new("read");
|
||||
// Files made without roots: one in each folder.
|
||||
let mut free = Server::headless();
|
||||
assert_eq!(call(&mut free, "create_node", json!({"type": "frame", "width": 40, "height": 20, "name": "Made"}))["isError"], false);
|
||||
for f in ["in/a.figcraft", "out/b.figcraft", "outside/c.figcraft"] {
|
||||
assert_eq!(call(&mut free, "export", json!({"format": "svg", "path": d.p(&f.replace(".figcraft", ".svg"))}))["isError"], false);
|
||||
assert_eq!(call(&mut free, "run_command", json!({"command": "file.saveAs", "params": {"path": d.p(f)}}))["isError"], false);
|
||||
}
|
||||
let mut s = d.server();
|
||||
for (tool, args) in [
|
||||
("open_file", json!({"path": d.p("outside/c.figcraft")})),
|
||||
("open_file", json!({"path": d.p("in/../outside/c.figcraft")})),
|
||||
("open_file", json!({"path": d.p("in/a.figcraft"), "variables": d.p("outside/c.svg")})),
|
||||
("run_command", json!({"command": "file.import", "params": {"path": d.p("outside/c.svg")}})),
|
||||
("run_command", json!({"command": "file.placeImage", "params": {"path": d.p("outside/c.svg")}})),
|
||||
("run_command", json!({"command": "library.add", "params": {"path": d.p("outside/c.figcraft")}})),
|
||||
] {
|
||||
let r = call(&mut s, tool, args.clone());
|
||||
assert_eq!(r["isError"], true, "{tool} {args}: {r}");
|
||||
assert!(text(&r).contains("automation path rejected"), "{tool}: {r}");
|
||||
}
|
||||
assert_eq!(call(&mut s, "open_file", json!({"path": d.p("in/a.figcraft")}))["isError"], false);
|
||||
assert_eq!(call(&mut s, "open_file", json!({"path": d.p("out/b.figcraft")}))["isError"], false, "a write root may be read");
|
||||
assert_eq!(call(&mut s, "run_command", json!({"command": "file.import", "params": {"path": d.p("in/a.svg")}}))["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_names_the_roots_and_no_roots_changes_nothing() {
|
||||
let d = Dirs::new("init");
|
||||
let mut s = d.server();
|
||||
let r: Value =
|
||||
serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#).unwrap())
|
||||
.unwrap();
|
||||
let ins = r["result"]["instructions"].as_str().unwrap();
|
||||
assert!(ins.contains("only these folders may be read") && ins.contains("figcraft-mcp-roots-init"), "{ins}");
|
||||
let mut free = Server::headless();
|
||||
let r: Value = serde_json::from_str(
|
||||
&free.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r["result"]["instructions"].as_str().unwrap().contains("only these folders"));
|
||||
assert_eq!(call(&mut free, "create_node", json!({"type": "frame", "width": 4, "height": 4}))["isError"], false);
|
||||
assert_eq!(call(&mut free, "export", json!({"format": "png", "path": d.p("outside/free.png")}))["isError"], false);
|
||||
}
|
||||
@@ -516,7 +516,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
image_pending = true;
|
||||
let target = target.clone();
|
||||
app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
|
||||
let Ok(bytes) = std::fs::read(&p) else { return };
|
||||
let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
|
||||
let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
|
||||
if let Some(mut paint) = current_paint(app, &target) {
|
||||
paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
//! prototype playing (presenter or preview pane)
|
||||
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
|
||||
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
|
||||
//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or
|
||||
//! returned as base64 `data`
|
||||
//! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a
|
||||
//! window), written to `path` or returned as base64 `data`
|
||||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
|
||||
//! windows (desktop)
|
||||
//! - `app.quit {}`
|
||||
@@ -55,7 +57,8 @@ impl ControlRequest {
|
||||
/// Pending screenshot replies.
|
||||
pub struct PendingShot {
|
||||
pub id: Value,
|
||||
pub path: String,
|
||||
/// Where to write the PNG; `None`: return it as data.
|
||||
pub path: Option<String>,
|
||||
pub reply: std::sync::mpsc::Sender<ControlReply>,
|
||||
/// When it was asked for (UI clock, seconds).
|
||||
pub since: f64,
|
||||
@@ -88,7 +91,7 @@ pub fn drain(app: &mut FigApp, ctx: &Context) {
|
||||
if p.viewport != *vp {
|
||||
return true;
|
||||
}
|
||||
let res = save_color_image(img, &p.path).map(|_| json!({"path": p.path, "width": img.size[0], "height": img.size[1]}));
|
||||
let res = shot_reply(img, p.path.as_deref());
|
||||
let _ = p.reply.send(ControlReply { id: p.id.clone(), result: res });
|
||||
false
|
||||
});
|
||||
@@ -110,7 +113,7 @@ pub fn drain(app: &mut FigApp, ctx: &Context) {
|
||||
continue;
|
||||
}
|
||||
if r.method == "ui.screenshot" {
|
||||
let path = r.params.get("path").and_then(Value::as_str).unwrap_or("figcraft-screenshot.png").to_string();
|
||||
let path = r.params.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
let viewport = ctx.viewport_id();
|
||||
list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now, viewport });
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
|
||||
@@ -181,14 +184,28 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> {
|
||||
/// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app
|
||||
/// has them), or returned as base64 `data`.
|
||||
fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result<Value, String> {
|
||||
let [w, h] = img.size;
|
||||
let mut rgba = Vec::with_capacity(w * h * 4);
|
||||
for p in &img.pixels {
|
||||
rgba.extend_from_slice(&p.to_srgba_unmultiplied());
|
||||
}
|
||||
let buf = image::RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or("bad screenshot buffer")?;
|
||||
buf.save(path).map_err(|e| e.to_string())
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
buf.write_to(&mut png, image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
let png = png.into_inner();
|
||||
match path {
|
||||
Some(path) => {
|
||||
figcraft_engine::file_access::write(path, &png).map_err(|e| e.to_string())?;
|
||||
Ok(json!({"path": path, "width": w, "height": h}))
|
||||
}
|
||||
None => {
|
||||
use base64::Engine as _;
|
||||
Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A controller button given as its standard-layout index, its key trigger name ("Gamepad A") or
|
||||
@@ -644,11 +661,13 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
Ok(app.inspect())
|
||||
}
|
||||
"ui.render" => {
|
||||
let path = p.get("path").and_then(Value::as_str).ok_or("`path` required")?;
|
||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
let ids: Vec<figcraft_doc::NodeId> =
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(figcraft_engine::params::id_of).collect()).unwrap_or_default();
|
||||
let mut params = json!({"format": "png", "scale": scale, "path": path});
|
||||
let mut params = json!({"format": "png", "scale": scale});
|
||||
if let Some(path) = p.get("path").and_then(Value::as_str) {
|
||||
params["path"] = json!(path);
|
||||
}
|
||||
if !ids.is_empty() {
|
||||
params["ids"] = json!(ids);
|
||||
}
|
||||
@@ -723,3 +742,7 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result<Value, String> {
|
||||
app.handle_ui_requests();
|
||||
Ok(json!({"selection": app.session.selection(), "tool": app.session.tool.tool.id()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "control_roots_tests.rs"]
|
||||
mod roots_tests;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! An app started with automation roots: its frames and control requests read and write only
|
||||
//! inside them, and captures come back as data without a path.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::file_access::Roots;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
struct Dirs(PathBuf);
|
||||
|
||||
impl Drop for Dirs {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn confined_app(name: &str) -> (FigApp, Dirs) {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-ui-roots-{name}-{}", std::process::id()));
|
||||
for sub in ["out", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
app.automation_roots = Some(Arc::new(Roots::new(&[], &[d.join("out").to_string_lossy().into_owned()]).unwrap()));
|
||||
(app, Dirs(d))
|
||||
}
|
||||
|
||||
/// Control method `method` handled as the desktop app does: inside the frame guard.
|
||||
fn handle(app: &mut FigApp, method: &str, p: Value) -> Result<Value, String> {
|
||||
let ctx = egui::Context::default();
|
||||
let mut res = Err("not run".to_string());
|
||||
crate::windows::guard(app, &ctx, |app| res = super::handle(app, &ctx, method, &p));
|
||||
res
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_confined_app_writes_only_inside_its_write_root() {
|
||||
let (mut app, d) = confined_app("write");
|
||||
let p = |rel: &str| d.0.join(rel).to_string_lossy().into_owned();
|
||||
let e = handle(&mut app, "ui.render", json!({"path": p("outside/r.png")})).unwrap_err();
|
||||
assert!(e.contains("automation path rejected"), "{e}");
|
||||
let e =
|
||||
handle(&mut app, "engine.execute", json!({"command": "file.export", "params": {"format": "svg", "path": p("outside/r.svg")}})).unwrap_err();
|
||||
assert!(e.contains("automation path rejected"), "{e}");
|
||||
assert!(handle(&mut app, "ui.render", json!({"path": p("out/r.png")})).is_ok());
|
||||
// Without a path the PNG comes back as data.
|
||||
let r = handle(&mut app, "ui.render", json!({})).unwrap();
|
||||
assert!(r["data"].as_str().is_some_and(|d| !d.is_empty()), "{r}");
|
||||
assert_eq!(std::fs::read_dir(d.0.join("outside")).unwrap().count(), 0);
|
||||
assert!(d.0.join("out/r.png").is_file());
|
||||
// Outside the guard (the app's own work) nothing is confined.
|
||||
assert!(app.session.execute("file.export", &json!({"format": "svg", "path": p("outside/own.svg")})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_captures_are_written_through_the_roots_or_returned() {
|
||||
let (app, d) = confined_app("capture");
|
||||
let img = egui::ColorImage::filled([3, 2], egui::Color32::RED);
|
||||
let roots = app.automation_roots.clone();
|
||||
let out = d.0.join("out/shot.png").to_string_lossy().into_owned();
|
||||
let outside = d.0.join("outside/shot.png").to_string_lossy().into_owned();
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), || {
|
||||
assert!(super::shot_reply(&img, Some(&outside)).unwrap_err().contains("automation path rejected"));
|
||||
assert_eq!(super::shot_reply(&img, Some(&out)).unwrap()["width"], 3);
|
||||
let data = super::shot_reply(&img, None).unwrap();
|
||||
assert_eq!((data["format"].as_str(), data["height"].as_u64()), (Some("png"), Some(2)));
|
||||
});
|
||||
assert!(d.0.join("out/shot.png").is_file() && !d.0.join("outside/shot.png").exists());
|
||||
}
|
||||
@@ -306,8 +306,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
app.ui.home = false;
|
||||
}
|
||||
}
|
||||
let recents = app.session.prefs.recent_files.clone();
|
||||
if recents.is_empty() {
|
||||
if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() {
|
||||
// A first run: nothing to reopen yet, so offer something to start from.
|
||||
subheading(ui, t, tr("Start from a template"));
|
||||
let shown: Vec<&Template> = TEMPLATES.iter().take(FIRST_RUN).collect();
|
||||
|
||||
@@ -506,7 +506,7 @@ impl Recents {
|
||||
|
||||
/// For `ui.inspect`: each file listed, whether it is there and whether its picture is in.
|
||||
pub fn inspect(&self, prefs: &Prefs) -> Value {
|
||||
let paths: Vec<String> = prefs.recent_files.iter().take(SHOWN).cloned().collect();
|
||||
let paths: Vec<String> = listed(&prefs.recent_files).take(SHOWN).cloned().collect();
|
||||
let files: Vec<Value> = sorted(self.shown(&paths), Sort::of(prefs))
|
||||
.iter()
|
||||
.map(|f| {
|
||||
@@ -525,6 +525,12 @@ impl Recents {
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent files that may be listed: all of them, or, when the app runs confined to automation
|
||||
/// roots, those inside them (no name or picture of another file is shown).
|
||||
pub(crate) fn listed(recents: &[String]) -> impl Iterator<Item = &String> {
|
||||
recents.iter().filter(|p| figcraft_engine::file_access::allowed(p, figcraft_engine::file_access::Access::Read))
|
||||
}
|
||||
|
||||
/// The recent files for `ui.inspect`: `{view, sort, order, files: [{path, name, exists, picture,
|
||||
/// selected}]}` in the order shown (`picture`: `none`, `loading`, `picture`, `page` or
|
||||
/// `placeholder`).
|
||||
@@ -550,7 +556,7 @@ enum Action {
|
||||
|
||||
/// The Recent files heading, its toolbar and the files as cards or a table.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let paths: Vec<String> = app.session.prefs.recent_files.iter().take(SHOWN).cloned().collect();
|
||||
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect();
|
||||
let time = ui.input(|i| i.time);
|
||||
let now = figcraft_engine::cmd::comment::now_secs();
|
||||
let reduce_motion = app.reduce_motion();
|
||||
|
||||
@@ -706,6 +706,9 @@ pub struct FigApp {
|
||||
pending_dialog: Option<PendingDialog>,
|
||||
/// The latest UI context schedules the first poll and wakes the app when a dialog completes.
|
||||
ui_context: Option<egui::Context>,
|
||||
/// The automation roots the app was started with: every frame and control request runs
|
||||
/// confined to them ([`windows::guard`]).
|
||||
pub automation_roots: Option<Arc<figcraft_engine::file_access::Roots>>,
|
||||
}
|
||||
|
||||
/// What to do with the result of a command run in the background.
|
||||
@@ -736,6 +739,7 @@ impl FigApp {
|
||||
waiting: Vec::new(),
|
||||
pending_dialog: None,
|
||||
ui_context: None,
|
||||
automation_roots: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1431,6 +1435,9 @@ impl FigApp {
|
||||
}
|
||||
if !self.has_files() || self.ui.home {
|
||||
home::show(self, ui);
|
||||
// The shortcuts panel belongs to the canvas; it doesn't hang over the home screen
|
||||
// (cleared after the home widgets, which may have just asked for it).
|
||||
self.ui.shortcuts_panel = false;
|
||||
dialogs::keys(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
self.toast_ui(&ctx);
|
||||
|
||||
@@ -301,9 +301,9 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
||||
/// the menu's English label, `label` the one shown. Returns whether an item ran.
|
||||
fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens) -> bool {
|
||||
let picked = submenu(ui, &format!("{prefix}/{key}"), label, t, |ui| {
|
||||
let recents = &app.session.prefs.recent_files;
|
||||
let recents: Vec<&String> = crate::home_recents::listed(&app.session.prefs.recent_files).collect();
|
||||
let mut picked = None;
|
||||
for p in recents.iter() {
|
||||
for p in recents.iter().copied() {
|
||||
let path = std::path::Path::new(p);
|
||||
let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned());
|
||||
let exists = path.exists();
|
||||
|
||||
@@ -188,3 +188,12 @@ fn shortcuts_panel_opens_switches_tabs_and_searches() {
|
||||
u.app.run("help.shortcuts", json!({}));
|
||||
assert!(!u.app.ui.shortcuts_panel, "the command toggles it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_panel_does_not_show_on_the_home_screen() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.shortcuts_panel = true;
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.shortcuts_panel, "the panel closes on the home screen");
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ pub fn with_app<R>(f: impl FnOnce(&mut FigApp) -> R) -> Option<R> {
|
||||
/// Last-resort guard around a frame: a panic becomes a message and recovery copies of unsaved
|
||||
/// files, not a lost document.
|
||||
pub fn guard(app: &mut FigApp, ctx: &Context, f: impl FnOnce(&mut FigApp)) {
|
||||
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut *app)));
|
||||
// Started with automation roots, everything the window and the control channel do is confined.
|
||||
let roots = app.automation_roots.clone();
|
||||
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| figcraft_engine::file_access::confined(roots.as_ref(), || f(&mut *app))));
|
||||
if let Err(e) = res {
|
||||
let msg = e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
log::error!("UI panic: {msg}");
|
||||
|
||||
+5
-2
@@ -156,10 +156,13 @@ figcraft-cli export dashboard.figcraft -o dashboard.png
|
||||
Every command with its label, shortcut, undoable flag (`*`) and parameter docs; `--json` for
|
||||
machines.
|
||||
|
||||
### `mcp [--attach PORT]`
|
||||
### `mcp [--attach PORT] [--automation-read-root DIR]… [--automation-write-root DIR]…`
|
||||
|
||||
Serve MCP on stdio: headless by default, or attached to a running app started with
|
||||
`figcraft --control PORT`. See [mcp.md](mcp.md). The headless session can host, join and publish
|
||||
`figcraft --control PORT`. See [mcp.md](mcp.md). The automation roots (each repeatable; existing
|
||||
folders) confine the files the agent's calls read (inside a read or write root) and write (inside a
|
||||
write root); everything else is refused ([mcp.md › Confining file access](mcp.md#confining-file-access)).
|
||||
With `--attach` they are refused: start the app with them instead. The headless session can host, join and publish
|
||||
live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`,
|
||||
`collab.manageFile` for the owner token and `collab.report`) and use plugin directories
|
||||
(`plugin.directory.*`); the one-shot `run` can't (no network link).
|
||||
|
||||
@@ -53,6 +53,29 @@ window, with its tabs, panels and canvas: `engine.execute` acts on the file it s
|
||||
last in front (the one the user clicked last, or the last `window.new` / `window.focus`). An
|
||||
unknown id is an error.
|
||||
|
||||
## Confining file access
|
||||
|
||||
```sh
|
||||
figcraft --control 7979 --automation-read-root /work/project --automation-write-root /work/project/out
|
||||
```
|
||||
|
||||
`--automation-read-root <dir>` and `--automation-write-root <dir>` (each repeatable, `--flag=<dir>`
|
||||
works too; the same flags as `figcraft-cli mcp`) confine the files the app reads and writes for
|
||||
whoever asks: every control request (`engine.execute`, `ui.render`, `ui.screenshot`, `window.new`,
|
||||
`ui.key`, `ui.menu.invoke`…) and every frame of every window, so input injected with `ui.input`
|
||||
and what a person does in the window are held to the same folders. Files are read only inside a
|
||||
read or write root and written only inside a write root; anything else is an ordinary error reply
|
||||
(`automation path rejected: …`), and recent files outside the roots aren't listed. Paths are
|
||||
resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder
|
||||
stops the app before its window opens. Without the flags nothing changes.
|
||||
|
||||
Not confined: opening the files given on the command line when the app starts and the documents
|
||||
the system hands over (Finder, Open With; saving them later is confined like any write), and the
|
||||
app's own folders (preferences, recovery copies, plugins installed from a plugin directory). Run `figcraft-cli mcp --attach PORT` without the flags against
|
||||
such an app (it refuses them: the app does the confining). `ui.screenshot` and `ui.render` without a
|
||||
`path` return the PNG as data, which is how the attached MCP server takes them, so it writes no
|
||||
temporary files.
|
||||
|
||||
## Methods
|
||||
|
||||
### `engine.execute {command, params?}`
|
||||
@@ -265,15 +288,17 @@ the annotations the canvas shows). `resetFrameStats` (true: start `framesDrawn`
|
||||
`mentions` the picker offers, `devCompare` (`{id, mode, split}` or `null`), `devFocus` (the frame the
|
||||
focus view shows, or `null`) and `annotationFilter`).
|
||||
|
||||
### `ui.screenshot {path}`
|
||||
### `ui.screenshot {path?}`
|
||||
|
||||
PNG of the whole window (`window` picks one), UI included (needs a visible window; an idle one draws the frame to
|
||||
capture). Returns `{path, width, height}`, or an error after 60 s when the window doesn't deliver.
|
||||
capture). Returns `{path, width, height}`, without `path` `{format: "png", width, height, data}`
|
||||
(base64), or an error after 60 s when the window doesn't deliver.
|
||||
|
||||
### `ui.render {path, scale?, ids?}`
|
||||
### `ui.render {path?, scale?, ids?}`
|
||||
|
||||
PNG of the document without UI chrome (works without a window). Without `ids` it exports the
|
||||
selection, else the page's top-level layers. Returns the export result.
|
||||
selection, else the page's top-level layers. Returns the export result: written to `path`, or
|
||||
`{format: "png", data}` (base64) without one.
|
||||
|
||||
### `window.new {path?, index?}`
|
||||
|
||||
|
||||
+56
@@ -26,6 +26,62 @@ If that connection cannot be established, the error says the request was not sen
|
||||
A connection the app closed while it sat idle is noticed before the next request is written and
|
||||
replaced then, so a pause between calls is not an error.
|
||||
|
||||
### Confining file access
|
||||
|
||||
```sh
|
||||
figcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
|
||||
```
|
||||
|
||||
```json
|
||||
{"mcpServers": {"figcraft": {"command": "/path/to/figcraft-cli",
|
||||
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
|
||||
```
|
||||
|
||||
The two flags (the other Craft apps take the same ones) keep an agent to the folders you give it.
|
||||
Each may be given more than once, as `--flag DIR` or `--flag=DIR`:
|
||||
|
||||
- Every file a tool call reads must lie inside a **read root or a write root**; every file it writes
|
||||
inside a **write root**. Anything else is refused with an ordinary tool error the agent can read
|
||||
and correct, for example `automation path rejected: /etc/x.png is outside the folders that may be
|
||||
written: /work/project/out`. Nothing is written and the server keeps serving.
|
||||
- Read and write authority are separate. With only `--automation-read-root` nothing can be written
|
||||
to disk (`export` and the export commands without a `path` still return the bytes). Without either
|
||||
flag nothing changes.
|
||||
- The roots must be existing folders: a missing one, or a file, stops the server at startup.
|
||||
`initialize`'s `instructions` name the folders, so the agent knows where it may work.
|
||||
- **What is covered:** everything a call does, because the engine reaches the file system through
|
||||
one module (`crates/engine/src/file_access.rs`), and `crates/engine/clippy.toml` refuses `std::fs`
|
||||
anywhere else in the engine, so a command added later is covered too. That is `open_file` (and the
|
||||
`variables` file it may name), `save_file`, `export {path}`, and through `run_command` / `batch`
|
||||
every command that names a file: `file.open`, `file.openRecent` (recent files outside the roots
|
||||
are never listed, opened, removed or named: `index` counts the files listed), `file.import`,
|
||||
`file.placeImage`, `image.place`, `file.save`, `file.saveAs`, `file.revert`, `file.recover`,
|
||||
`file.export`, `file.exportSvg`, `file.exportLayers` (and the folders it makes), `file.exportFramesPdf`, the images and variables JSON read next to an imported REST API
|
||||
file, `library.add` and the library files open documents link to, `variable.import` /
|
||||
`variable.export`, `codeConnect.import` / `codeConnect.export`, `component.playground {path}`,
|
||||
`plugin.import {path}`, a development plugin's module read again from disk,
|
||||
`plugin.directory.publish {path}` and its assets, `collab.recoverFile {path}`, and the headless
|
||||
`ui.render` / `ui.screenshot` with a `path`.
|
||||
- **How paths are resolved:** a relative path is made absolute against the server's working
|
||||
directory, then its links are followed: the real path is what is compared and what is opened. For
|
||||
a file that doesn't exist yet, its nearest existing folder is resolved and the rest must be plain
|
||||
names, so `..` below a folder that doesn't exist is refused, and so is a link that leads nowhere
|
||||
(writing through it would create its target, wherever that is). A path outside the roots gets
|
||||
the same refusal whatever exists there, so a refusal doesn't tell what is outside. Folders are
|
||||
compared by whole names: `/work2/x` is not inside `/work`. On Windows the case comes from the disk (`C:\WORK\x` is
|
||||
inside `C:\work`), `\\?\` and UNC (`\\server\share\…`) paths are resolved like drive-letter ones,
|
||||
and new names that are devices (`CON`, `NUL`, `COM1`…) or streams (`a.png:x`) are refused.
|
||||
- Not confined: the app's own folders, which no tool names (preferences, Data Recovery copies,
|
||||
plugins installed from a plugin directory, the system fonts).
|
||||
- `--attach` with either flag is a usage error: the app runs the commands, so only the app can
|
||||
confine them. Start it with the same flags, `figcraft --control 7979 --automation-read-root DIR
|
||||
--automation-write-root DIR` (see [Control protocol](control-protocol.md#confining-file-access)),
|
||||
then attach as usual. The attached `screenshot` gets its PNGs from the app as data, so no
|
||||
temporary file is written anywhere.
|
||||
- A path is checked, then opened: another program that swaps a folder for a link in between can
|
||||
still win that race. The roots keep an agent to the files it was given, not other software on the
|
||||
computer; if that matters, run the server as a user that can't change the roots' folders.
|
||||
|
||||
### Fonts
|
||||
|
||||
The headless server lays text out with the same fonts as the app: the fonts built into FigCraft
|
||||
|
||||
@@ -621,13 +621,16 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
focus ring) and screen-reader names for the custom controls (AccessKit), presentation player with
|
||||
transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs.
|
||||
- **Automation:** desktop control channel (`--control <port>`: execute commands, inspect document/UI,
|
||||
synthetic pointer/keys/typing, menu invocation, screenshots, renders).
|
||||
synthetic pointer/keys/typing, menu invocation, screenshots, renders). What agents read and write
|
||||
on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root` on
|
||||
`figcraft-cli mcp` and the desktop app, #715).
|
||||
- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user