mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
vectorcraft-cli mcp and vectorcraft --control take the flags PhotoCraft takes. While roots are in force, every path a command reads (open, place, relink, SVG image links, library and profile loads, plug-ins, preset imports, font searches) must lie inside the read root and every path it writes (save, export, package, Export for Screens and for Web, print to file, captures, library saves) inside the write root. Paths are made absolute, links are followed (a missing file's nearest folder's), and the result is compared by whole components, ignoring case on Windows; `..` below a missing folder, dangling links and device names are refused. A root left out grants none of its access; without the flags nothing changes. The checks live in one place, vectorcraft_engine::file_access, consulted by the engine's file helpers and the app's file services. The headless server confines its whole process; the app confines control requests, the frames carrying input they inject and the background saves they start, leaving the person at the keyboard and the app's own folders alone. The screenshot tool now has the backend write its file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
442 lines
20 KiB
Rust
442 lines
20 KiB
Rust
//! The folders automation may read and write (#832).
|
|
//!
|
|
//! `vectorcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`, and
|
|
//! `vectorcraft --control <port>` with the same flags, confine the files an agent's commands
|
|
//! reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
|
|
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
|
|
//!
|
|
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
|
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
|
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
|
//! is a link that leads nowhere).
|
|
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
|
//! `/work`), without regard to case on Windows.
|
|
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
|
//!
|
|
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
|
//! [`confine_process`] (`vectorcraft-cli mcp`, the desktop app with a control port), except inside
|
|
//! [`unconfined`]: what the person at the keyboard does, and the app's own folders (preferences,
|
|
//! Data Recovery, the library folders, VectorCraft's Fonts folder), which no agent names. Without
|
|
//! roots nothing is checked.
|
|
//!
|
|
//! A path is checked, then opened: a process that can change the folders in between (swapping in
|
|
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
|
|
//! guard against other programs on the computer.
|
|
|
|
use std::cell::RefCell;
|
|
use std::ffi::OsString;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
/// Read access or write access.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Access {
|
|
Read,
|
|
Write,
|
|
}
|
|
|
|
/// The folders automation may read and write; a root left out grants no access of its kind.
|
|
#[derive(Debug)]
|
|
pub struct AutomationRoots {
|
|
read: Option<Root>,
|
|
write: Option<Root>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Root {
|
|
/// The folder made absolute, links followed: what paths are compared with.
|
|
resolved: PathBuf,
|
|
/// The same without Windows' `\\?\` prefix, for people.
|
|
shown: PathBuf,
|
|
}
|
|
|
|
impl Root {
|
|
/// The folder at `path`, which must exist.
|
|
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
|
let kind = match access {
|
|
Access::Read => "read",
|
|
Access::Write => "write",
|
|
};
|
|
let resolved = std::fs::canonicalize(path)
|
|
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
|
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
|
Ok(Self { shown: crate::cmd::findfiles::plain(resolved.clone()), resolved })
|
|
}
|
|
}
|
|
|
|
impl AutomationRoots {
|
|
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
|
/// (nothing is confined then).
|
|
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
|
if read.is_none() && write.is_none() {
|
|
return Ok(None);
|
|
}
|
|
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
|
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
|
Ok(Some(Arc::new(Self { read, write })))
|
|
}
|
|
|
|
/// The read root, absolute with its links followed.
|
|
pub fn read_root(&self) -> Option<&Path> {
|
|
self.read.as_ref().map(|r| r.shown.as_path())
|
|
}
|
|
|
|
/// The write root, absolute with its links followed.
|
|
pub fn write_root(&self) -> Option<&Path> {
|
|
self.write.as_ref().map(|r| r.shown.as_path())
|
|
}
|
|
|
|
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
|
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
|
let root = match access {
|
|
Access::Read => self.read.as_ref(),
|
|
Access::Write => self.write.as_ref(),
|
|
};
|
|
let Some(Root { resolved: folder, shown }) = root else {
|
|
return match access {
|
|
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
|
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
|
};
|
|
};
|
|
if inside(&resolve(path)?, folder) {
|
|
return Ok(());
|
|
}
|
|
let root = shown.display();
|
|
match access {
|
|
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
|
|
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
|
|
#[derive(Clone, Debug)]
|
|
enum Scope {
|
|
Confined(Arc<AutomationRoots>),
|
|
Unconfined,
|
|
}
|
|
|
|
thread_local! {
|
|
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
|
|
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
|
|
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
|
|
|
|
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
|
|
/// (they stay as they were).
|
|
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
|
|
PROCESS.set(roots).is_ok()
|
|
}
|
|
|
|
/// Puts the scope it replaced back when dropped (also when `f` panics).
|
|
struct Restore(Option<Scope>);
|
|
|
|
impl Drop for Restore {
|
|
fn drop(&mut self) {
|
|
let previous = self.0.take();
|
|
SCOPE.with(|s| *s.borrow_mut() = previous);
|
|
}
|
|
}
|
|
|
|
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
|
|
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
|
|
f()
|
|
}
|
|
|
|
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
|
|
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
|
|
match roots {
|
|
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
|
|
None => f(),
|
|
}
|
|
}
|
|
|
|
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
|
|
/// app's own folders.
|
|
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
|
|
with_scope(Some(Scope::Unconfined), f)
|
|
}
|
|
|
|
/// This thread's scope, to carry to work it hands to another thread ([`ScopeHandle::run`]).
|
|
#[derive(Clone, Debug)]
|
|
pub struct ScopeHandle(Option<Scope>);
|
|
|
|
impl ScopeHandle {
|
|
/// The scope of the calling thread.
|
|
pub fn capture() -> Self {
|
|
Self(SCOPE.with(|s| s.borrow().clone()))
|
|
}
|
|
|
|
/// Run `f` in the captured scope.
|
|
pub fn run<T>(self, f: impl FnOnce() -> T) -> T {
|
|
with_scope(self.0, f)
|
|
}
|
|
}
|
|
|
|
/// The roots in force on this thread, if any.
|
|
pub fn current() -> Option<Arc<AutomationRoots>> {
|
|
match SCOPE.with(|s| s.borrow().clone()) {
|
|
Some(Scope::Confined(r)) => Some(r),
|
|
Some(Scope::Unconfined) => None,
|
|
None => PROCESS.get().cloned(),
|
|
}
|
|
}
|
|
|
|
/// May `path` be read here? Always, without roots in force.
|
|
pub fn check_read(path: &str) -> Result<(), String> {
|
|
current().map_or(Ok(()), |r| r.check(path, Access::Read))
|
|
}
|
|
|
|
/// May `path` be written here? Always, without roots in force.
|
|
pub fn check_write(path: &str) -> Result<(), String> {
|
|
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
|
}
|
|
|
|
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
|
/// existing folder's links followed and the rest appended.
|
|
fn resolve(path: &str) -> Result<PathBuf, String> {
|
|
if path.is_empty() {
|
|
return Err("automation path rejected: path is empty".into());
|
|
}
|
|
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
|
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
|
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
|
}
|
|
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
|
// The names below the nearest existing folder, last first.
|
|
let mut missing: Vec<OsString> = vec![];
|
|
let mut at = absolute.as_path();
|
|
loop {
|
|
match std::fs::canonicalize(at) {
|
|
Ok(mut found) => {
|
|
found.extend(missing.iter().rev());
|
|
return Ok(found);
|
|
}
|
|
Err(e) => match std::fs::symlink_metadata(at) {
|
|
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
|
// There, yet its real path can't be had (no permission): nothing to compare.
|
|
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
|
},
|
|
}
|
|
let parent = at.parent();
|
|
match (at.components().next_back(), parent) {
|
|
(Some(Component::Normal(name)), Some(parent)) => {
|
|
missing.push(name.to_os_string());
|
|
at = parent;
|
|
}
|
|
(Some(Component::ParentDir), _) => {
|
|
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
|
}
|
|
// Down to the root (or drive) and it doesn't exist either.
|
|
_ => {
|
|
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
|
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
|
/// Windows, whose file systems ignore it)?
|
|
fn inside(path: &Path, root: &Path) -> bool {
|
|
let mut path = path.components();
|
|
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
|
}
|
|
|
|
fn same_component(a: Component, b: Component) -> bool {
|
|
if a == b {
|
|
return true;
|
|
}
|
|
if !cfg!(windows) {
|
|
return false;
|
|
}
|
|
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
|
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
|
fn is_device_name(component: &str) -> bool {
|
|
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
|
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
|
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
|
|
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
|
let base = std::env::temp_dir().join(format!("vc-file-access-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
for d in ["in/sub", "out", "outside", "in2"] {
|
|
std::fs::create_dir_all(base.join(d)).unwrap();
|
|
}
|
|
std::fs::write(base.join("in/a.svg"), "<svg/>").unwrap();
|
|
std::fs::write(base.join("outside/secret.svg"), "<svg/>").unwrap();
|
|
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
|
(base, roots)
|
|
}
|
|
|
|
fn s(p: PathBuf) -> String {
|
|
p.to_string_lossy().into_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn paths_inside_the_roots_pass_and_others_dont() {
|
|
let (base, roots) = folders("inside");
|
|
assert_eq!(roots.check(&s(base.join("in/a.svg")), Access::Read), Ok(()));
|
|
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
|
|
assert_eq!(roots.check(&s(base.join("in/sub/new.svg")), Access::Read), Ok(()), "missing files are checked by their folder");
|
|
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
|
|
let e = roots.check(&s(base.join("outside/secret.svg")), Access::Read).unwrap_err();
|
|
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
|
|
// Read and write access are separate.
|
|
let e = roots.check(&s(base.join("in/a.svg")), Access::Write).unwrap_err();
|
|
assert!(e.contains("outside the write root"), "{e}");
|
|
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn a_sibling_with_the_same_prefix_is_outside() {
|
|
let (base, roots) = folders("prefix");
|
|
std::fs::write(base.join("in2/b.svg"), "<svg/>").unwrap();
|
|
assert!(roots.check(&s(base.join("in2/b.svg")), Access::Read).is_err(), "in2 is not inside in");
|
|
assert!(roots.check(&format!("{}2/c.svg", s(base.join("in"))), Access::Read).is_err());
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn parent_traversal_is_resolved_or_refused() {
|
|
let (base, roots) = folders("dotdot");
|
|
let sep = std::path::MAIN_SEPARATOR;
|
|
let up = format!("{}{sep}..{sep}outside{sep}secret.svg", s(base.join("in")));
|
|
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
|
|
let back = format!("{}{sep}..{sep}a.svg", s(base.join("in/sub")));
|
|
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
|
|
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.svg", s(base.join("out")));
|
|
let e = roots.check(&missing, Access::Write).unwrap_err();
|
|
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
|
|
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn relative_paths_resolve_against_the_working_directory() {
|
|
let (base, roots) = folders("relative");
|
|
// The tests' working directory is the crate's folder: not inside the roots.
|
|
assert!(roots.check("a.svg", Access::Read).unwrap_err().contains("outside the read root"));
|
|
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
|
let (base, _) = folders("missing");
|
|
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
|
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
|
let e = AutomationRoots::new(None, Some(&base.join("in/a.svg"))).unwrap_err();
|
|
assert!(
|
|
e.starts_with("cannot open the automation write root")
|
|
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
|
"{e}"
|
|
);
|
|
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
|
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
|
// One root alone grants only its own access.
|
|
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
|
let e = read_only.check(&s(base.join("in/x.svg")), Access::Write).unwrap_err();
|
|
assert!(e.contains("write authority is absent"), "{e}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn links_are_followed_before_comparing() {
|
|
let (base, roots) = folders("links");
|
|
#[cfg(unix)]
|
|
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
|
|
#[cfg(windows)]
|
|
let made = |target: &Path, link: &Path| {
|
|
let made =
|
|
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
|
|
made.is_ok()
|
|
};
|
|
#[cfg(not(any(unix, windows)))]
|
|
let made = |_: &Path, _: &Path| false;
|
|
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
|
|
if !made(&base.join("outside/secret.svg"), &base.join("in/leak.svg")) {
|
|
eprintln!("skipped: symbolic links can't be made here");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
return;
|
|
}
|
|
assert!(roots.check(&s(base.join("in/leak.svg")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
|
|
assert!(made(&base.join("outside"), &base.join("out/away")));
|
|
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
|
|
assert!(e.contains("outside the write root"), "a folder link out: {e}");
|
|
assert!(made(&base.join("outside/gone.svg"), &base.join("out/dangling.png")));
|
|
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
|
|
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
|
|
// A link to a folder inside the root stays inside.
|
|
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
|
assert_eq!(roots.check(&s(base.join("in/alias/x.svg")), Access::Read), Ok(()));
|
|
// A root given through a link is the folder it leads to.
|
|
assert!(made(&base.join("in"), &base.join("in-link")));
|
|
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
|
assert_eq!(linked.check(&s(base.join("in/a.svg")), Access::Read), Ok(()));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_ignores_case_and_refuses_devices() {
|
|
let (base, roots) = folders("case");
|
|
let upper = s(base.join("IN/A.SVG"));
|
|
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
|
assert_eq!(roots.check(&s(base.join("In/Sub/New.svg")), Access::Read), Ok(()));
|
|
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
|
|
assert!(e.contains("reserved device names"), "{e}");
|
|
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
|
|
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn scopes_nest_and_carry_to_other_threads() {
|
|
let (base, roots) = folders("scopes");
|
|
let outside = s(base.join("outside/secret.svg"));
|
|
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
|
|
confine(Some(&roots), || {
|
|
assert!(check_read(&outside).is_err());
|
|
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
|
|
assert!(check_read(&outside).is_err(), "restored after unconfined");
|
|
let handle = ScopeHandle::capture();
|
|
let o = outside.clone();
|
|
let there = std::thread::spawn(move || (check_read(&o).is_ok(), handle.run(|| check_read(&o).is_ok()))).join().unwrap();
|
|
assert_eq!(there, (true, false), "another thread is confined only in the captured scope");
|
|
});
|
|
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
|
|
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
|
|
assert!(r.is_err());
|
|
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
|
|
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn device_names() {
|
|
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.svg", "NUL "] {
|
|
assert!(is_device_name(d), "{d}");
|
|
}
|
|
for f in ["console", "com10", "COM0", "lpt", "a.svg", "nul-x"] {
|
|
assert!(!is_device_name(f), "{f}");
|
|
}
|
|
}
|
|
}
|