use super::EvalContext;
use crate::action::AtomicAction;
use crate::kind::Kind;
use crate::pattern::{literal_folder, split_negation};
use crate::policy::RuleGroup;
use crate::verdict::{Decision, Verdict};
pub const HOLDS_PROTECTED_RULE: &str = "protected-contents";
#[derive(Debug, Default)]
pub(super) struct ProtectedFolders {
reads: Vec<Folder>,
writes: Vec<Folder>,
homes: Vec<String>,
case_insensitive: bool,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Folder {
path: String,
rule: String,
}
impl ProtectedFolders {
pub(super) fn compile(deny: &[RuleGroup], ctx: &EvalContext) -> Self {
let case_insensitive = ctx.case_insensitive_paths;
let folders = |kind: Kind| {
let mut out: Vec<Folder> = deny
.iter()
.flat_map(|group| {
group
.patterns(kind)
.iter()
.filter(|p| !split_negation(p).0)
.flat_map(|p| ctx.spellings(p))
.filter_map(|spelled| {
let (negated, body) = split_negation(&spelled);
let folder = literal_folder(body).filter(|_| !negated)?;
Some(Folder {
path: fold(folder, case_insensitive),
rule: group.id.clone(),
})
})
.collect::<Vec<_>>()
})
.collect();
out.sort();
out.dedup();
out
};
let homes = std::iter::once(ctx.home.as_str())
.chain(ctx.real_home.as_deref())
.map(|home| format!("{}/", fold(home.trim_end_matches('/'), case_insensitive)))
.collect();
Self {
reads: folders(Kind::FsRead),
writes: folders(Kind::FsWrite),
homes,
case_insensitive,
}
}
pub(super) fn decide(&self, atom: &AtomicAction) -> Option<Decision> {
let (path, folders, verb) = match atom {
AtomicAction::FsRead { path } => (path, &self.reads, "reads"),
AtomicAction::FsWrite { path } => (path, &self.writes, "writes into"),
_ => return None,
};
let dir = fold(path.trim_end_matches('/'), self.case_insensitive);
if self.homes.iter().any(|home| holds(&dir, home)) {
return None;
}
let mut rules: Vec<&str> = Vec::new();
for folder in folders.iter().filter(|f| holds(&dir, &f.path)) {
if !rules.contains(&folder.rule.as_str()) {
rules.push(&folder.rule);
}
}
if rules.is_empty() {
return None;
}
let names: Vec<String> = rules.iter().map(|r| format!("`{r}`")).collect();
let (names, verb_rules) = match names.as_slice() {
[one] => (one.clone(), "denies"),
[rest @ .., last] => (format!("{} and {last}", rest.join(", ")), "deny"),
[] => return None,
};
Some(Decision::single(
Verdict::Ask,
HOLDS_PROTECTED_RULE,
format!("{verb} a folder that holds paths {names} {verb_rules}: {path}"),
))
}
}
fn holds(dir: &str, folder: &str) -> bool {
folder.len() > dir.len() && folder.starts_with(dir) && folder.as_bytes()[dir.len()] == b'/'
}
fn fold(path: &str, case_insensitive: bool) -> String {
if case_insensitive {
path.to_ascii_lowercase()
} else {
path.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::{HOLDS_PROTECTED_RULE, ProtectedFolders, holds};
use crate::action::AtomicAction;
use crate::engine::EvalContext;
use crate::policy::Policy;
use crate::verdict::Verdict;
fn ctx(case_insensitive_paths: bool) -> EvalContext {
EvalContext {
home: "/Users/me".into(),
project: Some("/p".into()),
real_home: Some("/Volumes/h/me".into()),
real_project: None,
moved_dirs: vec![("~/.codex".into(), "/srv/codex".into())],
cwd: "/p".into(),
case_insensitive_paths,
}
}
fn folders(case_insensitive: bool) -> ProtectedFolders {
let policy = Policy::parse(
r#"
version: 1
deny:
- id: creds
hook_only: true
fs.read: ["~/.codex/{auth.json,x}", "**/.env", "!~/.codex/ok/**", "/", "/srv/a.key"]
fs.write: ["~/.moat/**"]
- id: keys
fs.read: ["~/.ssh/**"]
"#,
)
.unwrap();
ProtectedFolders::compile(&policy.deny, &ctx(case_insensitive))
}
fn read(path: &str) -> AtomicAction {
AtomicAction::FsRead { path: path.into() }
}
fn rules(f: &ProtectedFolders, atom: &AtomicAction) -> Option<String> {
f.decide(atom).map(|d| {
assert_eq!(d.verdict, Verdict::Ask);
assert_eq!(d.rules, [HOLDS_PROTECTED_RULE]);
d.reasons.join("|")
})
}
#[test]
fn the_folders_cover_every_spelling_and_skip_rootless_patterns() {
let f = folders(false);
let paths: Vec<&str> = f.reads.iter().map(|x| x.path.as_str()).collect();
assert_eq!(
paths,
[
"/Users/me/.codex/",
"/Users/me/.ssh/",
"/Volumes/h/me/.codex/",
"/Volumes/h/me/.ssh/",
"/srv/",
"/srv/codex/",
]
);
let writes: Vec<&str> = f.writes.iter().map(|x| x.path.as_str()).collect();
assert_eq!(writes, ["/Users/me/.moat/", "/Volumes/h/me/.moat/"]);
}
#[test]
fn a_folder_or_an_ancestor_asks_and_a_folder_below_does_not() {
let f = folders(false);
let codex = rules(&f, &read("/Users/me/.codex")).unwrap();
assert_eq!(
codex,
"reads a folder that holds paths `creds` denies: /Users/me/.codex"
);
assert!(rules(&f, &read("/Users/me/.codex/")).is_some());
assert!(rules(&f, &read("/srv/codex")).is_some());
assert!(rules(&f, &read("/Volumes/h/me/.ssh")).is_some());
assert!(rules(&f, &read("/srv")).is_some());
for above in [
"/Users/me",
"/Users/me/",
"/Users",
"/",
"/Volumes/h/me",
"/Volumes",
] {
assert_eq!(rules(&f, &read(above)), None, "{above}");
}
for below in [
"/Users/me/.codex/sessions",
"/Users/me/.codexx",
"/Users/me/.cod",
"/p",
"/p/.env",
] {
assert_eq!(rules(&f, &read(below)), None, "{below}");
}
let write = AtomicAction::FsWrite {
path: "/Users/me/.codex".into(),
};
assert_eq!(rules(&f, &write), None);
assert!(
rules(
&f,
&AtomicAction::FsWrite {
path: "/Users/me/.moat".into()
}
)
.is_some()
);
let home_write = AtomicAction::FsWrite {
path: "/Users/me".into(),
};
assert_eq!(rules(&f, &home_write), None);
assert_eq!(rules(&f, &read("/Users/me/.moat")), None);
}
#[test]
fn folders_compare_without_case_when_paths_do() {
assert!(rules(&folders(true), &read("/users/ME/.Codex")).is_some());
assert_eq!(rules(&folders(false), &read("/users/ME/.Codex")), None);
}
#[test]
fn holds_is_the_folder_or_an_ancestor() {
assert!(holds("/a/b", "/a/b/"));
assert!(holds("/a", "/a/b/"));
assert!(holds("", "/a/b/"));
assert!(!holds("/a/b/c", "/a/b/"));
assert!(!holds("/a/bc", "/a/b/"));
assert!(!holds("/a/b", "/a/bc/"));
}
}