use crate::policy::{AcceptEntry, UNKNOWN};
use candor_report::{AcceptedItem, GateViolation};
use std::collections::{BTreeMap, BTreeSet};
pub struct RunFacts<'a> {
pub units: &'a [(String, String)],
pub unevaluated: &'a BTreeSet<String>,
}
#[derive(Default, Debug)]
pub struct Settled {
pub remaining: Vec<GateViolation>,
pub stale: Vec<GateViolation>,
pub accepted: Vec<AcceptedItem>,
pub accepted_rows: usize,
pub notes: Vec<String>,
pub direct_rows: Vec<(usize, Vec<GateViolation>)>,
pub stale_lines: Vec<usize>,
}
const DENY: &str = "AS-EFF-006";
const FORBID: &str = "AS-EFF-009";
fn enclosing_of(func: &str) -> Option<&str> {
if !func.ends_with('}') {
return None;
}
let at = func.rfind("::{")?;
let seg = &func[at + 2..];
crate::policy::is_positional_key(seg).then(|| &func[..at])
}
fn covers(e: &AcceptEntry, row: &GateViolation) -> bool {
for x in &row.effects {
if x == UNKNOWN {
let Some(cs) = &e.classes else { return false };
if !e.effects.contains(UNKNOWN) || row.reason_class.is_empty() {
return false;
}
if !row.reason_class.iter().all(|c| cs.contains(c)) {
return false;
}
} else if !e.effects.contains(x.as_str()) {
return false;
}
}
true
}
fn item(e: &AcceptEntry, rule: &str, func: &str, hash: &str, effects: Vec<String>, status: &str, enclosing: &str) -> AcceptedItem {
AcceptedItem {
rule: rule.to_string(),
func: func.to_string(),
hash: hash.to_string(),
effects,
line: e.line,
policy_rule: e.rule_key.clone(),
because: e.because.clone(),
status: status.to_string(),
enclosing: enclosing.to_string(),
}
}
pub fn twin_names(units: &[(String, String)]) -> BTreeSet<String> {
let mut by_name: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (n, id) in units {
by_name.entry(n.as_str()).or_default().insert(id.as_str());
}
by_name.into_iter().filter(|(_, s)| s.len() > 1).map(|(n, _)| n.to_string()).collect()
}
pub fn settle(entries: &[AcceptEntry], rows: Vec<GateViolation>, facts: &RunFacts) -> Settled {
let mut out = Settled::default();
if entries.is_empty() {
out.remaining = rows;
return out;
}
let twins = twin_names(facts.units);
let twin = |name: &str| twins.contains(name);
let usable = |e: &AcceptEntry| !facts.unevaluated.contains(&e.rule_key) && !twin(&e.func);
let policy_row = |r: &GateViolation| !r.rule_key.is_empty() && (r.rule == DENY || r.rule == FORBID);
let mut acc = vec![false; rows.len()];
let mut direct: Vec<Vec<usize>> = vec![Vec::new(); entries.len()];
let mut nested: Vec<Vec<(usize, usize)>> = vec![Vec::new(); entries.len()];
for (i, r) in rows.iter().enumerate() {
if !policy_row(r) {
continue;
}
for (k, e) in entries.iter().enumerate() {
if usable(e) && e.rule_key == r.rule_key && e.func == r.func && covers(e, r) {
acc[i] = true;
direct[k].push(i);
}
}
}
for (i, v) in rows.iter().enumerate() {
if acc[i] || !policy_row(v) {
continue;
}
let mut cur: &str = &v.func;
for _ in 0..8 {
let Some(enc) = enclosing_of(cur) else { break };
for (k, e) in entries.iter().enumerate() {
if !usable(e) || e.rule_key != v.rule_key || e.func != enc {
continue;
}
for &u in &direct[k] {
let ur = &rows[u];
if ur.func == enc && ur.rule_key == v.rule_key && v.effects.iter().all(|x| ur.effects.contains(x)) {
acc[i] = true;
nested[k].push((i, u));
}
}
}
if acc[i] {
break;
}
cur = enc; }
}
out.accepted_rows = acc.iter().filter(|a| **a).count();
let remaining_idx: Vec<usize> = (0..rows.len()).filter(|&i| !acc[i]).collect();
let mut items: Vec<AcceptedItem> = Vec::new();
for (k, e) in entries.iter().enumerate() {
let code = if e.forbid { FORBID } else { DENY };
let names: Vec<String> = e.effects.iter().map(|s| s.to_string()).collect();
if facts.unevaluated.contains(&e.rule_key) {
items.push(item(e, code, &e.func, "", names, "unjudged", ""));
out.notes.push(format!(
"policy line {} (`{}`) is UNJUDGED — its rule `{}` could not be evaluated on this route, so nothing was \
accepted by it. Gate at scan time (`candor-scan <dir> --policy …`) to judge it.",
e.line, e.text.trim(), e.rule_key
));
continue;
}
if twin(&e.func) {
items.push(item(e, code, &e.func, "", names, "ambiguous", ""));
out.notes.push(format!(
"policy line {} (`{}`) is AMBIGUOUS — two units in this run are named `{}` with different identities, \
so it accepts NEITHER. Gate the modules separately (one policy file per separately-gated module) so \
each name is one unit.",
e.line, e.text.trim(), e.func
));
continue;
}
if !direct[k].is_empty() {
out.direct_rows.push((e.line, direct[k].iter().map(|&i| rows[i].clone()).collect()));
let &first = direct[k].iter().min_by_key(|&&i| (rows[i].hash.clone(), rows[i].effects.clone())).unwrap();
let r = &rows[first];
let mut eff = r.effects.clone();
eff.sort();
items.push(item(e, &r.rule, &r.func, &r.hash, eff, "accepted", ""));
for &(vi, _) in &nested[k] {
let v = &rows[vi];
let mut eff = v.effects.clone();
eff.sort();
items.push(item(e, &v.rule, &v.func, &v.hash, eff, "accepted", &e.func));
}
continue;
}
let over = remaining_idx.iter().map(|&i| &rows[i]).find(|r| policy_row(r) && r.rule_key == e.rule_key && r.func == e.func);
if let Some(r) = over {
items.push(item(e, code, &e.func, "", names, "exceeded", ""));
out.notes.push(format!(
"policy line {} (`{}`) no longer covers `{}`: it now performs {{ {} }} under `{}`, more than the {{ {} }} \
accepted. The violation stays red. Review the new behaviour; if it is intended, widen the entry to list \
it, otherwise fix the code.",
e.line, e.text.trim(), e.func, r.effects.join(", "), e.rule_key, e.effects.iter().copied().collect::<Vec<_>>().join(", ")
));
continue;
}
out.stale_lines.push(e.line);
out.stale.push(GateViolation {
rule: "AS-EFF-012".into(),
func: e.func.clone(),
effects: names.clone(),
detail: format!(
"`{}` has a STALE acceptance — policy line {} (`{}`) matched no violation of `{}`. Delete line {}: left \
in place it would accept whatever a later edit puts under that key",
e.func, e.line, e.text.trim(), e.rule_key, e.line
),
..Default::default()
});
}
items.sort_by(|a, b| (a.line, &a.func, &a.hash).cmp(&(b.line, &b.func, &b.hash)));
out.accepted = items;
out.remaining = rows.into_iter().enumerate().filter(|(i, _)| !acc[*i]).map(|(_, r)| r).collect();
out
}
pub fn announce(accepted_rows: usize, new: usize) -> Option<String> {
(accepted_rows > 0).then(|| format!("{accepted_rows} violation(s) accepted by policy; {new} new"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::parse_policy_silent;
fn row(fnn: &str, key: &str, eff: &[&str], hash: &str, rc: &[&str]) -> GateViolation {
GateViolation {
rule: if eff.is_empty() { FORBID } else { DENY }.into(),
func: fnn.into(),
hash: hash.into(),
effects: eff.iter().map(|s| s.to_string()).collect(),
reason_class: rc.iter().map(|s| s.to_string()).collect(),
rule_key: key.into(),
..Default::default()
}
}
fn entries(pol: &str) -> Vec<AcceptEntry> {
parse_policy_silent(pol, &Default::default()).accepts
}
fn run<'a>(units: &'a [(String, String)], uneval: &'a BTreeSet<String>) -> RunFacts<'a> {
RunFacts { units, unevaluated: uneval }
}
fn u(n: &str, id: &str) -> (String, String) {
(n.to_string(), id.to_string())
}
#[test]
fn grammar_pins_every_malformed_form_to_ignored() {
let bad = [
"deny Fs dao\naccept dao.a Fs for deny Fs dao", "deny Fs dao\naccept dao.a Fs for deny Fs dao because", "deny Fs dao\naccept dao.a Fs for deny Fs dao because #123", "deny Fs dao\naccept dao.a Fs deny Fs dao because x", "deny Fs dao\naccept dao.a for deny Fs dao because x", "deny Fs dao\naccept dao.a Fs for deny Fs app because x", "deny Fs dao\naccept dao.a Net[unknown-host] for deny Fs dao because x", "deny Unknown dao\naccept dao.a Unknown for deny Unknown dao because x", "deny Unknown dao\naccept dao.a Unknown[*] for deny Unknown dao because x",
"deny Unknown dao\naccept dao.a Unknown[dynamic] for deny Unknown dao because x",
"deny Fs dao\naccept dao.f::{closure#0} Fs for deny Fs dao because x",
"deny Fs dao\nallow Net in dao ok.example\naccept dao.a Net for allow Net in dao ok.example because x",
"forbid dao -> infra\naccept dao.a Fs for forbid dao -> infra because x", "deny Fs dao\naccept dao.a Fs for deny Fs dao extra because x", "deny Fs dao extra\naccept dao.a Fs for deny Fs dao because x", ];
for pol in bad {
let p = parse_policy_silent(pol, &Default::default());
assert!(p.accepts.is_empty(), "must NOT become an entry: {pol:?}");
assert!(p.errors.iter().any(|e| e.token == "accept" && !e.fatal), "must be listed as ignored: {pol:?}");
}
for pol in [
"deny Fs dao\naccept dao.a Fss for deny Fs dao because x",
"deny Unknown dao\naccept dao.a Unknown[dispatchh] for deny Unknown dao because x",
] {
let p = parse_policy_silent(pol, &Default::default());
assert!(p.accepts.is_empty());
assert!(!p.fatal_messages().is_empty(), "must be fatal: {pol:?}");
}
}
#[test]
fn grammar_reads_the_good_forms() {
let p = parse_policy_silent(
"accept dao.a Fs for deny Fs\tdao extra because known since 2026 # why\n\
deny Fs dao extra\n\
pure parse\n\
forbid dao -> infra\n\
accept parse.x Clock Rand for pure parse because x\n\
accept dao.b for forbid dao -> infra because y\n\
deny Unknown[dispatch,native] dao\n\
accept dao.c Unknown[dispatch] for deny Unknown[dispatch,native] dao because z\n",
&Default::default(),
);
assert!(p.errors.is_empty(), "{:?}", p.errors.iter().map(|e| &e.message).collect::<Vec<_>>());
assert_eq!(p.accepts.len(), 4);
assert_eq!(p.accepts[0].rule_key, "deny Fs dao extra");
assert_eq!(p.accepts[0].because, "known since 2026");
assert_eq!(p.accepts[0].line, 1);
assert!(p.accepts[2].forbid && p.accepts[2].effects.is_empty());
assert_eq!(p.accepts[3].classes, Some(["dispatch".to_string()].into_iter().collect()));
assert_eq!(p.rules.len(), 3);
}
#[test]
fn only_accept_lines_is_still_zero_rules() {
let p = parse_policy_silent("accept dao.a Fs for deny Fs dao because x\n", &Default::default());
assert!(p.rules.is_empty() && p.allow_rules.is_empty() && p.layer_rules.is_empty() && p.only_rules.is_empty());
}
#[test]
fn exact_fn_per_line_effect_subset() {
let es = entries("deny Fs dao\ndeny Fs\naccept dao.a Fs for deny Fs dao because x\n");
let units = [u("dao.a", "p#dao.a"), u("dao.ab", "p#dao.ab")];
let none = BTreeSet::new();
let rows = vec![
row("dao.a", "deny Fs dao", &["Fs"], "p#dao.a", &[]),
row("dao.a", "deny Fs", &["Fs"], "p#dao.a", &[]), row("dao.ab", "deny Fs dao", &["Fs"], "p#dao.ab", &[]), ];
let s = settle(&es, rows, &run(&units, &none));
assert_eq!(s.accepted_rows, 1);
assert_eq!(s.remaining.len(), 2);
assert!(s.remaining.iter().all(|r| !(r.func == "dao.a" && r.rule_key == "deny Fs dao")));
assert!(s.stale.is_empty());
assert_eq!(s.accepted.len(), 1);
assert_eq!(s.accepted[0].status, "accepted");
let es = entries("deny Fs Rand dao\naccept dao.a Fs for deny Fs Rand dao because x\n");
let rows = vec![row("dao.a", "deny Fs Rand dao", &["Fs", "Rand"], "p#dao.a", &[])];
let s = settle(&es, rows, &run(&units, &none));
assert_eq!(s.accepted_rows, 0);
assert_eq!(s.remaining.len(), 1);
assert!(s.stale.is_empty());
assert_eq!(s.accepted[0].status, "exceeded");
assert!(s.notes[0].contains("widen the entry"));
}
#[test]
fn twins_unjudged_stale_and_unknown_classes() {
let none = BTreeSet::new();
let es = entries("deny Fs dao\naccept dao.t Fs for deny Fs dao because x\n");
let units = [u("dao.t", "p#dao.t"), u("dao.t", "q#dao.t")];
let rows = vec![row("dao.t", "deny Fs dao", &["Fs"], "p#dao.t", &[]), row("dao.t", "deny Fs dao", &["Fs"], "q#dao.t", &[])];
let s = settle(&es, rows, &run(&units, &none));
assert_eq!((s.accepted_rows, s.remaining.len(), s.stale.len()), (0, 2, 0));
assert_eq!(s.accepted[0].status, "ambiguous");
let units = [u("dao.a", "p#dao.a")];
let uneval: BTreeSet<String> = ["deny Fs dao".to_string()].into_iter().collect();
let s = settle(&es_for("dao.a"), vec![], &run(&units, &uneval));
assert_eq!(s.accepted[0].status, "unjudged");
assert!(s.stale.is_empty());
let s = settle(&es_for("dao.a"), vec![], &run(&units, &none));
assert_eq!(s.stale.len(), 1);
assert_eq!(s.stale[0].rule, "AS-EFF-012");
assert!(s.stale[0].detail.contains("Delete line 2"));
let es = entries("deny Unknown dao\naccept dao.c Unknown[dispatch] for deny Unknown dao because x\n");
let units = [u("dao.c", "p#dao.c")];
let ok = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &["dispatch"])], &run(&units, &none));
assert_eq!(ok.accepted_rows, 1);
let moved = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &["dispatch", "native"])], &run(&units, &none));
assert_eq!((moved.accepted_rows, moved.remaining.len()), (0, 1));
assert_eq!(moved.accepted[0].status, "exceeded");
let blind = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &[])], &run(&units, &none));
assert_eq!(blind.accepted_rows, 0, "an Unknown whose classes are not determinable is covered by NO filter");
}
fn es_for(f: &str) -> Vec<AcceptEntry> {
entries(&format!("deny Fs dao\naccept {f} Fs for deny Fs dao because x\n"))
}
#[test]
fn nested_units_need_the_enclosing_row_and_a_subset() {
let none = BTreeSet::new();
let es = es_for("dao::a");
let units = [u("dao::a", "p#dao::a"), u("dao::a::{closure#0}", "p#dao::a::{closure#0}")];
let a = row("dao::a", "deny Fs dao", &["Fs"], "p#dao::a", &[]);
let v = row("dao::a::{closure#0}", "deny Fs dao", &["Fs"], "p#dao::a::{closure#0}", &[]);
let s = settle(&es, vec![a.clone(), v.clone()], &run(&units, &none));
assert_eq!((s.accepted_rows, s.remaining.len()), (2, 0));
assert_eq!(s.accepted.len(), 2);
assert_eq!(s.accepted[1].enclosing, "dao::a");
let v2 = row("dao::a::{closure#0}", "deny Fs dao", &["Fs", "Clock"], "p#dao::a::{closure#0}", &[]);
let s = settle(&es, vec![a.clone(), v2], &run(&units, &none));
assert_eq!((s.accepted_rows, s.remaining.len()), (1, 1));
let s = settle(&es, vec![v], &run(&units, &none));
assert_eq!((s.accepted_rows, s.remaining.len()), (0, 1));
assert_eq!(s.stale.len(), 1, "nothing accepted and nothing exceeded");
}
#[test]
fn positional_spellings() {
for f in ["a::{closure#0}", "a::{closure#12}::{closure#3}", "x::{async_block#1}", "<T as U>::m::{constant#0}"] {
assert!(crate::policy::is_positional_key(f), "{f}");
}
for f in ["a::closure", "a::{closure}", "a::{closure#}", "a::{#0}", "dao::a", "a::b#1", "{closure#0"] {
assert!(!crate::policy::is_positional_key(f), "{f}");
}
}
}