//! The folders automation may read and write (#832). //! //! `vectorcraft-cli mcp --automation-read-root --automation-write-root `, and //! `vectorcraft --control ` 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, write: Option, } #[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 { 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>, 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), Unconfined, } thread_local! { /// This thread's scope; `None`: the process's roots ([`confine_process`]). static SCOPE: RefCell> = const { RefCell::new(None) }; } /// The roots in force in every thread outside [`confine`] and [`unconfined`]. static PROCESS: OnceLock> = 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) -> bool { PROCESS.set(roots).is_ok() } /// Puts the scope it replaced back when dropped (also when `f` panics). struct Restore(Option); impl Drop for Restore { fn drop(&mut self) { let previous = self.0.take(); SCOPE.with(|s| *s.borrow_mut() = previous); } } fn with_scope(scope: Option, 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(roots: Option<&Arc>, 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(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); 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(self, f: impl FnOnce() -> T) -> T { with_scope(self.0, f) } } /// The roots in force on this thread, if any. pub fn current() -> Option> { 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 { 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 = 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) { 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"), "").unwrap(); std::fs::write(base.join("outside/secret.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"), "").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}"); } } }