mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 15:48:18 +00:00
Engine: check automation read and write roots on every path a command reads or writes
This commit is contained in:
1 parent
be14248577
commit
dc6ea04a8f
5 files changed
+519
-12
No files matched your search
@@ -85,7 +85,7 @@ pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presenta
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let doc = open_presentation(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
||||
@@ -167,16 +167,8 @@ fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// Write to a temporary sibling then rename, so a failed save never truncates the old file.
|
||||
let tmp = format!("{path}.saving");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
Err(EngineError::Other("saving to a path is not available in the browser".into()))
|
||||
crate::io::write_file(path, bytes).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -222,9 +222,8 @@ pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec<u8>)> {
|
||||
let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into());
|
||||
return Ok((name, bytes));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `deckcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` 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: 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: 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)
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `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<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!("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(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.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 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(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.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(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.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(Some(&base.join("in-link")), None).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<PathBuf>) {
|
||||
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.
|
||||
if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "recovery.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.
|
||||
let code = text.split("#[cfg(test)]").next().unwrap_or("");
|
||||
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:#?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Reading and writing the files a command names by path.
|
||||
//!
|
||||
//! Every such read and write goes through [`read_file`] and [`write_file`], so the automation
|
||||
//! roots ([`crate::file_access`]) are checked in one place. A command module that called
|
||||
//! `std::fs` itself would bypass them; a test in `file_access` keeps that from happening.
|
||||
|
||||
/// The bytes of the file at `path`, inside the automation read root when there is one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
Err(format!("{path}: files are opened through the browser on the web"))
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
///
|
||||
/// The bytes go to a temporary sibling first and are renamed over `path`, so a failed save never
|
||||
/// truncates the old file. The sibling is in the same folder, so it is inside the root whenever
|
||||
/// `path` is.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
crate::file_access::check_write(path)?;
|
||||
let tmp = format!("{path}.saving");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> {
|
||||
Err(format!("{path}: saving to a path is not available in the browser"))
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod file_access;
|
||||
pub mod guard;
|
||||
pub mod io;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
pub mod recovery;
|
||||
|
||||
Reference in new issue
Block a user