use std::path::{Path, PathBuf};
use globset::{Glob, GlobSet, GlobSetBuilder};
use crate::Decision;
use crate::grant::{FsConfig, PolicyError, PolicyMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FsAccess {
Read,
Write,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct FsMatcher {
mode: PolicyMode,
read_allow: GlobSet,
write_allow: GlobSet,
deny: GlobSet,
allow_prefixes: Vec<PathBuf>,
read_allow_entries: Vec<(String, GlobSet)>,
write_allow_entries: Vec<(String, GlobSet)>,
}
impl FsMatcher {
pub fn compile(cfg: &FsConfig) -> Result<Self, PolicyError> {
let mut allow_prefixes = Vec::new();
for entry in &cfg.allow {
let expanded = expand_pattern(&entry.glob);
allow_prefixes.push(PathBuf::from(literal_prefix(&expanded)));
}
let all_globs: Vec<String> = cfg.allow.iter().map(|e| e.glob.clone()).collect();
let rw_globs: Vec<String> = cfg
.allow
.iter()
.filter(|e| e.mode == act_types::FsMode::Rw)
.map(|e| e.glob.clone())
.collect();
let mut read_allow_entries = Vec::with_capacity(cfg.allow.len());
for entry in &cfg.allow {
let gs = compile_set("read_allow_entry", std::slice::from_ref(&entry.glob))?;
read_allow_entries.push((entry.glob.clone(), gs));
}
let mut write_allow_entries = Vec::new();
for entry in cfg.allow.iter().filter(|e| e.mode == act_types::FsMode::Rw) {
let gs = compile_set("write_allow_entry", std::slice::from_ref(&entry.glob))?;
write_allow_entries.push((entry.glob.clone(), gs));
}
Ok(Self {
mode: cfg.mode,
read_allow: compile_set("read_allow", &all_globs)?,
write_allow: compile_set("write_allow", &rw_globs)?,
deny: compile_set("deny", &cfg.deny)?,
allow_prefixes,
read_allow_entries,
write_allow_entries,
})
}
pub fn which_allow(&self, path: &Path, access: FsAccess) -> Option<&str> {
let entries = match access {
FsAccess::Read => &self.read_allow_entries,
FsAccess::Write => &self.write_allow_entries,
};
entries
.iter()
.find(|(_, gs)| gs.is_match(path))
.map(|(glob, _)| glob.as_str())
}
pub fn decide(&self, path: &Path, access: FsAccess) -> Decision {
let allow = match access {
FsAccess::Read => &self.read_allow,
FsAccess::Write => &self.write_allow,
};
match self.mode {
PolicyMode::Deny => Decision::Deny,
PolicyMode::Open => Decision::Allow,
PolicyMode::Ask => {
if self.deny.is_match(path) {
return Decision::Deny;
}
let in_ceiling = allow.is_match(path)
|| (matches!(access, FsAccess::Read)
&& self
.allow_prefixes
.iter()
.any(|prefix| is_ancestor(path, prefix)));
if in_ceiling {
return Decision::Ask;
}
Decision::Deny
}
PolicyMode::Allowlist => {
if self.deny.is_match(path) {
return Decision::Deny;
}
if allow.is_match(path) {
return Decision::Allow;
}
if matches!(access, FsAccess::Read)
&& self
.allow_prefixes
.iter()
.any(|prefix| is_ancestor(path, prefix))
{
return Decision::Allow;
}
Decision::Deny
}
}
}
}
fn literal_prefix(pattern: &str) -> &str {
let bytes = pattern.as_bytes();
let mut last_boundary = 0usize;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
if b == b'/' {
last_boundary = i;
} else if matches!(b, b'*' | b'?' | b'[' | b'{') {
return &pattern[..last_boundary];
}
i += 1;
}
pattern
}
fn is_ancestor(candidate: &Path, target: &Path) -> bool {
if candidate.as_os_str().is_empty() {
return false;
}
for ancestor in target.ancestors() {
if ancestor == candidate {
return true;
}
}
false
}
fn compile_set(label: &str, patterns: &[String]) -> Result<GlobSet, PolicyError> {
let mut b = GlobSetBuilder::new();
for p in patterns {
let expanded = expand_pattern(p);
let glob = Glob::new(&expanded).map_err(|e| PolicyError::Glob {
pat: p.clone(),
source: e,
})?;
b.add(glob);
if !expanded.ends_with("/**") && !expanded.contains('*') && !expanded.contains('?') {
let descendants = format!("{expanded}/**");
let glob = Glob::new(&descendants).map_err(|e| PolicyError::Glob {
pat: descendants.clone(),
source: e,
})?;
b.add(glob);
}
if let Some(dir) = expanded.strip_suffix("/**")
&& !dir.is_empty()
{
let glob = Glob::new(dir).map_err(|e| PolicyError::Glob {
pat: dir.to_string(),
source: e,
})?;
b.add(glob);
}
}
b.build().map_err(|e| PolicyError::Glob {
pat: label.to_string(),
source: e,
})
}
fn expand_pattern(pattern: &str) -> String {
let expanded = shellexpand::tilde(pattern).into_owned();
let absolute = if expanded.starts_with("**") || Path::new(&expanded).is_absolute() {
expanded
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(&expanded).to_string_lossy().into_owned(),
Err(_) => expanded,
}
};
#[cfg(windows)]
{
absolute.replace('\\', "/")
}
#[cfg(not(windows))]
{
absolute
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grant::FsAllow;
use std::path::PathBuf;
fn cfg(mode: PolicyMode, allow: &[&str], deny: &[&str]) -> FsConfig {
FsConfig {
mode,
allow: allow
.iter()
.map(|s| FsAllow {
glob: s.to_string(),
mode: act_types::FsMode::Rw,
})
.collect(),
deny: deny.iter().map(std::string::ToString::to_string).collect(),
}
}
#[test]
fn deny_mode_blocks_everything() {
let m = FsMatcher::compile(&cfg(PolicyMode::Deny, &[], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/anything"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn open_mode_allows_everything() {
let m = FsMatcher::compile(&cfg(PolicyMode::Open, &[], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/etc/passwd"), FsAccess::Read),
Decision::Allow
);
}
#[test]
fn allow_literal_path_matches_descendants() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/work"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/work/sub/file.txt"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/other"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn allow_trailing_double_star_matches_descendants() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work/**"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/work/sub/file.txt"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/other"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn deny_rules_beat_allow() {
let m = FsMatcher::compile(&cfg(
PolicyMode::Allowlist,
&["/home/alex/**"],
&["/home/alex/.ssh/**", "/home/alex/.aws/**"],
))
.unwrap();
assert_eq!(
m.decide(&PathBuf::from("/home/alex/project/main.rs"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/home/alex/.ssh/id_rsa"), FsAccess::Read),
Decision::Deny
);
assert_eq!(
m.decide(
&PathBuf::from("/home/alex/.aws/credentials"),
FsAccess::Read
),
Decision::Deny
);
}
#[test]
fn ripgrep_style_brace_expansion() {
let m = FsMatcher::compile(&cfg(
PolicyMode::Allowlist,
&["/home/alex/{projects,work}/**"],
&[],
))
.unwrap();
assert_eq!(
m.decide(
&PathBuf::from("/home/alex/projects/foo/lib.rs"),
FsAccess::Read
),
Decision::Allow
);
assert_eq!(
m.decide(
&PathBuf::from("/home/alex/work/docs/README.md"),
FsAccess::Read
),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/home/alex/Downloads/x"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn ancestor_of_allowed_literal_file_is_traversable() {
let m =
FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work/db.sqlite"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/work/db.sqlite"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/work"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/other"), FsAccess::Read),
Decision::Deny
);
assert_eq!(
m.decide(&PathBuf::from("/var"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn ancestor_of_glob_literal_prefix_is_traversable() {
let m =
FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work/**/*.db"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/work"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/work/a/b.db"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/work/a/b.txt"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn ancestor_does_not_leak_past_first_glob_component() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/*.db"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/foo.db"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/foo.txt"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn extension_glob() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/**/*.md"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/notes/today.md"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/notes/secret.txt"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn ask_mode_defers_in_ceiling_paths() {
let m = FsMatcher::compile(&cfg(PolicyMode::Ask, &["/tmp/**"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/x"), FsAccess::Read),
Decision::Ask
);
assert_eq!(
m.decide(&PathBuf::from("/etc/passwd"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn ask_mode_is_bounded_by_allow_ceiling() {
let m = FsMatcher::compile(&cfg(PolicyMode::Ask, &["/data/**"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/data/x"), FsAccess::Read),
Decision::Ask
);
assert_eq!(
m.decide(&PathBuf::from("/etc/passwd"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn ask_mode_deny_rule_beats_ceiling() {
let m = FsMatcher::compile(&cfg(PolicyMode::Ask, &["/data/**"], &["/data/secrets/**"]))
.unwrap();
assert_eq!(
m.decide(&PathBuf::from("/data/ok"), FsAccess::Read),
Decision::Ask
);
assert_eq!(
m.decide(&PathBuf::from("/data/secrets/key"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn ask_mode_empty_ceiling_denies_everything() {
let m = FsMatcher::compile(&cfg(PolicyMode::Ask, &[], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/etc/passwd"), FsAccess::Read),
Decision::Deny
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/x"), FsAccess::Read),
Decision::Deny
);
}
#[test]
fn ro_entry_denies_write_allows_read() {
use act_types::FsMode;
let cfg = FsConfig {
mode: PolicyMode::Allowlist,
allow: vec![FsAllow {
glob: "/data/**".into(),
mode: FsMode::Ro,
}],
deny: vec![],
};
let m = FsMatcher::compile(&cfg).unwrap();
assert_eq!(
m.decide(Path::new("/data/x.db"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(Path::new("/data/x.db"), FsAccess::Write),
Decision::Deny
);
}
#[cfg(feature = "host")]
#[tokio::test]
async fn ask_decision_resolved_through_consent_cache() {
use crate::consent::{ConsentAsk, ConsentPrompter, DecisionCache, DenyPrompter};
use std::sync::Mutex;
struct Scripted {
allow_path: String,
calls: Mutex<usize>,
}
#[async_trait::async_trait]
impl ConsentPrompter for Scripted {
async fn decide(&self, ask: &ConsentAsk) -> bool {
*self.calls.lock().unwrap() += 1;
ask.key == self.allow_path
}
}
let m = FsMatcher::compile(&cfg(PolicyMode::Ask, &["/data/**", "/etc/**"], &[])).unwrap();
let cache = DecisionCache::new();
let p = Scripted {
allow_path: "/data/ok".to_string(),
calls: Mutex::new(0),
};
let mk_ask = |path: &str| ConsentAsk {
cap_id: "wasi:filesystem".to_string(),
key: path.to_string(),
summary: format!("filesystem access: {path}"),
};
assert_eq!(
m.decide(&PathBuf::from("/data/ok"), FsAccess::Read),
Decision::Ask
);
assert!(cache.decide_cached(&p, mk_ask("/data/ok")).await);
assert!(cache.decide_cached(&p, mk_ask("/data/ok")).await);
assert_eq!(*p.calls.lock().unwrap(), 1, "second access must be cached");
assert_eq!(
m.decide(&PathBuf::from("/etc/shadow"), FsAccess::Read),
Decision::Ask
);
assert!(!cache.decide_cached(&p, mk_ask("/etc/shadow")).await);
let deny_cache = DecisionCache::new();
assert!(
!deny_cache
.decide_cached(&DenyPrompter, mk_ask("/data/ok"))
.await
);
}
#[test]
fn subtree_glob_matches_dir_itself() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work/**"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/work"), FsAccess::Write),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/work/a.txt"), FsAccess::Write),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/other"), FsAccess::Write),
Decision::Deny
);
}
#[test]
fn double_star_matches_absolute_paths() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["**"], &[])).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/anywhere/x"), FsAccess::Write),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/etc/passwd"), FsAccess::Read),
Decision::Allow
);
}
#[test]
fn ro_subtree_dir_readable_not_writable() {
let cfg = FsConfig {
mode: PolicyMode::Allowlist,
allow: vec![FsAllow {
glob: "/tmp/work/**".into(),
mode: act_types::FsMode::Ro,
}],
deny: vec![],
};
let m = FsMatcher::compile(&cfg).unwrap();
assert_eq!(
m.decide(&PathBuf::from("/tmp/work"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/work"), FsAccess::Write),
Decision::Deny
);
assert_eq!(
m.decide(&PathBuf::from("/tmp/work/a.txt"), FsAccess::Write),
Decision::Deny
);
}
#[test]
fn which_allow_reports_the_matching_entry_among_several() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/data/**", "/other/**"], &[]))
.unwrap();
assert_eq!(
m.which_allow(&PathBuf::from("/data/app.db"), FsAccess::Read),
Some("/data/**")
);
assert_eq!(
m.which_allow(&PathBuf::from("/other/x"), FsAccess::Read),
Some("/other/**")
);
assert_eq!(
m.which_allow(&PathBuf::from("/etc/passwd"), FsAccess::Read),
None
);
}
#[test]
fn which_allow_respects_the_ro_write_filter() {
let cfg = FsConfig {
mode: PolicyMode::Allowlist,
allow: vec![FsAllow {
glob: "/data/**".into(),
mode: act_types::FsMode::Ro,
}],
deny: vec![],
};
let m = FsMatcher::compile(&cfg).unwrap();
assert_eq!(
m.which_allow(&PathBuf::from("/data/x.db"), FsAccess::Read),
Some("/data/**")
);
assert_eq!(
m.which_allow(&PathBuf::from("/data/x.db"), FsAccess::Write),
None
);
}
#[test]
fn which_allow_reports_bare_dir_pattern_for_a_descendant_op() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/data"], &[])).unwrap();
assert_eq!(
m.which_allow(&PathBuf::from("/data/app.db"), FsAccess::Read),
Some("/data")
);
}
#[test]
fn which_allow_reports_subtree_pattern_for_the_dir_itself() {
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/data/**"], &[])).unwrap();
assert_eq!(
m.which_allow(&PathBuf::from("/data"), FsAccess::Read),
Some("/data/**")
);
}
#[test]
fn which_allow_matches_a_relative_pattern_against_the_expanded_path() {
let cwd = std::env::current_dir().unwrap();
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["rel/**"], &[])).unwrap();
let target = cwd.join("rel/x.txt");
assert_eq!(m.which_allow(&target, FsAccess::Read), Some("rel/**"));
}
}