//! The folders automation may read and write. //! //! `deckcraft-cli mcp --automation-read-root --automation-write-root ` confines the //! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command //! reads goes through [`check_read`] and every path it writes through [`check_write`]: //! //! - The path is made absolute against the working directory, as the file system reads it. Its //! links are followed: for a file that doesn't exist yet, the links of its nearest existing //! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so //! is a link that leads nowhere). //! - The result must lie inside the root, compared by whole components (`/work2` isn't inside //! `/work`), without regard to case on Windows. //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Roots are in force on a thread inside [`confine`], and in the whole process after //! [`confine_process`] (`deckcraft-cli mcp`), except inside [`unconfined`]. Without roots //! nothing is checked. //! //! A path is checked, then opened: a process that can change the folders in between (swapping in //! a link) can still win that race. The roots keep an agent to the files it was given; they don't //! guard against other programs on the computer. use std::cell::RefCell; use std::ffi::OsString; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, OnceLock}; /// Read access or write access. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Access { Read, Write, } /// The folders automation may read and write; a root left out grants no access of its kind. #[derive(Debug)] pub struct AutomationRoots { read: Vec, write: Vec, } #[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: plain(resolved.clone()), resolved }) } } impl AutomationRoots { /// The read and write roots, which must be existing folders; `None` when both are empty /// (nothing is confined then). pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result>, String> { if read.is_empty() && write.is_empty() { return Ok(None); } let read = read.iter().map(|p| Root::open(p, Access::Read)).collect::>()?; let write = write.iter().map(|p| Root::open(p, Access::Write)).collect::>()?; Ok(Some(Arc::new(Self { read, write }))) } /// The read roots, absolute with their links followed. pub fn read_roots(&self) -> impl Iterator { self.read.iter().map(|r| r.shown.as_path()) } /// The write roots, absolute with their links followed. pub fn write_roots(&self) -> impl Iterator { self.write.iter().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 roots = match access { Access::Read => &self.read, Access::Write => &self.write, }; if roots.is_empty() { 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}")), }; } let resolved = resolve(path)?; if roots.iter().any(|root| inside(&resolved, &root.resolved)) { return Ok(()); } let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::>().join(", "); match access { Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")), Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {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) } /// 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(), } } /// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. fn plain(path: PathBuf) -> PathBuf { if !cfg!(windows) { return path; } let text = path.to_string_lossy(); if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { return PathBuf::from(format!(r"\\{rest}")); } match text.strip_prefix(r"\\?\") { Some(rest) => PathBuf::from(rest), None => path, } } /// May `path` be read here? Always, without roots in force. pub fn check_read(path: &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!("dc-file-access-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); for d in ["in/sub", "out", "outside", "in2"] { std::fs::create_dir_all(base.join(d)).unwrap(); } std::fs::write(base.join("in/a.deckcraft"), "x").unwrap(); std::fs::write(base.join("outside/secret.deckcraft"), "x").unwrap(); let roots = AutomationRoots::new(&[base.join("in")], &[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.deckcraft")), 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.deckcraft")), 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.deckcraft")), 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.deckcraft")), 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 any_configured_root_grants_access() { let (base, _) = folders("multiple"); let roots = AutomationRoots::new(&[base.join("in"), base.join("in2")], &[base.join("out"), base.join("outside")]).unwrap().unwrap(); assert_eq!(roots.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(())); assert_eq!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read), Ok(())); assert_eq!(roots.check(&s(base.join("out/new.deckcraft")), Access::Write), Ok(())); assert_eq!(roots.check(&s(base.join("outside/new.deckcraft")), Access::Write), Ok(())); 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.deckcraft"), "x").unwrap(); assert!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read).is_err(), "in2 is not inside in"); assert!(roots.check(&format!("{}2/c.deckcraft", 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.deckcraft", 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.deckcraft", 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.deckcraft", 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.deckcraft", 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(&[base.join("nope")], &[]).unwrap_err(); assert!(e.starts_with("cannot open the automation read root"), "{e}"); let e = AutomationRoots::new(&[], &[base.join("in/a.deckcraft")]).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(&[PathBuf::new()], &[]).unwrap_err().starts_with("cannot open the automation read root ``")); assert!(AutomationRoots::new(&[], &[]).unwrap().is_none()); // One root alone grants only its own access. let read_only = AutomationRoots::new(&[base.join("in")], &[]).unwrap().unwrap(); let e = read_only.check(&s(base.join("in/x.deckcraft")), 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.deckcraft"), &base.join("in/leak.deckcraft")) { 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.deckcraft")), 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.deckcraft"), &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.deckcraft")), 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(&[base.join("in-link")], &[]).unwrap().unwrap(); assert_eq!(linked.check(&s(base.join("in/a.deckcraft")), 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.deckcraft")); assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); assert_eq!(roots.check(&s(base.join("In/Sub/New.deckcraft")), Access::Read), Ok(())); let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); assert!(e.contains("reserved device names"), "{e}"); assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); let _ = std::fs::remove_dir_all(base); } #[test] fn scopes_nest_and_stay_on_their_thread() { let (base, roots) = folders("scopes"); let outside = s(base.join("outside/secret.deckcraft")); assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); confine(Some(&roots), || { assert!(check_read(&outside).is_err()); assert_eq!(unconfined(|| check_read(&outside)), Ok(())); assert!(check_read(&outside).is_err(), "restored after unconfined"); let o = outside.clone(); let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); assert!(there, "another thread is not confined by this thread's scope"); }); assert_eq!(check_read(&outside), Ok(()), "restored after confine"); let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); assert!(r.is_err()); assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); assert_eq!(confine(None, || check_read(&outside)), Ok(())); let _ = std::fs::remove_dir_all(base); } #[test] fn device_names() { for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL "] { assert!(is_device_name(d), "{d}"); } for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x"] { assert!(!is_device_name(f), "{f}"); } } /// Every file a command reads or writes by path goes through `io` (`read_file`, /// `write_file`), which check the roots. Engine code that reaches for `std::fs` itself would /// bypass them, so none may: every file under `src/` (commands, `lib.rs`, `guard.rs`...) /// except `io.rs`, this module, `recovery.rs` (the recovery folder is set by the host /// application, never by a command's path, and a headless session has none) and the test /// files. #[test] fn commands_do_not_touch_the_file_system_themselves() { fn walk(dir: &Path, files: &mut Vec) { for entry in std::fs::read_dir(dir).unwrap() { let path = entry.unwrap().path(); if path.is_dir() { walk(&path, files); } else if path.extension().is_some_and(|e| e == "rs") { files.push(path); } } } let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut files = vec![]; walk(&src, &mut files); assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); let mut offenders = vec![]; for path in files { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); // `io` and this module are the checked gateway; `tests*.rs` are test-only modules. // `recovery` and `spell` touch only the app's own folders (AutoRecover files, the // system and user spelling dictionaries), never a path a command names. if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "recovery.rs" | "spell.rs") || name.starts_with("tests") { continue; } let text = std::fs::read_to_string(&path).unwrap(); // Test modules (which build their own fixtures) come last in each file; a // `#[cfg(test)]` import near the top doesn't start them. let mut code = text.as_str(); let mut from = 0; while let Some(i) = text[from..].find("#[cfg(test)]") { let at = from + i; let next = text[at + "#[cfg(test)]".len()..].trim_start(); if ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|p| next.starts_with(p)) { from = at + 1; continue; } code = &text[..at]; break; } for (n, line) in code.lines().enumerate() { let line = line.trim_start(); let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w)); if !line.starts_with("//") && touches { offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); } } } assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file}}: {offenders:#?}"); } }