1use crate::policy::{AcceptEntry, UNKNOWN};
17use candor_report::{AcceptedItem, GateViolation};
18use std::collections::{BTreeMap, BTreeSet};
19
20pub struct RunFacts<'a> {
22 pub units: &'a [(String, String)],
26 pub unevaluated: &'a BTreeSet<String>,
30}
31
32#[derive(Default, Debug)]
34pub struct Settled {
35 pub remaining: Vec<GateViolation>,
37 pub stale: Vec<GateViolation>,
40 pub accepted: Vec<AcceptedItem>,
42 pub accepted_rows: usize,
44 pub notes: Vec<String>,
47 pub direct_rows: Vec<(usize, Vec<GateViolation>)>,
51 pub stale_lines: Vec<usize>,
54}
55
56const DENY: &str = "AS-EFF-006";
57const FORBID: &str = "AS-EFF-009";
58
59fn enclosing_of(func: &str) -> Option<&str> {
64 if !func.ends_with('}') {
65 return None;
66 }
67 let at = func.rfind("::{")?;
68 let seg = &func[at + 2..];
69 crate::policy::is_positional_key(seg).then(|| &func[..at])
70}
71
72fn covers(e: &AcceptEntry, row: &GateViolation) -> bool {
73 for x in &row.effects {
74 if x == UNKNOWN {
75 let Some(cs) = &e.classes else { return false };
78 if !e.effects.contains(UNKNOWN) || row.reason_class.is_empty() {
79 return false;
80 }
81 if !row.reason_class.iter().all(|c| cs.contains(c)) {
82 return false;
83 }
84 } else if !e.effects.contains(x.as_str()) {
85 return false;
86 }
87 }
88 true
89}
90
91fn item(e: &AcceptEntry, rule: &str, func: &str, hash: &str, effects: Vec<String>, status: &str, enclosing: &str) -> AcceptedItem {
92 AcceptedItem {
93 rule: rule.to_string(),
94 func: func.to_string(),
95 hash: hash.to_string(),
96 effects,
97 line: e.line,
98 policy_rule: e.rule_key.clone(),
99 because: e.because.clone(),
100 status: status.to_string(),
101 enclosing: enclosing.to_string(),
102 }
103}
104
105pub fn twin_names(units: &[(String, String)]) -> BTreeSet<String> {
108 let mut by_name: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
109 for (n, id) in units {
110 by_name.entry(n.as_str()).or_default().insert(id.as_str());
111 }
112 by_name.into_iter().filter(|(_, s)| s.len() > 1).map(|(n, _)| n.to_string()).collect()
113}
114
115pub fn settle(entries: &[AcceptEntry], rows: Vec<GateViolation>, facts: &RunFacts) -> Settled {
118 let mut out = Settled::default();
119 if entries.is_empty() {
120 out.remaining = rows;
121 return out;
122 }
123 let twins = twin_names(facts.units);
125 let twin = |name: &str| twins.contains(name);
126 let usable = |e: &AcceptEntry| !facts.unevaluated.contains(&e.rule_key) && !twin(&e.func);
127 let policy_row = |r: &GateViolation| !r.rule_key.is_empty() && (r.rule == DENY || r.rule == FORBID);
128
129 let mut acc = vec![false; rows.len()];
130 let mut direct: Vec<Vec<usize>> = vec![Vec::new(); entries.len()];
132 let mut nested: Vec<Vec<(usize, usize)>> = vec![Vec::new(); entries.len()];
133
134 for (i, r) in rows.iter().enumerate() {
138 if !policy_row(r) {
139 continue;
140 }
141 for (k, e) in entries.iter().enumerate() {
142 if usable(e) && e.rule_key == r.rule_key && e.func == r.func && covers(e, r) {
143 acc[i] = true;
144 direct[k].push(i);
145 }
146 }
147 }
148 for (i, v) in rows.iter().enumerate() {
153 if acc[i] || !policy_row(v) {
154 continue;
155 }
156 let mut cur: &str = &v.func;
157 for _ in 0..8 {
158 let Some(enc) = enclosing_of(cur) else { break };
159 for (k, e) in entries.iter().enumerate() {
160 if !usable(e) || e.rule_key != v.rule_key || e.func != enc {
161 continue;
162 }
163 for &u in &direct[k] {
164 let ur = &rows[u];
165 if ur.func == enc && ur.rule_key == v.rule_key && v.effects.iter().all(|x| ur.effects.contains(x)) {
166 acc[i] = true;
167 nested[k].push((i, u));
168 }
169 }
170 }
171 if acc[i] {
172 break;
173 }
174 cur = enc; }
176 }
177 out.accepted_rows = acc.iter().filter(|a| **a).count();
178 let remaining_idx: Vec<usize> = (0..rows.len()).filter(|&i| !acc[i]).collect();
179
180 let mut items: Vec<AcceptedItem> = Vec::new();
182 for (k, e) in entries.iter().enumerate() {
183 let code = if e.forbid { FORBID } else { DENY };
184 let names: Vec<String> = e.effects.iter().map(|s| s.to_string()).collect();
185 if facts.unevaluated.contains(&e.rule_key) {
186 items.push(item(e, code, &e.func, "", names, "unjudged", ""));
187 out.notes.push(format!(
188 "policy line {} (`{}`) is UNJUDGED — its rule `{}` could not be evaluated on this route, so nothing was \
189 accepted by it. Gate at scan time (`candor-scan <dir> --policy …`) to judge it.",
190 e.line, e.text.trim(), e.rule_key
191 ));
192 continue;
193 }
194 if twin(&e.func) {
195 items.push(item(e, code, &e.func, "", names, "ambiguous", ""));
196 out.notes.push(format!(
197 "policy line {} (`{}`) is AMBIGUOUS — two units in this run are named `{}` with different identities, \
198 so it accepts NEITHER. Gate the modules separately (one policy file per separately-gated module) so \
199 each name is one unit.",
200 e.line, e.text.trim(), e.func
201 ));
202 continue;
203 }
204 if !direct[k].is_empty() {
205 out.direct_rows.push((e.line, direct[k].iter().map(|&i| rows[i].clone()).collect()));
206 let &first = direct[k].iter().min_by_key(|&&i| (rows[i].hash.clone(), rows[i].effects.clone())).unwrap();
209 let r = &rows[first];
210 let mut eff = r.effects.clone();
211 eff.sort();
212 items.push(item(e, &r.rule, &r.func, &r.hash, eff, "accepted", ""));
213 for &(vi, _) in &nested[k] {
214 let v = &rows[vi];
215 let mut eff = v.effects.clone();
216 eff.sort();
217 items.push(item(e, &v.rule, &v.func, &v.hash, eff, "accepted", &e.func));
218 }
219 continue;
220 }
221 let over = remaining_idx.iter().map(|&i| &rows[i]).find(|r| policy_row(r) && r.rule_key == e.rule_key && r.func == e.func);
222 if let Some(r) = over {
223 items.push(item(e, code, &e.func, "", names, "exceeded", ""));
224 out.notes.push(format!(
225 "policy line {} (`{}`) no longer covers `{}`: it now performs {{ {} }} under `{}`, more than the {{ {} }} \
226 accepted. The violation stays red. Review the new behaviour; if it is intended, widen the entry to list \
227 it, otherwise fix the code.",
228 e.line, e.text.trim(), e.func, r.effects.join(", "), e.rule_key, e.effects.iter().copied().collect::<Vec<_>>().join(", ")
229 ));
230 continue;
231 }
232 out.stale_lines.push(e.line);
234 out.stale.push(GateViolation {
235 rule: "AS-EFF-012".into(),
236 func: e.func.clone(),
237 effects: names.clone(),
238 detail: format!(
239 "`{}` has a STALE acceptance — policy line {} (`{}`) matched no violation of `{}`. Delete line {}: left \
240 in place it would accept whatever a later edit puts under that key",
241 e.func, e.line, e.text.trim(), e.rule_key, e.line
242 ),
243 ..Default::default()
244 });
245 }
246 items.sort_by(|a, b| (a.line, &a.func, &a.hash).cmp(&(b.line, &b.func, &b.hash)));
247 out.accepted = items;
248 out.remaining = rows.into_iter().enumerate().filter(|(i, _)| !acc[*i]).map(|(_, r)| r).collect();
249 out
250}
251
252pub fn announce(accepted_rows: usize, new: usize) -> Option<String> {
256 (accepted_rows > 0).then(|| format!("{accepted_rows} violation(s) accepted by policy; {new} new"))
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::policy::parse_policy_silent;
263
264 fn row(fnn: &str, key: &str, eff: &[&str], hash: &str, rc: &[&str]) -> GateViolation {
265 GateViolation {
266 rule: if eff.is_empty() { FORBID } else { DENY }.into(),
267 func: fnn.into(),
268 hash: hash.into(),
269 effects: eff.iter().map(|s| s.to_string()).collect(),
270 reason_class: rc.iter().map(|s| s.to_string()).collect(),
271 rule_key: key.into(),
272 ..Default::default()
273 }
274 }
275 fn entries(pol: &str) -> Vec<AcceptEntry> {
276 parse_policy_silent(pol, &Default::default()).accepts
277 }
278 fn run<'a>(units: &'a [(String, String)], uneval: &'a BTreeSet<String>) -> RunFacts<'a> {
279 RunFacts { units, unevaluated: uneval }
280 }
281 fn u(n: &str, id: &str) -> (String, String) {
282 (n.to_string(), id.to_string())
283 }
284
285 #[test]
286 fn grammar_pins_every_malformed_form_to_ignored() {
287 let bad = [
288 "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",
297 "deny Unknown dao\naccept dao.a Unknown[dynamic] for deny Unknown dao because x",
298 "deny Fs dao\naccept dao.f::{closure#0} Fs for deny Fs dao because x",
302 "deny Fs dao\nallow Net in dao ok.example\naccept dao.a Net for allow Net in dao ok.example because x",
303 "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", ];
307 for pol in bad {
308 let p = parse_policy_silent(pol, &Default::default());
309 assert!(p.accepts.is_empty(), "must NOT become an entry: {pol:?}");
310 assert!(p.errors.iter().any(|e| e.token == "accept" && !e.fatal), "must be listed as ignored: {pol:?}");
311 }
312 for pol in [
314 "deny Fs dao\naccept dao.a Fss for deny Fs dao because x",
315 "deny Unknown dao\naccept dao.a Unknown[dispatchh] for deny Unknown dao because x",
316 ] {
317 let p = parse_policy_silent(pol, &Default::default());
318 assert!(p.accepts.is_empty());
319 assert!(!p.fatal_messages().is_empty(), "must be fatal: {pol:?}");
320 }
321 }
322
323 #[test]
324 fn grammar_reads_the_good_forms() {
325 let p = parse_policy_silent(
326 "accept dao.a Fs for deny Fs\tdao extra because known since 2026 # why\n\
327 deny Fs dao extra\n\
328 pure parse\n\
329 forbid dao -> infra\n\
330 accept parse.x Clock Rand for pure parse because x\n\
331 accept dao.b for forbid dao -> infra because y\n\
332 deny Unknown[dispatch,native] dao\n\
333 accept dao.c Unknown[dispatch] for deny Unknown[dispatch,native] dao because z\n",
334 &Default::default(),
335 );
336 assert!(p.errors.is_empty(), "{:?}", p.errors.iter().map(|e| &e.message).collect::<Vec<_>>());
337 assert_eq!(p.accepts.len(), 4);
338 assert_eq!(p.accepts[0].rule_key, "deny Fs dao extra");
340 assert_eq!(p.accepts[0].because, "known since 2026");
341 assert_eq!(p.accepts[0].line, 1);
342 assert!(p.accepts[2].forbid && p.accepts[2].effects.is_empty());
343 assert_eq!(p.accepts[3].classes, Some(["dispatch".to_string()].into_iter().collect()));
344 assert_eq!(p.rules.len(), 3);
346 }
347
348 #[test]
349 fn only_accept_lines_is_still_zero_rules() {
350 let p = parse_policy_silent("accept dao.a Fs for deny Fs dao because x\n", &Default::default());
351 assert!(p.rules.is_empty() && p.allow_rules.is_empty() && p.layer_rules.is_empty() && p.only_rules.is_empty());
352 }
353
354 #[test]
355 fn exact_fn_per_line_effect_subset() {
356 let es = entries("deny Fs dao\ndeny Fs\naccept dao.a Fs for deny Fs dao because x\n");
357 let units = [u("dao.a", "p#dao.a"), u("dao.ab", "p#dao.ab")];
358 let none = BTreeSet::new();
359 let rows = vec![
360 row("dao.a", "deny Fs dao", &["Fs"], "p#dao.a", &[]),
361 row("dao.a", "deny Fs", &["Fs"], "p#dao.a", &[]), row("dao.ab", "deny Fs dao", &["Fs"], "p#dao.ab", &[]), ];
364 let s = settle(&es, rows, &run(&units, &none));
365 assert_eq!(s.accepted_rows, 1);
366 assert_eq!(s.remaining.len(), 2);
367 assert!(s.remaining.iter().all(|r| !(r.func == "dao.a" && r.rule_key == "deny Fs dao")));
368 assert!(s.stale.is_empty());
369 assert_eq!(s.accepted.len(), 1);
370 assert_eq!(s.accepted[0].status, "accepted");
371 let es = entries("deny Fs Rand dao\naccept dao.a Fs for deny Fs Rand dao because x\n");
373 let rows = vec![row("dao.a", "deny Fs Rand dao", &["Fs", "Rand"], "p#dao.a", &[])];
374 let s = settle(&es, rows, &run(&units, &none));
375 assert_eq!(s.accepted_rows, 0);
376 assert_eq!(s.remaining.len(), 1);
377 assert!(s.stale.is_empty());
378 assert_eq!(s.accepted[0].status, "exceeded");
379 assert!(s.notes[0].contains("widen the entry"));
380 }
381
382 #[test]
383 fn twins_unjudged_stale_and_unknown_classes() {
384 let none = BTreeSet::new();
385 let es = entries("deny Fs dao\naccept dao.t Fs for deny Fs dao because x\n");
387 let units = [u("dao.t", "p#dao.t"), u("dao.t", "q#dao.t")];
388 let rows = vec![row("dao.t", "deny Fs dao", &["Fs"], "p#dao.t", &[]), row("dao.t", "deny Fs dao", &["Fs"], "q#dao.t", &[])];
389 let s = settle(&es, rows, &run(&units, &none));
390 assert_eq!((s.accepted_rows, s.remaining.len(), s.stale.len()), (0, 2, 0));
391 assert_eq!(s.accepted[0].status, "ambiguous");
392 let units = [u("dao.a", "p#dao.a")];
394 let uneval: BTreeSet<String> = ["deny Fs dao".to_string()].into_iter().collect();
395 let s = settle(&es_for("dao.a"), vec![], &run(&units, &uneval));
396 assert_eq!(s.accepted[0].status, "unjudged");
397 assert!(s.stale.is_empty());
398 let s = settle(&es_for("dao.a"), vec![], &run(&units, &none));
400 assert_eq!(s.stale.len(), 1);
401 assert_eq!(s.stale[0].rule, "AS-EFF-012");
402 assert!(s.stale[0].detail.contains("Delete line 2"));
403 let es = entries("deny Unknown dao\naccept dao.c Unknown[dispatch] for deny Unknown dao because x\n");
405 let units = [u("dao.c", "p#dao.c")];
406 let ok = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &["dispatch"])], &run(&units, &none));
407 assert_eq!(ok.accepted_rows, 1);
408 let moved = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &["dispatch", "native"])], &run(&units, &none));
409 assert_eq!((moved.accepted_rows, moved.remaining.len()), (0, 1));
410 assert_eq!(moved.accepted[0].status, "exceeded");
411 let blind = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &[])], &run(&units, &none));
412 assert_eq!(blind.accepted_rows, 0, "an Unknown whose classes are not determinable is covered by NO filter");
413 }
414
415 fn es_for(f: &str) -> Vec<AcceptEntry> {
416 entries(&format!("deny Fs dao\naccept {f} Fs for deny Fs dao because x\n"))
417 }
418
419 #[test]
420 fn nested_units_need_the_enclosing_row_and_a_subset() {
421 let none = BTreeSet::new();
422 let es = es_for("dao::a");
423 let units = [u("dao::a", "p#dao::a"), u("dao::a::{closure#0}", "p#dao::a::{closure#0}")];
424 let a = row("dao::a", "deny Fs dao", &["Fs"], "p#dao::a", &[]);
425 let v = row("dao::a::{closure#0}", "deny Fs dao", &["Fs"], "p#dao::a::{closure#0}", &[]);
426 let s = settle(&es, vec![a.clone(), v.clone()], &run(&units, &none));
427 assert_eq!((s.accepted_rows, s.remaining.len()), (2, 0));
428 assert_eq!(s.accepted.len(), 2);
429 assert_eq!(s.accepted[1].enclosing, "dao::a");
430 let v2 = row("dao::a::{closure#0}", "deny Fs dao", &["Fs", "Clock"], "p#dao::a::{closure#0}", &[]);
432 let s = settle(&es, vec![a.clone(), v2], &run(&units, &none));
433 assert_eq!((s.accepted_rows, s.remaining.len()), (1, 1));
434 let s = settle(&es, vec![v], &run(&units, &none));
436 assert_eq!((s.accepted_rows, s.remaining.len()), (0, 1));
437 assert_eq!(s.stale.len(), 1, "nothing accepted and nothing exceeded");
438 }
439
440 #[test]
441 fn positional_spellings() {
442 for f in ["a::{closure#0}", "a::{closure#12}::{closure#3}", "x::{async_block#1}", "<T as U>::m::{constant#0}"] {
443 assert!(crate::policy::is_positional_key(f), "{f}");
444 }
445 for f in ["a::closure", "a::{closure}", "a::{closure#}", "a::{#0}", "dao::a", "a::b#1", "{closure#0"] {
446 assert!(!crate::policy::is_positional_key(f), "{f}");
447 }
448 }
449}