Files
OneCAD-Simulation/crates/io/src/roots.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

235 lines
10 KiB
Rust

//! Folders an automation client may read from and write to.
//!
//! An agent driving FieldCraft (the MCP server, docs/mcp.md) can be kept to the folders it was
//! given: every file a command reads must lie inside the read root, every file it writes inside
//! the write root. [`Roots::check`] decides, before the file is touched:
//!
//! - The path is made absolute against the working folder, then its links are followed: a file
//! (or folder) link that leads out of a root is outside it. For a file that does not exist yet,
//! its nearest existing folder's links are followed and the rest must be plain names, so `..`
//! below a folder that does not exist is refused, as is a link that leads nowhere (writing
//! through it would create its target).
//! - The result is compared with the root by whole folder names: `/work2/x` is not inside
//! `/work`.
//! - Read and write access are separate; a root left out grants none of its access. Without
//! either root ([`Roots::default`]) nothing is confined.
//!
//! A path is checked, then opened: another program that swaps a link in between can still win
//! that race, so the roots keep an agent to the files it was given rather than guarding against
//! other software on the computer.
use std::path::{Component, Path, PathBuf};
use crate::{Error, Result};
/// What a command does with a file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
impl Access {
fn name(self) -> &'static str {
match self {
Access::Read => "read",
Access::Write => "write",
}
}
}
/// The folders file access is confined to; the default confines nothing.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Roots {
/// `None` while unconfined; otherwise the resolved read and write roots (each `None` when
/// that access is not granted).
confined: Option<[Option<PathBuf>; 2]>,
}
impl Roots {
/// Nothing confined.
pub fn unconfined() -> Roots {
Roots::default()
}
/// Confine reads to `read` and writes to `write`. A root left out grants none of its access.
/// Each root must be an existing folder; it is resolved (links followed) once, here.
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Roots> {
let resolve = |p: Option<&Path>, access: Access| -> Result<Option<PathBuf>> {
let Some(p) = p else { return Ok(None) };
let shown = p.display();
let dir = std::fs::canonicalize(p).map_err(|e| Error::Io(format!("the {} root `{shown}`: {e}", access.name())))?;
if !dir.is_dir() {
return Err(Error::Io(format!("the {} root `{shown}` is not a folder", access.name())));
}
Ok(Some(dir))
};
Ok(Roots { confined: Some([resolve(read, Access::Read)?, resolve(write, Access::Write)?]) })
}
/// Whether any access is confined.
pub fn is_confined(&self) -> bool {
self.confined.is_some()
}
/// The root of `access`, when confined and granted.
pub fn root(&self, access: Access) -> Option<&Path> {
let roots = self.confined.as_ref()?;
roots.get(access as usize).and_then(|r| r.as_deref())
}
/// The path to use for `access` to `path`: unchanged while unconfined, else the resolved path
/// (absolute, links followed), which lies inside the root. Refused with an error naming the
/// root and the path.
pub fn check(&self, path: &Path, access: Access) -> Result<PathBuf> {
let Some(roots) = &self.confined else { return Ok(path.to_path_buf()) };
let shown = path.display();
let Some(root) = roots.get(access as usize).and_then(|r| r.as_deref()) else {
return Err(Error::Refused(format!("automation filesystem access is not granted: {} authority is absent: {shown}", access.name())));
};
let resolved = resolve(path).map_err(|why| Error::Refused(format!("automation path rejected: {why}: {shown}")))?;
if !resolved.starts_with(root) {
return Err(Error::Refused(format!("automation path rejected: outside the {} root {}: {shown}", access.name(), root.display())));
}
Ok(resolved)
}
}
/// `path` absolute, its links followed: wholly when it exists, else up to its nearest existing
/// folder, below which only plain names may follow.
fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
if path.as_os_str().is_empty() {
return Err("empty path".into());
}
#[cfg(windows)]
if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
const DEVICES: [&str; 22] = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4",
"LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if DEVICES.iter().any(|d| d.eq_ignore_ascii_case(name.trim_end())) {
return Err("a device name".into());
}
}
let absolute = std::path::absolute(path).map_err(|e| e.to_string())?;
if let Ok(p) = std::fs::canonicalize(&absolute) {
return Ok(p);
}
// A link that leads nowhere: writing through it would create its target.
if std::fs::symlink_metadata(&absolute).is_ok() {
return Err("a link that leads nowhere".into());
}
let mut rest = Vec::new();
let mut base = absolute.as_path();
loop {
let Some(parent) = base.parent() else { return Err("no existing folder above it".into()) };
let Some(name) = base.file_name() else { return Err("not a plain file name".into()) };
rest.push(name.to_os_string());
base = parent;
if let Ok(dir) = std::fs::canonicalize(base) {
if !dir.is_dir() {
return Err(format!("{} is not a folder", base.display()));
}
let mut out = dir;
for name in rest.iter().rev() {
out.push(name);
}
return Ok(out);
}
if std::fs::symlink_metadata(base).is_ok() {
return Err("a link that leads nowhere".into());
}
// `..` or `.` below a folder that does not exist cannot be resolved.
if !matches!(Path::new(base).components().next_back(), Some(Component::Normal(_))) {
return Err("`..` below a folder that does not exist".into());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Dir(PathBuf);
impl Dir {
fn new(tag: &str) -> Dir {
let d = std::env::temp_dir().join(format!("fieldcraft-roots-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
Dir(std::fs::canonicalize(&d).unwrap())
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn unconfined_changes_nothing() {
let r = Roots::unconfined();
assert!(!r.is_confined());
assert_eq!(r.check(Path::new("/etc/passwd"), Access::Write).unwrap(), Path::new("/etc/passwd"));
}
#[test]
fn reads_and_writes_stay_inside_their_roots() {
let d = Dir::new("inside");
let (read, write) = (d.0.join("in"), d.0.join("in").join("out"));
std::fs::create_dir_all(&write).unwrap();
std::fs::write(read.join("a.solidcraft"), b"x").unwrap();
let r = Roots::new(Some(&read), Some(&write)).unwrap();
assert_eq!(r.check(&read.join("a.solidcraft"), Access::Read).unwrap(), read.join("a.solidcraft"));
assert_eq!(r.check(&write.join("new.inp"), Access::Write).unwrap(), write.join("new.inp"));
// A new file in a new folder below the root: plain names only.
assert_eq!(r.check(&write.join("x/y/z.inp"), Access::Write).unwrap(), write.join("x/y/z.inp"));
assert!(r.check(&write.join("x/../../../z.inp"), Access::Write).is_err());
// `..` through an existing folder resolves first, then is compared.
assert!(r.check(&write.join("../a.solidcraft"), Access::Write).is_err());
assert!(r.check(&write.join("../a.solidcraft"), Access::Read).is_ok());
let e = r.check(&read.join("x.png"), Access::Write).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{e}");
assert!(r.check(Path::new("/etc/passwd"), Access::Read).is_err());
// Whole folder names: `in2` is not inside `in`.
std::fs::create_dir_all(d.0.join("in2")).unwrap();
assert!(r.check(&d.0.join("in2/a"), Access::Read).is_err());
assert!(r.check(Path::new(""), Access::Read).is_err());
}
#[test]
fn a_root_left_out_grants_nothing() {
let d = Dir::new("readonly");
let r = Roots::new(Some(&d.0), None).unwrap();
assert!(r.is_confined());
let e = r.check(&d.0.join("x.inp"), Access::Write).unwrap_err().to_string();
assert!(e.contains("write authority is absent"), "{e}");
assert!(r.check(&d.0.join("x.inp"), Access::Read).is_ok());
assert!(Roots::new(Some(&d.0.join("missing")), None).is_err());
std::fs::write(d.0.join("file"), b"").unwrap();
assert!(Roots::new(None, Some(&d.0.join("file"))).is_err(), "a root is a folder");
}
#[cfg(unix)]
#[test]
fn links_are_followed() {
let d = Dir::new("links");
let (root, outside) = (d.0.join("root"), d.0.join("outside"));
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret"), b"x").unwrap();
std::os::unix::fs::symlink(outside.join("secret"), root.join("file-link")).unwrap();
std::os::unix::fs::symlink(&outside, root.join("dir-link")).unwrap();
std::os::unix::fs::symlink(outside.join("nowhere"), root.join("dangling")).unwrap();
std::os::unix::fs::symlink(&root, outside.join("back")).unwrap();
let r = Roots::new(Some(&root), Some(&root)).unwrap();
for p in [root.join("file-link"), root.join("dir-link/secret"), root.join("dir-link/new.inp")] {
for a in [Access::Read, Access::Write] {
assert!(r.check(&p, a).is_err(), "{} {a:?}", p.display());
}
}
assert!(r.check(&root.join("dangling"), Access::Write).unwrap_err().to_string().contains("leads nowhere"));
// A link from outside that leads in is inside.
assert_eq!(r.check(&outside.join("back/x.inp"), Access::Write).unwrap(), root.join("x.inp"));
}
}