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)]
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 discover_config_text(start: &std::path::Path) -> Option<String> {
157 if let Ok(p) = std::env::var("CANDOR_CONFIG") {
158 return std::fs::read_to_string(&p).ok();
159 }
160 let start = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
161 let mut cur = if start.is_dir() { Some(start.as_path()) } else { start.parent() };
162 while let Some(d) = cur {
163 let cand = d.join(".candor/config");
164 if cand.is_file() {
165 return std::fs::read_to_string(&cand).ok();
166 }
167 cur = d.parent();
168 }
169 None
170}
171
172#[derive(Debug, Clone)]
175pub struct PolicyRule {
176 pub effects: BTreeSet<&'static str>,
177 pub scope: Option<String>,
178 pub unknown_classes: BTreeSet<ReasonClass>,
182 pub raw: String,
183}
184
185#[derive(Debug, Clone)]
190pub struct AllowRule {
191 pub effect: &'static str,
192 pub scope: Option<String>,
193 pub literals: BTreeSet<String>,
194 pub raw: String,
195}
196
197#[derive(Debug, Clone)]
200pub struct LayerRule {
201 pub from: String,
202 pub to: String,
203 pub raw: String,
204}
205
206#[derive(Default, Debug)]
208pub struct ParsedPolicy {
209 pub rules: Vec<PolicyRule>,
210 pub allow_rules: Vec<AllowRule>,
211 pub layer_rules: Vec<LayerRule>,
212}
213
214pub fn host_part(h: &str) -> &str {
220 if let Some(rest) = h.strip_prefix('[') {
221 return rest.split(']').next().unwrap_or(rest);
223 }
224 if h.matches(':').count() > 1 {
225 return h; }
227 h.split(':').next().unwrap_or(h)
228}
229
230pub fn cmd_base(c: &str) -> &str {
232 c.rsplit(['/', '\\']).next().unwrap_or(c)
233}
234
235pub fn fs_path_covered(a: &str, r: &str) -> bool {
240 if r.split(['/', '\\']).any(|c| c == "..") {
241 return false;
242 }
243 let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
244 if absolute(a) != absolute(r) {
245 return false;
246 }
247 let norm = |s: &str| -> Vec<String> {
248 s.split(['/', '\\'])
249 .filter(|c| !c.is_empty() && *c != ".")
250 .map(|c| c.to_string())
251 .collect()
252 };
253 let (ac, rc) = (norm(a), norm(r));
254 ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
255}
256
257pub fn db_table_covered(a: &str, r: &str) -> bool {
263 let (a, r) = (a.to_lowercase(), r.to_lowercase());
264 if let Some(schema) = a.strip_suffix(".*") {
265 return r.strip_prefix(schema).is_some_and(|rest| rest.starts_with('.'));
266 }
267 a == r
268}
269
270pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
274 match effect {
275 "Net" | "Llm" => allow.iter().any(|a| host_part(a) == host_part(reached)),
277 "Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
278 "Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
279 "Db" => allow.iter().any(|a| db_table_covered(a, reached)),
280 _ => allow.contains(reached),
281 }
282}
283
284fn name_segments(s: &str) -> Vec<&str> {
292 s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
293}
294
295pub fn scope_matches(name: &str, scope: &str) -> bool {
300 let segs = name_segments(name);
301 let parts = name_segments(scope);
302 if parts.is_empty() || parts.len() > segs.len() {
303 return false;
304 }
305 let (last, init) = parts.split_last().unwrap();
306 segs.windows(parts.len()).any(|w| {
307 let (w_last, w_init) = w.split_last().unwrap();
308 w_init == init && w_last.starts_with(last)
309 })
310}
311
312pub fn rule_and_upgrade(r: &PolicyRule) -> (String, String) {
317 let scope = r.scope.clone().unwrap_or_default();
318 let suffix = if scope.is_empty() { String::new() } else { format!(" {scope}") };
319 if r.effects.is_empty() {
320 (format!("pure{suffix}"), format!("deny Unknown{suffix}"))
322 } else {
323 let effs = r.effects.iter().copied().collect::<Vec<_>>().join(" ");
324 (format!("deny {effs}{suffix}"), format!("deny {effs} Unknown{suffix}"))
325 }
326}
327
328pub fn unverified_hole_rule<'a, S: AsRef<str>>(
336 name: &str,
337 effects: &[S],
338 rules: &'a [PolicyRule],
339) -> Option<&'a PolicyRule> {
340 if !effects.iter().any(|e| e.as_ref() == UNKNOWN) {
341 return None;
342 }
343 rules.iter().find(|r| {
344 if let Some(s) = &r.scope {
346 if !scope_matches(name, s) {
347 return false;
348 }
349 }
350 let violates = if r.effects.is_empty() {
352 effects.iter().any(|e| e.as_ref() != UNKNOWN)
353 } else {
354 effects.iter().any(|e| r.effects.contains(e.as_ref()))
355 };
356 !violates
357 })
358}
359
360fn is_ascii_ws(c: char) -> bool {
379 matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
380}
381
382pub fn parse_policy(text: &str) -> ParsedPolicy {
383 parse_policy_impl(text, true, &std::collections::BTreeMap::new())
384}
385pub fn parse_policy_with_aliases(text: &str, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
390 parse_policy_impl(text, true, aliases)
391}
392pub fn parse_policy_quiet(text: &str) -> ParsedPolicy {
396 parse_policy_impl(text, false, &std::collections::BTreeMap::new())
397}
398fn parse_policy_impl(text: &str, warn: bool, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
399 macro_rules! warn_ignore { ($($a:tt)*) => { if warn { eprintln!($($a)*); } } }
400 let mut out = ParsedPolicy::default();
401 let normalized;
407 let text = if text.contains('\r') {
408 normalized = text.replace("\r\n", "\n").replace('\r', "\n");
409 normalized.as_str()
410 } else {
411 text
412 };
413 for raw_line in text.lines() {
414 let line = raw_line.split('#').next().unwrap_or("").trim_matches(is_ascii_ws);
415 if line.is_empty() {
416 continue;
417 }
418 let mut toks = line.split(is_ascii_ws).filter(|s| !s.is_empty());
419 match toks.next().unwrap_or("") {
420 "allow" => {
421 let effect = match toks.next().unwrap_or("") {
422 "Net" => "Net",
423 "Llm" => "Llm",
427 "Exec" => "Exec",
428 "Fs" => "Fs",
429 "Db" => "Db",
430 _ => {
431 warn_ignore!(
432"candor: ignoring policy rule (allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables): {line}"
433 );
434 continue;
435 }
436 };
437 let mut rest: Vec<&str> = toks.collect();
438 let scope = if rest.first() == Some(&"in") {
439 let s = rest.get(1).map(|s| s.to_string());
440 rest.drain(..2.min(rest.len()));
441 s
442 } else {
443 None
444 };
445 let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
446 if literals.is_empty() {
447 warn_ignore!("candor: ignoring policy rule (allow {effect} names no values): {line}");
448 continue;
449 }
450 out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
451 }
452 "deny" => {
453 let mut effects = BTreeSet::new();
454 let mut scope = None;
455 let mut unknown_classes: BTreeSet<ReasonClass> = BTreeSet::new();
458 let mut unknown_star = false;
459 for t in toks {
460 if let Some(inner) = t.strip_prefix("Unknown[").and_then(|s| s.strip_suffix(']')) {
462 effects.insert(UNKNOWN);
463 for cn in inner.split(',') {
464 let cn = cn.trim();
465 if cn.is_empty() {
466 continue;
467 }
468 if cn == "*" {
469 unknown_star = true;
470 } else if cn == "dynamic" {
471 unknown_classes.extend(ReasonClass::dynamic_set());
472 } else if let Some(rc) = ReasonClass::from_token(cn) {
473 unknown_classes.insert(rc);
474 } else if let Some(a) = aliases.get(cn) {
475 unknown_classes.extend(a.iter().copied()); } else {
477 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}");
478 }
479 }
480 continue;
481 }
482 let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
483 match e {
484 Some(e) => {
485 effects.insert(e);
486 if e == UNKNOWN {
487 unknown_star = true; }
489 }
490 None => {
491 scope = Some(t.to_string());
492 break;
493 }
494 }
495 }
496 if effects.is_empty() {
497 warn_ignore!("candor: ignoring policy rule (no known effect named): {line}");
498 continue;
499 }
500 if unknown_star {
502 unknown_classes.clear();
503 } else if !unknown_classes.is_empty() && !unknown_classes.contains(&ReasonClass::Unresolved) {
504 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}");
507 }
508 out.rules.push(PolicyRule { effects, scope, unknown_classes, raw: line.to_string() });
509 }
510 "pure" => out.rules.push(PolicyRule {
511 effects: BTreeSet::new(),
512 scope: toks.next().map(str::to_string),
513 unknown_classes: BTreeSet::new(),
514 raw: line.to_string(),
515 }),
516 "forbid" => {
517 let a = toks.next().unwrap_or("");
518 let arrow = toks.next().unwrap_or("");
519 let b = toks.next().unwrap_or("");
520 if a.is_empty() || arrow != "->" || b.is_empty() {
521 warn_ignore!("candor: ignoring layering rule (want `forbid <scope> -> <scope>`): {line}");
522 continue;
523 }
524 out.layer_rules.push(LayerRule {
525 from: a.to_string(),
526 to: b.to_string(),
527 raw: line.to_string(),
528 });
529 }
530 other => warn_ignore!("candor: ignoring policy rule (unknown kind `{other}`): {line}"),
531 }
532 }
533 out
534}
535
536#[cfg(test)]
537mod tests {
538 #[test]
539 fn db_table_covering_is_strict() {
540 use super::db_table_covered as c;
541 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"));
546 }
547
548 #[test]
549 fn allow_db_parses_and_gates() {
550 let p = super::parse_policy("allow Db in billing ledger.* customers\n");
551 assert_eq!(p.allow_rules.len(), 1);
552 assert_eq!(p.allow_rules[0].effect, "Db");
553 assert!(super::literal_allowed("Db", "ledger.entries", &p.allow_rules[0].literals));
554 assert!(super::literal_allowed("Db", "customers", &p.allow_rules[0].literals));
555 assert!(!super::literal_allowed("Db", "audit.log", &p.allow_rules[0].literals));
556 }
557
558 use super::*;
559
560 #[test]
561 fn policy_parses() {
562 let p = parse_policy(
563 "# the domain layer must stay pure of I/O\n\
564 deny Net Db domain\n\
565 deny Exec\n\
566 pure parse\n\
567 nonsense line\n\
568 deny notaneffect\n",
569 );
570 let rules = &p.rules;
571 assert_eq!(rules.len(), 3);
572 assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
573 assert_eq!(rules[0].scope.as_deref(), Some("domain"));
574 assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
575 assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
576 let cr = parse_policy("deny Net a\rdeny Exec b\rdeny Db c\r");
578 assert_eq!(cr.rules.len(), 3, "bare-CR lines must each parse");
579 assert!(cr.rules.iter().any(|r| r.effects.contains("Exec") && r.scope.as_deref() == Some("b")));
580 assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
582 assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
584 assert!(parse_policy("deny\ndeny \n").rules.is_empty());
585 assert!(parse_policy("deny notaneffect scope").rules.is_empty());
587 let p2 = parse_policy("deny Net foo Db");
589 assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
590 assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
591 assert!(parse_policy("deny\u{a0}Net core").rules.is_empty(),
596 "an NBSP between deny and the effect must NOT split into separate tokens");
597 let nb = parse_policy("deny Net \u{a0}domain");
600 assert_eq!(nb.rules.len(), 1);
601 assert_eq!(nb.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
602 assert_eq!(nb.rules[0].scope.as_deref(), Some("\u{a0}domain"));
603 }
604
605 #[test]
606 fn reason_scoped_unknown_parses() {
607 use super::ReasonClass::*;
608 let p = parse_policy("deny Net Unknown[dispatch,indirect] dom\n");
610 let r = &p.rules[0];
611 assert!(r.effects.contains("Unknown") && r.effects.contains("Net"));
612 assert_eq!(r.scope.as_deref(), Some("dom"));
613 assert_eq!(r.unknown_classes, [Dispatch, Indirect].into_iter().collect());
614 assert!(parse_policy("deny Net Unknown dom\n").rules[0].unknown_classes.is_empty(), "bare Unknown ⇒ all");
616 assert!(parse_policy("deny Net Unknown[*] dom\n").rules[0].unknown_classes.is_empty(), "Unknown[*] ⇒ all");
617 assert_eq!(
619 parse_policy("deny Net Unknown[dynamic] dom\n").rules[0].unknown_classes,
620 [Reflect, Dispatch, Indirect, Native, Unresolved].into_iter().collect()
621 );
622 let aliases = super::parse_unknown_aliases(
624 "unknown-alias risky = reflect,native\nunknown-alias telemetry = indirect\nunknown-alias reflect = native\n");
625 assert_eq!(aliases.get("risky"), Some(&[Reflect, Native].into_iter().collect()));
626 assert_eq!(aliases.get("telemetry"), Some(&[Indirect].into_iter().collect()));
627 assert!(!aliases.contains_key("reflect"), "a config alias may not shadow a class token");
628 assert_eq!(super::parse_unknown_aliases("Unknown-Alias hot = native\n").get("hot"),
630 Some(&[Native].into_iter().collect()), "the unknown-alias key must match case-insensitively");
631 let pr = super::parse_policy_with_aliases("deny Net Unknown[risky] api\n", &aliases);
632 assert_eq!(pr.rules[0].unknown_classes, [Reflect, Native].into_iter().collect());
633 assert!(super::parse_policy_with_aliases("deny Net Unknown[nope] api\n", &aliases).rules[0].unknown_classes.is_empty());
635 assert_eq!(ReasonClass::classify("reflect:Class.forName"), Reflect);
637 assert_eq!(ReasonClass::classify("native:extern fn"), Native);
638 assert_eq!(ReasonClass::classify("callback:unresolved call"), Indirect);
639 assert_eq!(ReasonClass::classify("ambiguous:same-name local defs"), Dispatch);
640 assert_eq!(ReasonClass::classify("unresolved"), Unresolved);
641 assert_eq!(ReasonClass::classify("whatever-new"), Unresolved); }
643
644 #[test]
645 fn allowlist_parses() {
646 let p = parse_policy(
647 "allow Net in billing api.stripe.com hooks.stripe.com\n\
648 allow Exec in ci git\n\
649 allow Fs in config /etc/app\n\
650 allow Net github.com\n\
651 allow Clock whatever\n\
652 allow Net in nohosts\n\
653 allow\n",
654 );
655 assert_eq!(p.allow_rules.len(), 4); assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
657 assert_eq!(
658 p.allow_rules[0].literals,
659 ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
660 );
661 assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
662 assert!(p.allow_rules[1].literals.contains("git"));
663 assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
664 assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
665
666 let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
667 assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
668 assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
671 assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
672 assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
673 assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
674 assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
675 assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
676 assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
677 assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
678 assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
679 assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
680 assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
681 assert_eq!(cmd_base("/usr/bin/git"), "git");
682 }
683
684 #[test]
685 fn layering_rule_parses() {
686 let p = parse_policy(
687 "forbid domain -> infra\n\
688 forbid app::web -> app::db \n\
689 forbid domain infra\n\
690 forbid domain ->\n\
691 forbid\n",
692 );
693 assert_eq!(p.layer_rules.len(), 2);
694 assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
695 assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
696 }
697
698 #[test]
699 fn scope_matches_by_segment_not_substring() {
700 assert!(scope_matches("app::domain::handle", "domain"));
701 assert!(scope_matches("domain::handle", "domain"));
702 assert!(scope_matches("app::domain", "domain"));
703 assert!(scope_matches("crate::domain_logic", "domain"));
704 assert!(!scope_matches("app::subdomain::handle", "domain"));
705 assert!(!scope_matches("app::not_my_domain::f", "domain"));
706 assert!(scope_matches("crate::net::client::send", "net::client"));
708 assert!(scope_matches("crate::net::client_pool::get", "net::client"));
709 assert!(!scope_matches("crate::net::server::send", "net::client"));
710 assert!(!scope_matches("crate::network::client::send", "net::client"));
711 assert!(!scope_matches("crate::net::x::client", "net::client"));
712 assert!(!scope_matches("net", "net::client"));
713 assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
717 assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
718 assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
719 assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
720 assert!(!scope_matches("com.acme.subdomain.h", "domain"));
721 assert!(!scope_matches("com.acme.domain.h", "infra"));
722 }
723
724 #[test]
725 fn fs_path_covered_respects_boundaries() {
726 assert!(fs_path_covered("/etc/app", "/etc/app"));
727 assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
728 assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
729 assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
730 assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
731 assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
732 assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
733 assert!(fs_path_covered("/", "/etc/app/x"));
734 assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
735 assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
736 assert!(fs_path_covered("etc/app", "etc/app/cfg"));
737 }
738}