use crate::cap_from_name;
use std::collections::BTreeSet;
pub const UNKNOWN: &str = "Unknown";
#[derive(Debug, Clone)]
pub struct PolicyRule {
pub effects: BTreeSet<&'static str>,
pub scope: Option<String>,
pub raw: String,
}
#[derive(Debug, Clone)]
pub struct AllowRule {
pub effect: &'static str,
pub scope: Option<String>,
pub literals: BTreeSet<String>,
pub raw: String,
}
#[derive(Debug, Clone)]
pub struct LayerRule {
pub from: String,
pub to: String,
pub raw: String,
}
#[derive(Default, Debug)]
pub struct ParsedPolicy {
pub rules: Vec<PolicyRule>,
pub allow_rules: Vec<AllowRule>,
pub layer_rules: Vec<LayerRule>,
}
pub fn host_part(h: &str) -> &str {
h.split(':').next().unwrap_or(h)
}
pub fn cmd_base(c: &str) -> &str {
c.rsplit(['/', '\\']).next().unwrap_or(c)
}
pub fn fs_path_covered(a: &str, r: &str) -> bool {
if r.split(['/', '\\']).any(|c| c == "..") {
return false;
}
let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
if absolute(a) != absolute(r) {
return false;
}
let norm = |s: &str| -> Vec<String> {
s.split(['/', '\\'])
.filter(|c| !c.is_empty() && *c != ".")
.map(|c| c.to_string())
.collect()
};
let (ac, rc) = (norm(a), norm(r));
ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
}
pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
match effect {
"Net" => allow.iter().any(|a| host_part(a) == host_part(reached)),
"Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
"Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
_ => allow.contains(reached),
}
}
pub fn scope_matches(name: &str, scope: &str) -> bool {
let segs: Vec<&str> = name.split("::").collect();
let parts: Vec<&str> = scope.split("::").collect();
if parts.is_empty() || parts.len() > segs.len() {
return false;
}
let (last, init) = parts.split_last().unwrap();
segs.windows(parts.len()).any(|w| {
let (w_last, w_init) = w.split_last().unwrap();
w_init == init && w_last.starts_with(last)
})
}
pub fn parse_policy(text: &str) -> ParsedPolicy {
let mut out = ParsedPolicy::default();
for raw_line in text.lines() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut toks = line.split_whitespace();
match toks.next().unwrap_or("") {
"allow" => {
let effect = match toks.next().unwrap_or("") {
"Net" => "Net",
"Exec" => "Exec",
"Fs" => "Fs",
_ => {
eprintln!(
"candor: ignoring policy rule (allow supports only Net hosts / Exec commands / Fs paths): {line}"
);
continue;
}
};
let mut rest: Vec<&str> = toks.collect();
let scope = if rest.first() == Some(&"in") {
let s = rest.get(1).map(|s| s.to_string());
rest.drain(..2.min(rest.len()));
s
} else {
None
};
let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
if literals.is_empty() {
eprintln!("candor: ignoring policy rule (allow {effect} names no values): {line}");
continue;
}
out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
}
"deny" => {
let mut effects = BTreeSet::new();
let mut scope = None;
for t in toks {
let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
match e {
Some(e) => {
effects.insert(e);
}
None => {
scope = Some(t.to_string());
break;
}
}
}
if effects.is_empty() {
eprintln!("candor: ignoring policy rule (no known effect named): {line}");
continue;
}
out.rules.push(PolicyRule { effects, scope, raw: line.to_string() });
}
"pure" => out.rules.push(PolicyRule {
effects: BTreeSet::new(),
scope: toks.next().map(str::to_string),
raw: line.to_string(),
}),
"forbid" => {
let a = toks.next().unwrap_or("");
let arrow = toks.next().unwrap_or("");
let b = toks.next().unwrap_or("");
if a.is_empty() || arrow != "->" || b.is_empty() {
eprintln!("candor: ignoring layering rule (want `forbid <scope> -> <scope>`): {line}");
continue;
}
out.layer_rules.push(LayerRule {
from: a.to_string(),
to: b.to_string(),
raw: line.to_string(),
});
}
other => eprintln!("candor: ignoring policy rule (unknown kind `{other}`): {line}"),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_parses() {
let p = parse_policy(
"# the domain layer must stay pure of I/O\n\
deny Net Db domain\n\
deny Exec\n\
pure parse\n\
nonsense line\n\
deny notaneffect\n",
);
let rules = &p.rules;
assert_eq!(rules.len(), 3);
assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
assert_eq!(rules[0].scope.as_deref(), Some("domain"));
assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
assert!(parse_policy("deny\ndeny \n").rules.is_empty());
assert!(parse_policy("deny notaneffect scope").rules.is_empty());
let p2 = parse_policy("deny Net foo Db");
assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
}
#[test]
fn allowlist_parses() {
let p = parse_policy(
"allow Net in billing api.stripe.com hooks.stripe.com\n\
allow Exec in ci git\n\
allow Fs in config /etc/app\n\
allow Net github.com\n\
allow Db whatever\n\
allow Net in nohosts\n\
allow\n",
);
assert_eq!(p.allow_rules.len(), 4);
assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
assert_eq!(
p.allow_rules[0].literals,
["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
);
assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
assert!(p.allow_rules[1].literals.contains("git"));
assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
assert_eq!(cmd_base("/usr/bin/git"), "git");
}
#[test]
fn layering_rule_parses() {
let p = parse_policy(
"forbid domain -> infra\n\
forbid app::web -> app::db \n\
forbid domain infra\n\
forbid domain ->\n\
forbid\n",
);
assert_eq!(p.layer_rules.len(), 2);
assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
}
#[test]
fn scope_matches_by_segment_not_substring() {
assert!(scope_matches("app::domain::handle", "domain"));
assert!(scope_matches("domain::handle", "domain"));
assert!(scope_matches("app::domain", "domain"));
assert!(scope_matches("crate::domain_logic", "domain"));
assert!(!scope_matches("app::subdomain::handle", "domain"));
assert!(!scope_matches("app::not_my_domain::f", "domain"));
assert!(scope_matches("crate::net::client::send", "net::client"));
assert!(scope_matches("crate::net::client_pool::get", "net::client"));
assert!(!scope_matches("crate::net::server::send", "net::client"));
assert!(!scope_matches("crate::network::client::send", "net::client"));
assert!(!scope_matches("crate::net::x::client", "net::client"));
assert!(!scope_matches("net", "net::client"));
}
#[test]
fn fs_path_covered_respects_boundaries() {
assert!(fs_path_covered("/etc/app", "/etc/app"));
assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
assert!(fs_path_covered("/", "/etc/app/x"));
assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
assert!(fs_path_covered("etc/app", "etc/app/cfg"));
}
}