use std::path::{Component, Path, PathBuf};
use regex::Regex;
use crate::error::{Error, Result};
use crate::policy::{Act, Effect, Policy, Verdict};
const IGNORE_DIRS: &[&str] = &[".git", "target", "node_modules"];
#[derive(Debug, Clone)]
pub struct Workspace {
root: PathBuf,
policy: Policy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
pub path: String,
pub line: u32,
pub text: String,
}
impl Workspace {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
policy: Policy::permissive(),
}
}
pub fn with_policy(root: impl Into<PathBuf>, policy: Policy) -> Self {
Self {
root: root.into(),
policy,
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn policy(&self) -> &Policy {
&self.policy
}
pub fn check_path(&self, act: Act, rel: &str) -> Verdict {
let mut worst = self.policy.check(act, &normalize(rel));
if let Ok(abs) = self.resolve(rel) {
if let Ok(canon) = abs.canonicalize() {
let root_canon = self
.root
.canonicalize()
.unwrap_or_else(|_| self.root.clone());
if let Ok(rel_canon) = canon.strip_prefix(&root_canon) {
let rel_canon = rel_canon.to_string_lossy().replace('\\', "/");
let v = self.policy.check(act, &rel_canon);
if v.effect > worst.effect {
worst = v;
}
} else {
return Verdict {
effect: Effect::Deny,
rule: Some("<resolves outside workspace root>".into()),
layer: None,
};
}
}
}
worst
}
fn enforce(&self, act: Act, rel: &str) -> Result<()> {
let v = self.check_path(act, rel);
if v.effect == Effect::Deny {
return Err(Error::Refused {
act: format!("{act:?}").to_lowercase(),
target: rel.to_string(),
rule: v.rule,
layer: v.layer,
});
}
Ok(())
}
pub fn resolve(&self, rel: &str) -> Result<PathBuf> {
let p = Path::new(rel);
if p.is_absolute() {
return Err(escape(rel));
}
let mut out = self.root.clone();
for comp in p.components() {
match comp {
Component::Normal(c) => out.push(c),
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() || !out.starts_with(&self.root) {
return Err(escape(rel));
}
}
Component::RootDir | Component::Prefix(_) => return Err(escape(rel)),
}
}
Ok(out)
}
pub fn grep(&self, pattern: &str, path_glob: Option<&str>) -> Result<Vec<Match>> {
let re = Regex::new(pattern).map_err(|e| Error::Config(format!("bad grep regex: {e}")))?;
let glob = path_glob.map(glob_to_regex).transpose()?;
let mut out = Vec::new();
for file in self.walk() {
if let Some(g) = &glob {
if !g.is_match(&file) {
continue;
}
}
if self.check_path(Act::Read, &file).effect == Effect::Deny {
continue;
}
let Ok(content) = std::fs::read_to_string(self.root.join(&file)) else {
continue;
};
for (i, line) in content.lines().enumerate() {
if re.is_match(line) {
out.push(Match {
path: file.clone(),
line: (i + 1) as u32,
text: line.to_string(),
});
}
}
}
Ok(out)
}
pub fn find(&self, name_glob: &str) -> Result<Vec<String>> {
let re = glob_to_regex(name_glob)?;
Ok(self
.walk()
.into_iter()
.filter(|file| {
let base = Path::new(file)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(file);
(re.is_match(base) || re.is_match(file))
&& self.check_path(Act::Read, file).effect != Effect::Deny
})
.collect())
}
pub fn read_file(&self, rel: &str) -> Result<String> {
let abs = self.resolve(rel)?;
self.enforce(Act::Read, rel)?;
Ok(std::fs::read_to_string(abs).unwrap_or_default())
}
pub fn write_file(&self, rel: &str, content: &str) -> Result<()> {
let abs = self.resolve(rel)?;
self.enforce(Act::Write, rel)?;
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(abs, content)?;
Ok(())
}
fn walk(&self) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![self.root.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
let name = entry.file_name();
if ft.is_dir() {
if !IGNORE_DIRS.contains(&name.to_string_lossy().as_ref()) {
stack.push(entry.path());
}
} else if ft.is_file() {
if let Ok(rel) = entry.path().strip_prefix(&self.root) {
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
}
out.sort();
out
}
}
fn escape(rel: &str) -> Error {
Error::Config(format!("path escapes workspace: {rel}"))
}
fn normalize(rel: &str) -> String {
let s = rel.replace('\\', "/");
let mut out: Vec<&str> = Vec::new();
for part in s.split('/') {
match part {
"" | "." => {}
".." => {
out.pop();
}
p => out.push(p),
}
}
out.join("/")
}
fn glob_to_regex(glob: &str) -> Result<Regex> {
let mut re = String::from("(?s)^");
for ch in glob.chars() {
match ch {
'*' => re.push_str(".*"),
'?' => re.push('.'),
c => re.push_str(®ex::escape(&c.to_string())),
}
}
re.push('$');
Regex::new(&re).map_err(|e| Error::Config(format!("bad glob: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("target")).unwrap();
std::fs::write(root.join("src/a.rs"), "pub fn alpha() -> u32 { 1 }\n").unwrap();
std::fs::write(
root.join("src/b.rs"),
"pub fn beta() -> u32 { 2 }\n// alpha ref\n",
)
.unwrap();
std::fs::write(root.join("README.md"), "# alpha and beta\n").unwrap();
std::fs::write(root.join("target/junk.rs"), "fn alpha() {}\n").unwrap();
dir
}
#[test]
fn grep_finds_matches_by_regex_across_files_skipping_ignored() {
let dir = fixture();
let ws = Workspace::new(dir.path());
let hits = ws.grep(r"alpha", None).unwrap();
let paths: Vec<_> = hits.iter().map(|m| m.path.as_str()).collect();
assert!(paths.contains(&"src/a.rs"));
assert!(paths.contains(&"src/b.rs"));
assert!(paths.contains(&"README.md"));
assert!(!paths.iter().any(|p| p.starts_with("target/")));
let b = hits.iter().find(|m| m.path == "src/b.rs").unwrap();
assert_eq!(b.line, 2);
}
#[test]
fn grep_path_glob_restricts_to_matching_files() {
let dir = fixture();
let ws = Workspace::new(dir.path());
let hits = ws.grep("alpha", Some("src/*.rs")).unwrap();
assert!(hits.iter().all(|m| m.path.starts_with("src/")));
assert!(!hits.iter().any(|m| m.path == "README.md"));
}
#[test]
fn find_matches_by_basename_and_path_glob() {
let dir = fixture();
let ws = Workspace::new(dir.path());
let rs = ws.find("*.rs").unwrap();
assert!(rs.contains(&"src/a.rs".to_string()));
assert!(rs.contains(&"src/b.rs".to_string()));
assert!(!rs.iter().any(|p| p.starts_with("target/"))); let only_a = ws.find("a.rs").unwrap();
assert_eq!(only_a, vec!["src/a.rs".to_string()]);
}
#[test]
fn resolve_refuses_escapes_but_allows_inner_paths() {
let dir = fixture();
let ws = Workspace::new(dir.path());
assert!(ws.resolve("src/a.rs").is_ok());
assert!(ws.resolve("src/../README.md").is_ok()); assert!(ws.resolve("../secret").is_err()); assert!(ws.resolve("src/../../etc/passwd").is_err()); #[cfg(unix)]
assert!(ws.resolve("/etc/passwd").is_err()); }
fn guarded(root: &Path) -> Workspace {
Workspace::with_policy(
root,
Policy::default()
.layer("base")
.allow_read("*")
.allow_write("src/*")
.deny_read("secrets/*")
.deny_write("secrets/*"),
)
}
#[test]
fn a_denied_write_is_refused_and_the_file_is_untouched() {
let dir = fixture();
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/key.txt"), "original").unwrap();
let ws = guarded(dir.path());
let err = ws.write_file("secrets/key.txt", "stolen").unwrap_err();
assert!(
matches!(&err, Error::Refused { rule, layer, .. }
if rule.as_deref() == Some("secrets/*") && layer.as_deref() == Some("base")),
"expected an attributable refusal, got {err:?}"
);
assert_eq!(
std::fs::read_to_string(dir.path().join("secrets/key.txt")).unwrap(),
"original"
);
assert!(ws
.write_file("src/a.rs", "pub fn alpha() -> u32 { 9 }\n")
.is_ok());
}
#[test]
fn denied_paths_are_invisible_to_grep_and_find() {
let dir = fixture();
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/creds.rs"), "alpha token\n").unwrap();
let ws = guarded(dir.path());
let hits = ws.grep("alpha", None).unwrap();
assert!(!hits.iter().any(|m| m.path.starts_with("secrets/")));
assert!(hits.iter().any(|m| m.path == "src/a.rs"));
let found = ws.find("*.rs").unwrap();
assert!(!found.iter().any(|p| p.starts_with("secrets/")));
assert!(matches!(
ws.read_file("secrets/creds.rs"),
Err(Error::Refused { .. })
));
}
#[test]
fn traversal_is_evaluated_on_the_resolved_path_not_the_literal_one() {
let dir = fixture();
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/key.txt"), "original").unwrap();
let ws = guarded(dir.path());
assert!(matches!(
ws.write_file("src/../secrets/key.txt", "stolen"),
Err(Error::Refused { .. })
));
assert_eq!(
std::fs::read_to_string(dir.path().join("secrets/key.txt")).unwrap(),
"original"
);
}
#[cfg(unix)]
#[test]
fn a_symlink_is_denied_by_its_target_even_when_its_own_path_is_allowed() {
let dir = fixture();
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/key.txt"), "secret").unwrap();
std::os::unix::fs::symlink(
dir.path().join("secrets/key.txt"),
dir.path().join("src/link.rs"),
)
.unwrap();
let ws = guarded(dir.path());
assert_eq!(
ws.check_path(Act::Read, "src/link.rs").effect,
Effect::Deny,
"a link into a denied path must be refused"
);
assert!(matches!(
ws.read_file("src/link.rs"),
Err(Error::Refused { .. })
));
}
#[cfg(unix)]
#[test]
fn a_symlink_pointing_outside_the_root_is_refused() {
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("passwd"), "root:x:0:0").unwrap();
let dir = fixture();
std::os::unix::fs::symlink(outside.path().join("passwd"), dir.path().join("src/out.rs"))
.unwrap();
let ws = guarded(dir.path());
assert_eq!(ws.check_path(Act::Read, "src/out.rs").effect, Effect::Deny);
}
#[test]
fn a_workspace_without_a_policy_behaves_exactly_as_0_3_0_did() {
let dir = fixture();
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/key.txt"), "x").unwrap();
let ws = Workspace::new(dir.path());
assert!(ws.write_file("secrets/key.txt", "y").is_ok());
assert!(ws.read_file("secrets/key.txt").is_ok());
assert!(ws
.find("*.txt")
.unwrap()
.iter()
.any(|p| p.starts_with("secrets/")));
}
#[test]
fn check_path_agrees_with_what_read_and_write_actually_enforce() {
let dir = fixture();
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/key.txt"), "x").unwrap();
let ws = guarded(dir.path());
for (act, path) in [
(Act::Read, "src/a.rs"),
(Act::Read, "secrets/key.txt"),
(Act::Write, "src/a.rs"),
(Act::Write, "secrets/key.txt"),
] {
let denied = ws.check_path(act, path).effect == Effect::Deny;
let refused = match act {
Act::Read => matches!(ws.read_file(path), Err(Error::Refused { .. })),
Act::Write => matches!(ws.write_file(path, "x"), Err(Error::Refused { .. })),
Act::Exec | Act::Net => unreachable!(),
};
assert_eq!(denied, refused, "{act:?} {path}");
}
}
#[test]
fn read_missing_is_empty_then_write_roundtrips_within_root() {
let dir = fixture();
let ws = Workspace::new(dir.path());
assert_eq!(ws.read_file("src/new.rs").unwrap(), "");
ws.write_file("src/new.rs", "fn n() {}").unwrap();
assert_eq!(ws.read_file("src/new.rs").unwrap(), "fn n() {}");
assert!(ws.write_file("../evil.rs", "x").is_err());
}
}