use crate::cap_from_name;
use std::collections::{BTreeMap, BTreeSet};
pub const UNKNOWN: &str = "Unknown";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ReasonClass {
Reflect,
Dispatch,
Indirect,
Native,
Unresolved,
Setup,
}
impl ReasonClass {
pub fn token(self) -> &'static str {
match self {
ReasonClass::Reflect => "reflect",
ReasonClass::Dispatch => "dispatch",
ReasonClass::Indirect => "indirect",
ReasonClass::Native => "native",
ReasonClass::Unresolved => "unresolved",
ReasonClass::Setup => "setup",
}
}
pub fn from_token(t: &str) -> Option<ReasonClass> {
Some(match t {
"reflect" => ReasonClass::Reflect,
"dispatch" => ReasonClass::Dispatch,
"indirect" => ReasonClass::Indirect,
"native" => ReasonClass::Native,
"unresolved" => ReasonClass::Unresolved,
"setup" => ReasonClass::Setup,
_ => return None,
})
}
pub fn classify(why: &str) -> ReasonClass {
let w = why.trim().to_ascii_lowercase();
if w.starts_with("reflect") || w == "dynamicmemberlookup" {
ReasonClass::Reflect
} else if w.starts_with("native") {
ReasonClass::Native
} else if w.starts_with("callback") || w.starts_with("closure") || w.starts_with("task-handoff") {
ReasonClass::Indirect
} else if w.starts_with("dispatch") || w.starts_with("indy") || w.starts_with("ambiguous") {
ReasonClass::Dispatch
} else if w.starts_with("missing-config") || w.starts_with("no-tsconfig") || w.starts_with("no-node_modules") {
ReasonClass::Setup
} else {
ReasonClass::Unresolved
}
}
pub fn dynamic_set() -> BTreeSet<ReasonClass> {
[
ReasonClass::Reflect,
ReasonClass::Dispatch,
ReasonClass::Indirect,
ReasonClass::Native,
ReasonClass::Unresolved,
]
.into_iter()
.collect()
}
}
pub fn reason_class_matches(classes: Option<&BTreeSet<String>>, want: &BTreeSet<&str>) -> bool {
match classes {
None => want.contains(ReasonClass::Unresolved.token()),
Some(cs) if cs.is_empty() => want.contains(ReasonClass::Unresolved.token()),
Some(cs) => cs.iter().any(|t| want.contains(t.as_str())),
}
}
pub fn parse_unknown_aliases(config_text: &str) -> std::collections::BTreeMap<String, BTreeSet<ReasonClass>> {
let mut out = std::collections::BTreeMap::new();
for raw in config_text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut it = line.splitn(2, char::is_whitespace);
if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("unknown-alias")) {
continue;
}
let val = it.next().unwrap_or("").trim();
let Some((name, classes)) = val.split_once('=') else {
eprintln!("candor: ignoring `unknown-alias` (want `unknown-alias <name> = <class,…>`): {val}");
continue;
};
let name = name.trim();
if name.is_empty() || name == "*" || name == "dynamic" || ReasonClass::from_token(name).is_some() {
eprintln!("candor: ignoring `unknown-alias` with reserved/empty name `{name}` (may not shadow `*`/`dynamic`/a class token)");
continue;
}
let mut set = BTreeSet::new();
let mut bad: Vec<&str> = Vec::new();
for cn in classes.split(',') {
let cn = cn.trim();
if cn.is_empty() {
continue;
}
if cn == "dynamic" {
set.extend(ReasonClass::dynamic_set());
} else if let Some(rc) = ReasonClass::from_token(cn) {
set.insert(rc);
} else {
bad.push(cn);
}
}
if !bad.is_empty() {
eprintln!(
"candor: REFUSING `unknown-alias {name}` — it names unrecognised reason-class(es) `{}` \
(accepted: reflect, dispatch, indirect, native, unresolved, setup, plus `dynamic`). The \
definition is refused WHOLE rather than narrowed to the tokens that parsed: keeping the \
rest would silently redefine `{name}` as something narrower than the config says, and a \
policy using it would gate less than it reads. `{name}` is now undefined, so a policy \
naming it is a policy error (exit 2).",
bad.join("`, `")
);
} else if set.is_empty() {
eprintln!("candor: ignoring `unknown-alias {name}` — no valid reason-class");
} else {
out.insert(name.to_string(), set);
}
}
out
}
pub fn parse_net_partners(config_text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for raw in config_text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut it = line.splitn(2, char::is_whitespace);
if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("net-partner")) {
continue;
}
let val = it.next().unwrap_or("").trim();
if val.is_empty() {
continue;
}
if val.contains(char::is_whitespace) || val.starts_with('=') {
eprintln!(
"candor: net-partner takes a bare host — `net-partner <host>`, one per line; \
'{val}' is not one and was IGNORED (an '=' or extra words is the usual cause) — {}",
raw.trim()
);
continue;
}
out.insert(host_part(val).to_ascii_lowercase());
}
out
}
pub fn discover_config_text(start: &std::path::Path) -> Option<String> {
discover_config(start).map(|(_, t)| t)
}
static REFUSAL_SINK: std::sync::OnceLock<fn(&str) -> !> = std::sync::OnceLock::new();
pub fn set_refusal_sink(f: fn(&str) -> !) {
let _ = REFUSAL_SINK.set(f);
}
fn refuse_unevaluable(reason: &str) -> ! {
if let Some(f) = REFUSAL_SINK.get() {
f(reason)
}
std::process::exit(2)
}
pub fn discover_config(start: &std::path::Path) -> Option<(std::path::PathBuf, String)> {
let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
if let Ok(p) = std::env::var("CANDOR_CONFIG") {
let p = std::path::PathBuf::from(p);
match std::fs::read_to_string(&p) {
Ok(t) => return Some((canon(&p), t)),
Err(e) => {
eprintln!("candor: CANDOR_CONFIG set but {} could not be read ({e}) — failing (exit 2,", p.display());
eprintln!(" unevaluable). A config that cannot be read is a guard the operator believes is on.");
refuse_unevaluable(&format!("CANDOR_CONFIG set but {} could not be read ({e})", p.display()));
}
}
}
let start = canon(start);
let mut cur = if start.is_dir() { Some(start.as_path()) } else { start.parent() };
while let Some(d) = cur {
let cand = d.join(".candor/config");
if cand.is_file() {
match std::fs::read_to_string(&cand) {
Ok(t) => return Some((canon(&cand), t)),
Err(e) => {
eprintln!("candor: {} exists but could not be read ({e}) — failing (exit 2,", cand.display());
eprintln!(" unevaluable). Treating it as absent would run without what it declares.");
refuse_unevaluable(&format!("{} exists but could not be read ({e})", cand.display()));
}
}
}
cur = d.parent();
}
None
}
#[derive(Debug, Clone)]
pub struct PolicyRule {
pub effects: BTreeSet<&'static str>,
pub scope: Option<String>,
pub unknown_classes: BTreeSet<ReasonClass>,
pub net_classes: BTreeSet<String>,
pub raw: String,
}
pub fn canonical_deny_rule(r: &PolicyRule) -> String {
let suffix = r.scope.as_deref().map(|s| format!(" {s}")).unwrap_or_default();
if r.effects.is_empty() {
return format!("pure{suffix}");
}
let parts: Vec<String> = r
.effects
.iter()
.map(|&e| {
if e == "Unknown" && !r.unknown_classes.is_empty() {
let mut toks: Vec<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
toks.sort_unstable();
format!("Unknown[{}]", toks.join(","))
} else if e == "Net" && !r.net_classes.is_empty() {
format!("Net[{}]", r.net_classes.iter().cloned().collect::<Vec<_>>().join(","))
} else {
e.to_string()
}
})
.collect();
format!("deny {}{suffix}", parts.join(" "))
}
pub fn canonical_deny_set(rules: &[PolicyRule]) -> Vec<String> {
rules.iter().map(canonical_deny_rule).collect::<BTreeSet<String>>().into_iter().collect()
}
#[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(Debug, Clone)]
pub struct OnlyRule {
pub from: String,
pub to: Vec<String>,
pub raw: String,
}
#[derive(Debug, Clone)]
pub struct PolicyError {
pub kind: &'static str,
pub token: String,
pub accepted: Vec<String>,
pub rule: String,
pub message: String,
pub line: usize,
pub text: String,
pub fatal: bool,
}
impl PolicyError {
pub const KIND_REASON_CLASS: &'static str = "reason-class/alias";
pub const KIND_NET_CLASS: &'static str = "Net destination-class";
pub const KIND_EFFECT_NAME: &'static str = "effect-name";
pub const KIND_RULE_KIND: &'static str = "rule-kind";
}
#[derive(Default, Debug)]
pub struct ParsedPolicy {
pub rules: Vec<PolicyRule>,
pub allow_rules: Vec<AllowRule>,
pub layer_rules: Vec<LayerRule>,
pub only_rules: Vec<OnlyRule>,
pub errors: Vec<PolicyError>,
pub used_aliases: BTreeMap<String, BTreeSet<String>>,
}
impl ParsedPolicy {
pub fn fatal_messages(&self) -> Vec<&str> {
self.errors.iter().filter(|e| e.fatal).map(|e| e.message.as_str()).collect()
}
}
pub fn host_part(h: &str) -> &str {
if let Some(rest) = h.strip_prefix('[') {
return rest.split(']').next().unwrap_or(rest);
}
if h.matches(':').count() > 1 {
return h; }
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 db_table_covered(a: &str, r: &str) -> bool {
let (a, r) = (a.to_lowercase(), r.to_lowercase());
if let Some(schema) = a.strip_suffix(".*") {
return r.strip_prefix(schema).is_some_and(|rest| rest.starts_with('.'));
}
a == r
}
pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
match effect {
"Net" | "Llm" => 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)),
"Db" => allow.iter().any(|a| db_table_covered(a, reached)),
_ => allow.contains(reached),
}
}
fn name_segments(s: &str) -> Vec<&str> {
s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
}
pub fn scope_matches_permitted(name: &str, scope: &str) -> bool {
let segs = name_segments(name);
let parts = name_segments(scope);
if parts.is_empty() || parts.len() > segs.len() {
return false;
}
segs.windows(parts.len()).any(|w| w == parts.as_slice())
}
fn scope_is_exact(scope: &str) -> bool {
let t = scope.trim_end();
t.ends_with("::") || t.ends_with('.')
}
pub fn scope_matches(name: &str, scope: &str) -> bool {
let segs = name_segments(name);
let parts = name_segments(scope);
if parts.is_empty() || parts.len() > segs.len() {
return false;
}
let exact = scope_is_exact(scope);
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 && if exact { w_last == last } else { w_last.starts_with(last) }
})
}
pub fn rule_and_upgrade(r: &PolicyRule) -> (String, String) {
let scope = r.scope.clone().unwrap_or_default();
let suffix = if scope.is_empty() { String::new() } else { format!(" {scope}") };
if r.effects.is_empty() {
return (format!("pure{suffix}"), format!("deny Unknown{suffix}"));
}
let term = |e: &str| -> String {
if e == UNKNOWN && !r.unknown_classes.is_empty() {
let mut t: Vec<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
t.sort_unstable();
format!("{UNKNOWN}[{}]", t.join(","))
} else if e == "Net" && !r.net_classes.is_empty() {
format!("Net[{}]", r.net_classes.iter().map(String::as_str).collect::<Vec<_>>().join(","))
} else {
e.to_string()
}
};
let effs = r.effects.iter().map(|e| term(e)).collect::<Vec<_>>().join(" ");
if r.effects.contains(UNKNOWN) {
let widened =
r.effects.iter().map(|e| if *e == UNKNOWN { UNKNOWN.to_string() } else { term(e) }).collect::<Vec<_>>();
(format!("deny {effs}{suffix}"), format!("deny {}{suffix}", widened.join(" ")))
} else {
(format!("deny {effs}{suffix}"), format!("deny {effs} {UNKNOWN}{suffix}"))
}
}
pub fn unverified_hole_rule<'a, S: AsRef<str>>(
name: &str,
effects: &[S],
reason_classes: Option<&BTreeSet<String>>,
net_classes: &[String],
rules: &'a [PolicyRule],
) -> Option<&'a PolicyRule> {
if !effects.iter().any(|e| e.as_ref() == UNKNOWN) {
return None;
}
let effs: Vec<&str> = effects.iter().map(AsRef::as_ref).collect();
rules.iter().find(|r| {
if let Some(s) = &r.scope {
if !scope_matches(name, s) {
return false;
}
}
crate::gate::rule_hits(r, &effs, reason_classes, net_classes).hits.is_empty()
})
}
fn is_ascii_ws(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
}
pub fn parse_policy(text: &str) -> ParsedPolicy {
parse_policy_impl(text, true, &std::collections::BTreeMap::new())
}
pub fn parse_policy_with_aliases(text: &str, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
parse_policy_impl(text, true, aliases)
}
pub fn parse_policy_silent(
text: &str,
aliases: &std::collections::BTreeMap<String, BTreeSet<ReasonClass>>,
) -> ParsedPolicy {
parse_policy_impl(text, false, aliases)
}
pub fn parse_policy_quiet(text: &str) -> ParsedPolicy {
parse_policy_impl(text, false, &std::collections::BTreeMap::new())
}
fn parse_policy_impl(text: &str, warn: bool, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
macro_rules! warn_ignore { ($($a:tt)*) => { if warn { eprintln!($($a)*); } } }
let mut out = ParsedPolicy::default();
let mut cur_line: usize;
let mut cur_text: &str;
macro_rules! not_honoured {
($fatal:expr, $kind:expr, $token:expr, $accepted:expr, $rule:expr, $msg:expr) => {{
let message: String = $msg;
out.errors.push(PolicyError {
kind: $kind,
token: ($token).to_string(),
accepted: ($accepted).iter().map(|s: &&str| s.to_string()).collect(),
rule: ($rule).to_string(),
message,
line: cur_line,
text: cur_text.to_string(),
fatal: $fatal,
});
}};
}
let normalized;
let text = if text.contains('\r') {
normalized = text.replace("\r\n", "\n").replace('\r', "\n");
normalized.as_str()
} else {
text
};
for (line_idx, raw_line) in text.lines().enumerate() {
cur_line = line_idx + 1;
cur_text = raw_line;
let line = raw_line.split('#').next().unwrap_or("").trim_matches(is_ascii_ws);
if line.is_empty() {
continue;
}
let mut toks = line.split(is_ascii_ws).filter(|s| !s.is_empty());
match toks.next().unwrap_or("") {
"allow" => {
let effect = match toks.next().unwrap_or("") {
"Net" => "Net",
"Llm" => "Llm",
"Exec" => "Exec",
"Fs" => "Fs",
"Db" => "Db",
other => {
let msg = format!(
"unknown effect-name `{other}` in `allow` (accepted: Db, Exec, Fs, Llm, Net \
\u{2014} `allow` covers only the effects carrying a literal surface: Net/Llm \
hosts, Exec commands, Fs paths, Db tables): {line}"
);
warn_ignore!("candor: policy error — {msg}");
not_honoured!(
true,
PolicyError::KIND_EFFECT_NAME,
other,
["Db", "Exec", "Fs", "Llm", "Net"],
line,
msg
);
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() {
let msg = format!("`allow {effect}` names no values: {line}");
warn_ignore!("candor: ignoring policy rule ({msg})");
not_honoured!(false, PolicyError::KIND_RULE_KIND, "", [], line, msg);
continue;
}
out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
}
"deny" => {
let mut effects = BTreeSet::new();
let mut scope = None;
let mut unknown_classes: BTreeSet<ReasonClass> = BTreeSet::new();
let mut unknown_star = false;
let mut net_classes: BTreeSet<String> = BTreeSet::new();
let mut net_star = false;
for t in toks {
if let Some(inner) = t.strip_prefix("Net[").and_then(|s| s.strip_suffix(']')) {
effects.insert("Net");
for cn in inner.split(',') {
let cn = cn.trim();
if cn.is_empty() {
continue;
}
if cn == "*" {
net_star = true;
} else if crate::NET_DEST_CLASSES.contains(&cn) {
net_classes.insert(cn.to_string());
} else {
not_honoured!(
true,
PolicyError::KIND_NET_CLASS,
cn,
["known-telemetry", "known-partner", "unknown-host", "*"],
line,
format!(
"unrecognised Net destination-class `{cn}` in `{line}` — \
accepted: known-telemetry, known-partner, unknown-host, plus `*`"
)
);
}
}
continue;
}
if let Some(inner) = t.strip_prefix("Unknown[").and_then(|s| s.strip_suffix(']')) {
effects.insert(UNKNOWN);
for cn in inner.split(',') {
let cn = cn.trim();
if cn.is_empty() {
continue;
}
if cn == "*" {
unknown_star = true;
} else if cn == "dynamic" {
unknown_classes.extend(ReasonClass::dynamic_set());
} else if let Some(rc) = ReasonClass::from_token(cn) {
unknown_classes.insert(rc);
} else if let Some(a) = aliases.get(cn) {
unknown_classes.extend(a.iter().copied()); out.used_aliases
.insert(cn.to_string(), a.iter().map(|c| c.token().to_string()).collect());
} else {
not_honoured!(
true,
PolicyError::KIND_REASON_CLASS,
cn,
[
"reflect",
"dispatch",
"indirect",
"native",
"unresolved",
"setup",
"dynamic",
"*"
],
line,
format!(
"unrecognised reason-class/alias `{cn}` in `{line}` — accepted: \
reflect, dispatch, indirect, native, unresolved, setup, plus the \
aliases `dynamic` and `*`, plus any `unknown-alias` defined in \
the `.candor/config` beside the policy. (⟨0.24⟩ an \
`unknown-alias` whose OWN definition names an unrecognised class \
is refused WHOLE, so a typo in the config surfaces as an \
undefined alias here — check the `unknown-alias` lines too, and \
the line above this one.)"
)
);
}
}
continue;
}
let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
match e {
Some(e) => {
effects.insert(e);
if e == UNKNOWN {
unknown_star = true; }
if e == "Net" {
net_star = true; }
}
None => {
scope = Some(t.to_string());
break;
}
}
}
if effects.is_empty() {
let mut acc: Vec<&str> = candor_report::EFFECTS.to_vec();
acc.push(UNKNOWN);
acc.sort_unstable();
let msg = format!(
"`deny` names no known effect (accepted: {}): {line}",
acc.join(", ")
);
warn_ignore!("candor: policy error — {msg}");
not_honoured!(
true,
PolicyError::KIND_EFFECT_NAME,
scope.as_deref().unwrap_or(""),
acc,
line,
msg
);
continue;
}
if unknown_star {
unknown_classes.clear();
} else if !unknown_classes.is_empty() && !unknown_classes.contains(&ReasonClass::Unresolved) {
warn_ignore!("candor: policy rule narrows `Unknown[…]` but omits `unresolved` — may UNDER-gate on holes the engine couldn't classify; add `unresolved` (or use `dynamic`) to stay conservative: {line}");
}
if net_star {
net_classes.clear();
}
out.rules.push(PolicyRule { effects, scope, unknown_classes, net_classes, raw: line.to_string() });
}
"pure" => out.rules.push(PolicyRule {
effects: BTreeSet::new(),
scope: toks.next().map(str::to_string),
unknown_classes: BTreeSet::new(),
net_classes: BTreeSet::new(),
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() {
let msg = format!("`forbid` is malformed (want `forbid <scope> -> <scope>`): {line}");
warn_ignore!("candor: ignoring layering rule ({msg})");
not_honoured!(false, PolicyError::KIND_RULE_KIND, arrow, ["->"], line, msg);
continue;
}
out.layer_rules.push(LayerRule {
from: a.to_string(),
to: b.to_string(),
raw: line.to_string(),
});
}
"only" => {
let a = toks.next().unwrap_or("");
let arrow = toks.next().unwrap_or("");
let to: Vec<String> = toks.map(str::to_string).collect();
if a.is_empty() || arrow != "->" || to.is_empty() {
let msg = format!(
"`only` is malformed (want `only <scope> -> <scope> [<scope> …]`): {line}"
);
warn_ignore!("candor: ignoring permission rule ({msg})");
not_honoured!(false, PolicyError::KIND_RULE_KIND, arrow, ["->"], line, msg);
continue;
}
out.only_rules.push(OnlyRule {
from: a.to_string(),
to,
raw: line.to_string(),
});
}
other => {
let msg = format!(
"unknown rule kind `{other}` (accepted: allow, deny, forbid, only, pure): {line}"
);
warn_ignore!("candor: ignoring policy rule ({msg})");
not_honoured!(
false,
PolicyError::KIND_RULE_KIND,
other,
["allow", "deny", "forbid", "only", "pure"],
line,
msg
);
}
}
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn db_table_covering_is_strict() {
use super::db_table_covered as c;
assert!(c("ledger.entries", "Ledger.Entries")); assert!(c("ledger.*", "ledger.entries")); assert!(!c("ledger.*", "ledgerx.entries")); assert!(!c("entries", "ledger.entries")); assert!(c("entries", "entries"));
}
#[test]
fn allow_db_parses_and_gates() {
let p = super::parse_policy("allow Db in billing ledger.* customers\n");
assert_eq!(p.allow_rules.len(), 1);
assert_eq!(p.allow_rules[0].effect, "Db");
assert!(super::literal_allowed("Db", "ledger.entries", &p.allow_rules[0].literals));
assert!(super::literal_allowed("Db", "customers", &p.allow_rules[0].literals));
assert!(!super::literal_allowed("Db", "audit.log", &p.allow_rules[0].literals));
}
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"));
let cr = parse_policy("deny Net a\rdeny Exec b\rdeny Db c\r");
assert_eq!(cr.rules.len(), 3, "bare-CR lines must each parse");
assert!(cr.rules.iter().any(|r| r.effects.contains("Exec") && r.scope.as_deref() == Some("b")));
assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
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"));
assert!(parse_policy("deny\u{a0}Net core").rules.is_empty(),
"an NBSP between deny and the effect must NOT split into separate tokens");
let nb = parse_policy("deny Net \u{a0}domain");
assert_eq!(nb.rules.len(), 1);
assert_eq!(nb.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
assert_eq!(nb.rules[0].scope.as_deref(), Some("\u{a0}domain"));
}
#[test]
fn reason_scoped_unknown_parses() {
use super::ReasonClass::*;
let p = parse_policy("deny Net Unknown[dispatch,indirect] dom\n");
let r = &p.rules[0];
assert!(r.effects.contains("Unknown") && r.effects.contains("Net"));
assert_eq!(r.scope.as_deref(), Some("dom"));
assert_eq!(r.unknown_classes, [Dispatch, Indirect].into_iter().collect());
assert!(parse_policy("deny Net Unknown dom\n").rules[0].unknown_classes.is_empty(), "bare Unknown ⇒ all");
assert!(parse_policy("deny Net Unknown[*] dom\n").rules[0].unknown_classes.is_empty(), "Unknown[*] ⇒ all");
assert_eq!(
parse_policy("deny Net Unknown[dynamic] dom\n").rules[0].unknown_classes,
[Reflect, Dispatch, Indirect, Native, Unresolved].into_iter().collect()
);
let aliases = super::parse_unknown_aliases(
"unknown-alias risky = reflect,native\nunknown-alias telemetry = indirect\nunknown-alias reflect = native\n");
assert_eq!(aliases.get("risky"), Some(&[Reflect, Native].into_iter().collect()));
assert_eq!(aliases.get("telemetry"), Some(&[Indirect].into_iter().collect()));
assert!(!aliases.contains_key("reflect"), "a config alias may not shadow a class token");
assert_eq!(super::parse_unknown_aliases("Unknown-Alias hot = native\n").get("hot"),
Some(&[Native].into_iter().collect()), "the unknown-alias key must match case-insensitively");
let pr = super::parse_policy_with_aliases("deny Net Unknown[risky] api\n", &aliases);
assert_eq!(pr.rules[0].unknown_classes, [Reflect, Native].into_iter().collect());
assert!(super::parse_policy_with_aliases("deny Net Unknown[nope] api\n", &aliases).rules[0].unknown_classes.is_empty());
assert_eq!(ReasonClass::classify("reflect:Class.forName"), Reflect);
assert_eq!(ReasonClass::classify("native:extern fn"), Native);
assert_eq!(ReasonClass::classify("callback:unresolved call"), Indirect);
assert_eq!(ReasonClass::classify("ambiguous:same-name local defs"), Dispatch);
assert_eq!(ReasonClass::classify("unresolved"), Unresolved);
assert_eq!(ReasonClass::classify("whatever-new"), Unresolved); }
#[test]
fn off_vocabulary_kinds_round_trip_and_classify_through_the_catch_all() {
use ReasonClass::*;
assert_eq!(ReasonClass::classify("banana:whatever"), Unresolved);
assert_eq!(ReasonClass::classify("banana:dispatch of a banana"), Unresolved,
"a canonical kind appearing in the DETAIL must not leak into the classification");
for wrong in [Reflect, Dispatch, Indirect, Native] {
assert_ne!(ReasonClass::classify("banana:whatever"), wrong);
}
assert_eq!(ReasonClass::classify("reflect:x"), Reflect);
assert_eq!(ReasonClass::classify("native:x"), Native);
assert_eq!(ReasonClass::classify("dispatch:Owner.member"), Dispatch);
assert_eq!(ReasonClass::classify("callback:x"), Indirect);
assert_eq!(ReasonClass::classify("ambiguous:x"), Dispatch);
assert_eq!(ReasonClass::classify("dep:9f2c1a"), Unresolved);
assert_eq!(ReasonClass::classify("dep-stale:somepkg"), Unresolved);
}
#[test]
fn net_destination_class_parses_and_classifies() {
let p = parse_policy("deny Net[unknown-host,known-telemetry] dom\n");
let r = &p.rules[0];
assert!(r.effects.contains("Net"));
assert_eq!(r.scope.as_deref(), Some("dom"));
assert_eq!(
r.net_classes,
["unknown-host", "known-telemetry"].iter().map(|s| s.to_string()).collect()
);
assert!(parse_policy("deny Net dom\n").rules[0].net_classes.is_empty(), "bare Net ⇒ all");
assert!(parse_policy("deny Net[*] dom\n").rules[0].net_classes.is_empty(), "Net[*] ⇒ all");
assert!(parse_policy("deny Net[nope] dom\n").rules[0].net_classes.is_empty());
let no_partners = BTreeSet::new();
assert_eq!(crate::net_dest_class("sentry.io", &no_partners), "known-telemetry");
assert_eq!(crate::net_dest_class("us.i.posthog.com", &no_partners), "known-telemetry"); assert_eq!(crate::net_dest_class("o1.ingest.sentry.io", &no_partners), "known-telemetry");
assert_eq!(crate::net_dest_class("api.openai.com", &no_partners), "known-partner", "a model host is known-partner");
assert_eq!(crate::net_dest_class("evil.example.com", &no_partners), "unknown-host");
let partners: BTreeSet<String> = ["api.stripe.com".to_string()].into_iter().collect();
assert_eq!(crate::net_dest_class("api.stripe.com", &partners), "known-partner", "config-declared partner");
assert_eq!(crate::net_dest_class("api.stripe.com", &no_partners), "unknown-host", "partner is config-only");
let pset = super::parse_net_partners("net-partner Api.Stripe.com:443\nNET-PARTNER hooks.stripe.com\n");
assert!(pset.contains("api.stripe.com") && pset.contains("hooks.stripe.com"));
}
#[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 Clock 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("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
assert_eq!(host_part("api.stripe.com:443"), "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 parses_the_only_permission_form_and_drops_the_malformed() {
let p = parse_policy(
"only model -> util\n\
only app::web -> app::db app::dto \n\
only model util\n\
only model ->\n\
only\n",
);
assert_eq!(p.only_rules.len(), 2, "the two well-formed lines, and only those");
assert_eq!(p.only_rules[0].from.as_str(), "model");
assert_eq!(p.only_rules[0].to, vec!["util".to_string()]);
assert_eq!(p.only_rules[1].from.as_str(), "app::web");
assert_eq!(p.only_rules[1].to, vec!["app::db".to_string(), "app::dto".to_string()],
"every token after the arrow is a permitted scope");
assert!(!p.rules.is_empty() || !p.only_rules.is_empty(),
"an only-only policy must not read as a zero-rule file");
}
#[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"));
assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
assert!(!scope_matches("com.acme.subdomain.h", "domain"));
assert!(!scope_matches("com.acme.domain.h", "infra"));
}
#[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"));
}
#[test]
fn a_narrowed_rule_the_gate_tolerates_is_a_hole_and_the_one_it_fires_on_is_not() {
let effs = ["Unknown"];
let indirect: BTreeSet<String> = ["indirect".to_string()].into_iter().collect();
let hole = |src: &str, classes: Option<&BTreeSet<String>>, nets: &[String]| -> Option<String> {
let p = parse_policy(src);
unverified_hole_rule("app::go", &effs, classes, nets, &p.rules).map(|r| rule_and_upgrade(r).1)
};
assert_eq!(
hole("deny Unknown[reflect]\n", Some(&indirect), &[]).as_deref(),
Some("deny Unknown"),
"a rule the gate TOLERATES leaves the function unproven — that is the disclosure's whole subject"
);
assert_eq!(
hole("deny Unknown[indirect]\n", Some(&indirect), &[]),
None,
"a rule the gate FIRES on is a violation, not a hole — filter-awareness must not start \
disclosing the gate's own findings back as unproven passes"
);
assert_eq!(hole("deny Unknown[dynamic]\n", Some(&indirect), &[]), None, "`dynamic` covers indirect");
let netfn = ["Net".to_string(), "Unknown".to_string()];
let telemetry = vec!["known-telemetry".to_string()];
let p = parse_policy("deny Net[unknown-host]\n");
assert_eq!(
unverified_hole_rule("app::go", &netfn, Some(&indirect), &telemetry, &p.rules)
.map(|r| rule_and_upgrade(r).1)
.as_deref(),
Some("deny Net[unknown-host] Unknown"),
"a `Net[dest…]` the fn's destinations do not match is tolerated, so the Unknown beside it is a hole"
);
let p = parse_policy("deny Net[known-telemetry]\n");
assert_eq!(
unverified_hole_rule("app::go", &netfn, Some(&indirect), &telemetry, &p.rules).map(|r| r.raw.clone()),
None,
"MIRROR: the matching destination filter FIRES, so it is a violation and not a hole"
);
assert_eq!(hole("deny Unknown\n", Some(&indirect), &[]), None);
assert_eq!(hole("pure\n", Some(&indirect), &[]).as_deref(), Some("deny Unknown"));
assert_eq!(hole("deny Net Db domain\ndeny Fs\n", Some(&indirect), &[]).as_deref(), Some("deny Fs Unknown"));
assert_eq!(
hole("deny Net Db go\n", Some(&indirect), &[]).as_deref(),
Some("deny Db Net Unknown go"),
"the sorted multi-effect upgrade PART 12c-deny pins in all four engines"
);
assert_eq!(hole("deny Unknown[reflect]\n", None, &[]).as_deref(), Some("deny Unknown"));
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum PinVerdict {
Absent,
Match,
Mismatch,
Malformed,
Undetermined,
}
pub fn engine_pin_for(text: &str, impl_name: &str) -> Option<String> {
const IMPLS: [&str; 5] = ["java", "rust", "ts", "swift", "agents"];
let (mut wild, mut qual): (Option<String>, Option<String>) = (None, None);
let mut bad = false;
for raw in text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut it = line.split_whitespace();
if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("engine")) {
continue;
}
let rest: Vec<&str> = it.collect();
let slot = |cur: &mut Option<String>, v: String| {
if cur.as_ref().is_some_and(|p| *p != v) {
*cur = Some(format!("{} / {v}", cur.as_ref().unwrap()));
} else {
*cur = Some(v);
}
};
if let Some(head) = rest.first() {
if IMPLS.contains(&head.to_ascii_lowercase().as_str()) {
if head.eq_ignore_ascii_case(impl_name) {
if rest.len() == 2 { slot(&mut qual, rest[1].to_string()); } else { bad = true; }
}
continue; }
}
match rest.len() {
0 => bad = true, 1 => slot(&mut wild, rest[0].to_string()), _ => bad = true, }
}
if bad {
return Some("<unreadable>".to_string());
}
if let Some(w) = &wild {
if normalize_version(w).is_none() { return Some(w.clone()); }
}
qual.or(wild)
}
pub fn pin_verdict(pin: Option<&str>, running: &str) -> PinVerdict {
let Some(pin) = pin else { return PinVerdict::Absent };
let Some(want) = normalize_version(pin) else { return PinVerdict::Malformed };
if running.trim().is_empty() || running == "unknown" {
return PinVerdict::Undetermined;
}
if want == normalize_version(running).unwrap_or_else(|| running.trim().to_string()) {
PinVerdict::Match
} else {
PinVerdict::Mismatch
}
}
fn normalize_version(raw: &str) -> Option<String> {
let s = raw.trim().strip_prefix(['v', 'V']).unwrap_or_else(|| raw.trim());
let parts: Vec<&str> = s.split('.').collect();
if !(parts.len() == 2 || parts.len() == 3) || !parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) {
return None;
}
Some(if parts.len() == 2 { format!("{s}.0") } else { s.to_string() })
}