#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityLevel {
Strict,
Standard,
Permissive,
}
impl SecurityLevel {
pub fn label(self) -> &'static str {
match self {
SecurityLevel::Strict => "STRICT",
SecurityLevel::Standard => "STANDARD",
SecurityLevel::Permissive => "PERMISSIVE",
}
}
}
#[derive(Debug, Clone)]
pub struct RequestedPolicy {
pub level: SecurityLevel,
pub allow_read: Vec<String>,
pub allow_write: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompiledPolicy {
pub level: SecurityLevel,
pub allow_read: Vec<String>,
pub allow_write: Vec<String>,
}
pub fn compile(req: &RequestedPolicy) -> Result<CompiledPolicy, String> {
let allow_read = normalize(&req.allow_read);
let allow_write = normalize(&req.allow_write);
if allow_write.iter().any(|p| p == "/") {
return Err("policy_ir: allow_write contains filesystem root \"/\"".to_string());
}
if req.level != SecurityLevel::Permissive && allow_read.iter().any(|p| p == "/") {
return Err(
"policy_ir: allow_read contains filesystem root \"/\" without PERMISSIVE level"
.to_string(),
);
}
let compiled = CompiledPolicy {
level: req.level,
allow_read,
allow_write,
};
validate(&compiled)?;
Ok(compiled)
}
pub fn validate(c: &CompiledPolicy) -> Result<(), String> {
for w in &c.allow_write {
let covered = c.allow_read.iter().any(|r| under(w, r));
if !covered {
return Err(format!(
"policy_ir: allow_write '{w}' is not under any allow_read root"
));
}
}
Ok(())
}
fn normalize(paths: &[String]) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
for p in paths {
let t = p.trim();
if t.is_empty() {
continue;
}
let t = t.trim_end_matches('/').to_string();
let t = if t.is_empty() { "/".to_string() } else { t };
seen.insert(t);
}
seen.into_iter().collect()
}
fn under(path: &str, root: &str) -> bool {
if root == "/" {
return path.starts_with('/');
}
path == root || path.starts_with(&format!("{root}/"))
}
#[cfg(test)]
mod policy_ir_tests {
use super::*;
fn req(level: SecurityLevel, r: &[&str], w: &[&str]) -> RequestedPolicy {
RequestedPolicy {
level,
allow_read: r.iter().map(|s| s.to_string()).collect(),
allow_write: w.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn happy_path_compiles_and_validates() {
let c = compile(&req(
SecurityLevel::Standard,
&["/proj", "/tmp/"],
&["/proj/out"],
))
.unwrap();
assert_eq!(c.level, SecurityLevel::Standard);
assert_eq!(c.allow_read, vec!["/proj".to_string(), "/tmp".to_string()]);
assert_eq!(c.allow_write, vec!["/proj/out".to_string()]);
assert!(validate(&c).is_ok());
}
#[test]
fn root_read_requires_permissive() {
assert!(compile(&req(SecurityLevel::Standard, &["/"], &[])).is_err());
assert!(compile(&req(SecurityLevel::Strict, &["/"], &[])).is_err());
assert!(compile(&req(SecurityLevel::Permissive, &["/"], &[])).is_ok());
}
#[test]
fn root_write_rejected_at_any_level() {
assert!(compile(&req(SecurityLevel::Permissive, &["/"], &["/"])).is_err());
}
#[test]
fn write_outside_read_fails_closed() {
assert!(compile(&req(SecurityLevel::Standard, &["/proj"], &["/etc/x"])).is_err());
assert!(compile(&req(SecurityLevel::Standard, &["/proj"], &["/proj2/o"])).is_err());
}
#[test]
fn normalize_dedups_and_cleans() {
let c = compile(&req(
SecurityLevel::Standard,
&["/b/", "/a", "/b", " "],
&[],
))
.unwrap();
assert_eq!(c.allow_read, vec!["/a".to_string(), "/b".to_string()]);
}
}