1use crate::cap_from_name;
12use std::collections::BTreeSet;
13
14pub const UNKNOWN: &str = "Unknown";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub enum ReasonClass {
26 Reflect,
28 Dispatch,
30 Indirect,
32 Native,
34 Unresolved,
36 Setup,
38}
39
40impl ReasonClass {
41 pub fn token(self) -> &'static str {
43 match self {
44 ReasonClass::Reflect => "reflect",
45 ReasonClass::Dispatch => "dispatch",
46 ReasonClass::Indirect => "indirect",
47 ReasonClass::Native => "native",
48 ReasonClass::Unresolved => "unresolved",
49 ReasonClass::Setup => "setup",
50 }
51 }
52
53 pub fn from_token(t: &str) -> Option<ReasonClass> {
55 Some(match t {
56 "reflect" => ReasonClass::Reflect,
57 "dispatch" => ReasonClass::Dispatch,
58 "indirect" => ReasonClass::Indirect,
59 "native" => ReasonClass::Native,
60 "unresolved" => ReasonClass::Unresolved,
61 "setup" => ReasonClass::Setup,
62 _ => return None,
63 })
64 }
65
66 pub fn classify(why: &str) -> ReasonClass {
70 let w = why.trim().to_ascii_lowercase();
71 if w.starts_with("reflect") || w == "dynamicmemberlookup" {
72 ReasonClass::Reflect
73 } else if w.starts_with("native") {
74 ReasonClass::Native
75 } else if w.starts_with("callback") || w.starts_with("closure") || w.starts_with("task-handoff") {
76 ReasonClass::Indirect
77 } else if w.starts_with("dispatch") || w.starts_with("indy") || w.starts_with("ambiguous") {
78 ReasonClass::Dispatch
79 } else if w.starts_with("missing-config") || w.starts_with("no-tsconfig") || w.starts_with("no-node_modules") {
80 ReasonClass::Setup
81 } else {
82 ReasonClass::Unresolved
83 }
84 }
85
86 pub fn dynamic_set() -> BTreeSet<ReasonClass> {
89 [
90 ReasonClass::Reflect,
91 ReasonClass::Dispatch,
92 ReasonClass::Indirect,
93 ReasonClass::Native,
94 ReasonClass::Unresolved,
95 ]
96 .into_iter()
97 .collect()
98 }
99}
100
101pub fn parse_unknown_aliases(config_text: &str) -> std::collections::BTreeMap<String, BTreeSet<ReasonClass>> {
106 let mut out = std::collections::BTreeMap::new();
107 for raw in config_text.lines() {
108 let line = raw.split('#').next().unwrap_or("").trim();
109 if line.is_empty() {
110 continue;
111 }
112 let mut it = line.splitn(2, char::is_whitespace);
113 if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("unknown-alias")) {
117 continue;
118 }
119 let val = it.next().unwrap_or("").trim();
120 let Some((name, classes)) = val.split_once('=') else {
121 eprintln!("candor: ignoring `unknown-alias` (want `unknown-alias <name> = <class,…>`): {val}");
122 continue;
123 };
124 let name = name.trim();
125 if name.is_empty() || name == "*" || name == "dynamic" || ReasonClass::from_token(name).is_some() {
126 eprintln!("candor: ignoring `unknown-alias` with reserved/empty name `{name}` (may not shadow `*`/`dynamic`/a class token)");
127 continue;
128 }
129 let mut set = BTreeSet::new();
130 for cn in classes.split(',') {
131 let cn = cn.trim();
132 if cn.is_empty() {
133 continue;
134 }
135 if cn == "dynamic" {
136 set.extend(ReasonClass::dynamic_set());
137 } else if let Some(rc) = ReasonClass::from_token(cn) {
138 set.insert(rc);
139 } else {
140 eprintln!("candor: `unknown-alias {name}` names unknown reason-class `{cn}` — skipped");
141 }
142 }
143 if set.is_empty() {
144 eprintln!("candor: ignoring `unknown-alias {name}` — no valid reason-class");
145 } else {
146 out.insert(name.to_string(), set);
147 }
148 }
149 out
150}
151
152pub fn parse_net_partners(config_text: &str) -> BTreeSet<String> {
158 let mut out = BTreeSet::new();
159 for raw in config_text.lines() {
160 let line = raw.split('#').next().unwrap_or("").trim();
161 if line.is_empty() {
162 continue;
163 }
164 let mut it = line.splitn(2, char::is_whitespace);
165 if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("net-partner")) {
166 continue;
167 }
168 let val = it.next().unwrap_or("").trim();
169 if !val.is_empty() {
170 out.insert(host_part(val).to_ascii_lowercase());
171 }
172 }
173 out
174}
175
176pub fn discover_config_text(start: &std::path::Path) -> Option<String> {
181 if let Ok(p) = std::env::var("CANDOR_CONFIG") {
182 return std::fs::read_to_string(&p).ok();
183 }
184 let start = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
185 let mut cur = if start.is_dir() { Some(start.as_path()) } else { start.parent() };
186 while let Some(d) = cur {
187 let cand = d.join(".candor/config");
188 if cand.is_file() {
189 return std::fs::read_to_string(&cand).ok();
190 }
191 cur = d.parent();
192 }
193 None
194}
195
196#[derive(Debug, Clone)]
199pub struct PolicyRule {
200 pub effects: BTreeSet<&'static str>,
201 pub scope: Option<String>,
202 pub unknown_classes: BTreeSet<ReasonClass>,
206 pub net_classes: BTreeSet<String>,
210 pub raw: String,
211}
212
213#[derive(Debug, Clone)]
218pub struct AllowRule {
219 pub effect: &'static str,
220 pub scope: Option<String>,
221 pub literals: BTreeSet<String>,
222 pub raw: String,
223}
224
225#[derive(Debug, Clone)]
228pub struct LayerRule {
229 pub from: String,
230 pub to: String,
231 pub raw: String,
232}
233
234#[derive(Default, Debug)]
236pub struct ParsedPolicy {
237 pub rules: Vec<PolicyRule>,
238 pub allow_rules: Vec<AllowRule>,
239 pub layer_rules: Vec<LayerRule>,
240}
241
242pub fn host_part(h: &str) -> &str {
248 if let Some(rest) = h.strip_prefix('[') {
249 return rest.split(']').next().unwrap_or(rest);
251 }
252 if h.matches(':').count() > 1 {
253 return h; }
255 h.split(':').next().unwrap_or(h)
256}
257
258pub fn cmd_base(c: &str) -> &str {
260 c.rsplit(['/', '\\']).next().unwrap_or(c)
261}
262
263pub fn fs_path_covered(a: &str, r: &str) -> bool {
268 if r.split(['/', '\\']).any(|c| c == "..") {
269 return false;
270 }
271 let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
272 if absolute(a) != absolute(r) {
273 return false;
274 }
275 let norm = |s: &str| -> Vec<String> {
276 s.split(['/', '\\'])
277 .filter(|c| !c.is_empty() && *c != ".")
278 .map(|c| c.to_string())
279 .collect()
280 };
281 let (ac, rc) = (norm(a), norm(r));
282 ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
283}
284
285pub fn db_table_covered(a: &str, r: &str) -> bool {
291 let (a, r) = (a.to_lowercase(), r.to_lowercase());
292 if let Some(schema) = a.strip_suffix(".*") {
293 return r.strip_prefix(schema).is_some_and(|rest| rest.starts_with('.'));
294 }
295 a == r
296}
297
298pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
302 match effect {
303 "Net" | "Llm" => allow.iter().any(|a| host_part(a) == host_part(reached)),
305 "Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
306 "Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
307 "Db" => allow.iter().any(|a| db_table_covered(a, reached)),
308 _ => allow.contains(reached),
309 }
310}
311
312fn name_segments(s: &str) -> Vec<&str> {
320 s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
321}
322
323pub fn scope_matches(name: &str, scope: &str) -> bool {
328 let segs = name_segments(name);
329 let parts = name_segments(scope);
330 if parts.is_empty() || parts.len() > segs.len() {
331 return false;
332 }
333 let (last, init) = parts.split_last().unwrap();
334 segs.windows(parts.len()).any(|w| {
335 let (w_last, w_init) = w.split_last().unwrap();
336 w_init == init && w_last.starts_with(last)
337 })
338}
339
340pub fn rule_and_upgrade(r: &PolicyRule) -> (String, String) {
345 let scope = r.scope.clone().unwrap_or_default();
346 let suffix = if scope.is_empty() { String::new() } else { format!(" {scope}") };
347 if r.effects.is_empty() {
348 (format!("pure{suffix}"), format!("deny Unknown{suffix}"))
350 } else {
351 let effs = r.effects.iter().copied().collect::<Vec<_>>().join(" ");
352 (format!("deny {effs}{suffix}"), format!("deny {effs} Unknown{suffix}"))
353 }
354}
355
356pub fn unverified_hole_rule<'a, S: AsRef<str>>(
364 name: &str,
365 effects: &[S],
366 rules: &'a [PolicyRule],
367) -> Option<&'a PolicyRule> {
368 if !effects.iter().any(|e| e.as_ref() == UNKNOWN) {
369 return None;
370 }
371 rules.iter().find(|r| {
372 if let Some(s) = &r.scope {
374 if !scope_matches(name, s) {
375 return false;
376 }
377 }
378 let violates = if r.effects.is_empty() {
380 effects.iter().any(|e| e.as_ref() != UNKNOWN)
381 } else {
382 effects.iter().any(|e| r.effects.contains(e.as_ref()))
383 };
384 !violates
385 })
386}
387
388fn is_ascii_ws(c: char) -> bool {
407 matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
408}
409
410pub fn parse_policy(text: &str) -> ParsedPolicy {
411 parse_policy_impl(text, true, &std::collections::BTreeMap::new())
412}
413pub fn parse_policy_with_aliases(text: &str, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
418 parse_policy_impl(text, true, aliases)
419}
420pub fn parse_policy_quiet(text: &str) -> ParsedPolicy {
424 parse_policy_impl(text, false, &std::collections::BTreeMap::new())
425}
426fn parse_policy_impl(text: &str, warn: bool, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
427 macro_rules! warn_ignore { ($($a:tt)*) => { if warn { eprintln!($($a)*); } } }
428 let mut out = ParsedPolicy::default();
429 let normalized;
435 let text = if text.contains('\r') {
436 normalized = text.replace("\r\n", "\n").replace('\r', "\n");
437 normalized.as_str()
438 } else {
439 text
440 };
441 for raw_line in text.lines() {
442 let line = raw_line.split('#').next().unwrap_or("").trim_matches(is_ascii_ws);
443 if line.is_empty() {
444 continue;
445 }
446 let mut toks = line.split(is_ascii_ws).filter(|s| !s.is_empty());
447 match toks.next().unwrap_or("") {
448 "allow" => {
449 let effect = match toks.next().unwrap_or("") {
450 "Net" => "Net",
451 "Llm" => "Llm",
455 "Exec" => "Exec",
456 "Fs" => "Fs",
457 "Db" => "Db",
458 _ => {
459 warn_ignore!(
460"candor: ignoring policy rule (allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables): {line}"
461 );
462 continue;
463 }
464 };
465 let mut rest: Vec<&str> = toks.collect();
466 let scope = if rest.first() == Some(&"in") {
467 let s = rest.get(1).map(|s| s.to_string());
468 rest.drain(..2.min(rest.len()));
469 s
470 } else {
471 None
472 };
473 let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
474 if literals.is_empty() {
475 warn_ignore!("candor: ignoring policy rule (allow {effect} names no values): {line}");
476 continue;
477 }
478 out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
479 }
480 "deny" => {
481 let mut effects = BTreeSet::new();
482 let mut scope = None;
483 let mut unknown_classes: BTreeSet<ReasonClass> = BTreeSet::new();
486 let mut unknown_star = false;
487 let mut net_classes: BTreeSet<String> = BTreeSet::new();
490 let mut net_star = false;
491 for t in toks {
492 if let Some(inner) = t.strip_prefix("Net[").and_then(|s| s.strip_suffix(']')) {
494 effects.insert("Net");
495 for cn in inner.split(',') {
496 let cn = cn.trim();
497 if cn.is_empty() {
498 continue;
499 }
500 if cn == "*" {
501 net_star = true;
502 } else if crate::NET_DEST_CLASSES.contains(&cn) {
503 net_classes.insert(cn.to_string());
504 } else {
505 warn_ignore!("candor: policy rule names unknown Net destination-class `{cn}` (known: known-telemetry,known-partner,unknown-host, or *): {line}");
506 }
507 }
508 continue;
509 }
510 if let Some(inner) = t.strip_prefix("Unknown[").and_then(|s| s.strip_suffix(']')) {
512 effects.insert(UNKNOWN);
513 for cn in inner.split(',') {
514 let cn = cn.trim();
515 if cn.is_empty() {
516 continue;
517 }
518 if cn == "*" {
519 unknown_star = true;
520 } else if cn == "dynamic" {
521 unknown_classes.extend(ReasonClass::dynamic_set());
522 } else if let Some(rc) = ReasonClass::from_token(cn) {
523 unknown_classes.insert(rc);
524 } else if let Some(a) = aliases.get(cn) {
525 unknown_classes.extend(a.iter().copied()); } else {
527 warn_ignore!("candor: policy rule names unknown reason-class/alias `{cn}` (known: reflect,dispatch,indirect,native,unresolved,setup; aliases: dynamic,*, or a config `unknown-alias`): {line}");
528 }
529 }
530 continue;
531 }
532 let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
533 match e {
534 Some(e) => {
535 effects.insert(e);
536 if e == UNKNOWN {
537 unknown_star = true; }
539 if e == "Net" {
540 net_star = true; }
542 }
543 None => {
544 scope = Some(t.to_string());
545 break;
546 }
547 }
548 }
549 if effects.is_empty() {
550 warn_ignore!("candor: ignoring policy rule (no known effect named): {line}");
551 continue;
552 }
553 if unknown_star {
555 unknown_classes.clear();
556 } else if !unknown_classes.is_empty() && !unknown_classes.contains(&ReasonClass::Unresolved) {
557 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}");
560 }
561 if net_star {
563 net_classes.clear();
564 }
565 out.rules.push(PolicyRule { effects, scope, unknown_classes, net_classes, raw: line.to_string() });
566 }
567 "pure" => out.rules.push(PolicyRule {
568 effects: BTreeSet::new(),
569 scope: toks.next().map(str::to_string),
570 unknown_classes: BTreeSet::new(),
571 net_classes: BTreeSet::new(),
572 raw: line.to_string(),
573 }),
574 "forbid" => {
575 let a = toks.next().unwrap_or("");
576 let arrow = toks.next().unwrap_or("");
577 let b = toks.next().unwrap_or("");
578 if a.is_empty() || arrow != "->" || b.is_empty() {
579 warn_ignore!("candor: ignoring layering rule (want `forbid <scope> -> <scope>`): {line}");
580 continue;
581 }
582 out.layer_rules.push(LayerRule {
583 from: a.to_string(),
584 to: b.to_string(),
585 raw: line.to_string(),
586 });
587 }
588 other => warn_ignore!("candor: ignoring policy rule (unknown kind `{other}`): {line}"),
589 }
590 }
591 out
592}
593
594#[cfg(test)]
595mod tests {
596 #[test]
597 fn db_table_covering_is_strict() {
598 use super::db_table_covered as c;
599 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"));
604 }
605
606 #[test]
607 fn allow_db_parses_and_gates() {
608 let p = super::parse_policy("allow Db in billing ledger.* customers\n");
609 assert_eq!(p.allow_rules.len(), 1);
610 assert_eq!(p.allow_rules[0].effect, "Db");
611 assert!(super::literal_allowed("Db", "ledger.entries", &p.allow_rules[0].literals));
612 assert!(super::literal_allowed("Db", "customers", &p.allow_rules[0].literals));
613 assert!(!super::literal_allowed("Db", "audit.log", &p.allow_rules[0].literals));
614 }
615
616 use super::*;
617
618 #[test]
619 fn policy_parses() {
620 let p = parse_policy(
621 "# the domain layer must stay pure of I/O\n\
622 deny Net Db domain\n\
623 deny Exec\n\
624 pure parse\n\
625 nonsense line\n\
626 deny notaneffect\n",
627 );
628 let rules = &p.rules;
629 assert_eq!(rules.len(), 3);
630 assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
631 assert_eq!(rules[0].scope.as_deref(), Some("domain"));
632 assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
633 assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
634 let cr = parse_policy("deny Net a\rdeny Exec b\rdeny Db c\r");
636 assert_eq!(cr.rules.len(), 3, "bare-CR lines must each parse");
637 assert!(cr.rules.iter().any(|r| r.effects.contains("Exec") && r.scope.as_deref() == Some("b")));
638 assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
640 assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
642 assert!(parse_policy("deny\ndeny \n").rules.is_empty());
643 assert!(parse_policy("deny notaneffect scope").rules.is_empty());
645 let p2 = parse_policy("deny Net foo Db");
647 assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
648 assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
649 assert!(parse_policy("deny\u{a0}Net core").rules.is_empty(),
654 "an NBSP between deny and the effect must NOT split into separate tokens");
655 let nb = parse_policy("deny Net \u{a0}domain");
658 assert_eq!(nb.rules.len(), 1);
659 assert_eq!(nb.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
660 assert_eq!(nb.rules[0].scope.as_deref(), Some("\u{a0}domain"));
661 }
662
663 #[test]
664 fn reason_scoped_unknown_parses() {
665 use super::ReasonClass::*;
666 let p = parse_policy("deny Net Unknown[dispatch,indirect] dom\n");
668 let r = &p.rules[0];
669 assert!(r.effects.contains("Unknown") && r.effects.contains("Net"));
670 assert_eq!(r.scope.as_deref(), Some("dom"));
671 assert_eq!(r.unknown_classes, [Dispatch, Indirect].into_iter().collect());
672 assert!(parse_policy("deny Net Unknown dom\n").rules[0].unknown_classes.is_empty(), "bare Unknown ⇒ all");
674 assert!(parse_policy("deny Net Unknown[*] dom\n").rules[0].unknown_classes.is_empty(), "Unknown[*] ⇒ all");
675 assert_eq!(
677 parse_policy("deny Net Unknown[dynamic] dom\n").rules[0].unknown_classes,
678 [Reflect, Dispatch, Indirect, Native, Unresolved].into_iter().collect()
679 );
680 let aliases = super::parse_unknown_aliases(
682 "unknown-alias risky = reflect,native\nunknown-alias telemetry = indirect\nunknown-alias reflect = native\n");
683 assert_eq!(aliases.get("risky"), Some(&[Reflect, Native].into_iter().collect()));
684 assert_eq!(aliases.get("telemetry"), Some(&[Indirect].into_iter().collect()));
685 assert!(!aliases.contains_key("reflect"), "a config alias may not shadow a class token");
686 assert_eq!(super::parse_unknown_aliases("Unknown-Alias hot = native\n").get("hot"),
688 Some(&[Native].into_iter().collect()), "the unknown-alias key must match case-insensitively");
689 let pr = super::parse_policy_with_aliases("deny Net Unknown[risky] api\n", &aliases);
690 assert_eq!(pr.rules[0].unknown_classes, [Reflect, Native].into_iter().collect());
691 assert!(super::parse_policy_with_aliases("deny Net Unknown[nope] api\n", &aliases).rules[0].unknown_classes.is_empty());
693 assert_eq!(ReasonClass::classify("reflect:Class.forName"), Reflect);
695 assert_eq!(ReasonClass::classify("native:extern fn"), Native);
696 assert_eq!(ReasonClass::classify("callback:unresolved call"), Indirect);
697 assert_eq!(ReasonClass::classify("ambiguous:same-name local defs"), Dispatch);
698 assert_eq!(ReasonClass::classify("unresolved"), Unresolved);
699 assert_eq!(ReasonClass::classify("whatever-new"), Unresolved); }
701
702 #[test]
703 fn net_destination_class_parses_and_classifies() {
704 let p = parse_policy("deny Net[unknown-host,known-telemetry] dom\n");
706 let r = &p.rules[0];
707 assert!(r.effects.contains("Net"));
708 assert_eq!(r.scope.as_deref(), Some("dom"));
709 assert_eq!(
710 r.net_classes,
711 ["unknown-host", "known-telemetry"].iter().map(|s| s.to_string()).collect()
712 );
713 assert!(parse_policy("deny Net dom\n").rules[0].net_classes.is_empty(), "bare Net ⇒ all");
715 assert!(parse_policy("deny Net[*] dom\n").rules[0].net_classes.is_empty(), "Net[*] ⇒ all");
716 assert!(parse_policy("deny Net[nope] dom\n").rules[0].net_classes.is_empty());
718 let no_partners = BTreeSet::new();
720 assert_eq!(crate::net_dest_class("sentry.io", &no_partners), "known-telemetry");
721 assert_eq!(crate::net_dest_class("o1.ingest.sentry.io", &no_partners), "known-telemetry");
722 assert_eq!(crate::net_dest_class("api.openai.com", &no_partners), "known-partner", "a model host is known-partner");
723 assert_eq!(crate::net_dest_class("evil.example.com", &no_partners), "unknown-host");
724 let partners: BTreeSet<String> = ["api.stripe.com".to_string()].into_iter().collect();
725 assert_eq!(crate::net_dest_class("api.stripe.com", &partners), "known-partner", "config-declared partner");
726 assert_eq!(crate::net_dest_class("api.stripe.com", &no_partners), "unknown-host", "partner is config-only");
727 let pset = super::parse_net_partners("net-partner Api.Stripe.com:443\nNET-PARTNER hooks.stripe.com\n");
729 assert!(pset.contains("api.stripe.com") && pset.contains("hooks.stripe.com"));
730 }
731
732 #[test]
733 fn allowlist_parses() {
734 let p = parse_policy(
735 "allow Net in billing api.stripe.com hooks.stripe.com\n\
736 allow Exec in ci git\n\
737 allow Fs in config /etc/app\n\
738 allow Net github.com\n\
739 allow Clock whatever\n\
740 allow Net in nohosts\n\
741 allow\n",
742 );
743 assert_eq!(p.allow_rules.len(), 4); assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
745 assert_eq!(
746 p.allow_rules[0].literals,
747 ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
748 );
749 assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
750 assert!(p.allow_rules[1].literals.contains("git"));
751 assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
752 assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
753
754 let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
755 assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
756 assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
759 assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
760 assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
761 assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
762 assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
763 assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
764 assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
765 assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
766 assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
767 assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
768 assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
769 assert_eq!(cmd_base("/usr/bin/git"), "git");
770 }
771
772 #[test]
773 fn layering_rule_parses() {
774 let p = parse_policy(
775 "forbid domain -> infra\n\
776 forbid app::web -> app::db \n\
777 forbid domain infra\n\
778 forbid domain ->\n\
779 forbid\n",
780 );
781 assert_eq!(p.layer_rules.len(), 2);
782 assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
783 assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
784 }
785
786 #[test]
787 fn scope_matches_by_segment_not_substring() {
788 assert!(scope_matches("app::domain::handle", "domain"));
789 assert!(scope_matches("domain::handle", "domain"));
790 assert!(scope_matches("app::domain", "domain"));
791 assert!(scope_matches("crate::domain_logic", "domain"));
792 assert!(!scope_matches("app::subdomain::handle", "domain"));
793 assert!(!scope_matches("app::not_my_domain::f", "domain"));
794 assert!(scope_matches("crate::net::client::send", "net::client"));
796 assert!(scope_matches("crate::net::client_pool::get", "net::client"));
797 assert!(!scope_matches("crate::net::server::send", "net::client"));
798 assert!(!scope_matches("crate::network::client::send", "net::client"));
799 assert!(!scope_matches("crate::net::x::client", "net::client"));
800 assert!(!scope_matches("net", "net::client"));
801 assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
805 assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
806 assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
807 assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
808 assert!(!scope_matches("com.acme.subdomain.h", "domain"));
809 assert!(!scope_matches("com.acme.domain.h", "infra"));
810 }
811
812 #[test]
813 fn fs_path_covered_respects_boundaries() {
814 assert!(fs_path_covered("/etc/app", "/etc/app"));
815 assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
816 assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
817 assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
818 assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
819 assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
820 assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
821 assert!(fs_path_covered("/", "/etc/app/x"));
822 assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
823 assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
824 assert!(fs_path_covered("etc/app", "etc/app/cfg"));
825 }
826}