Skip to main content

candor_classify/
policy.rs

1//! The canonical CANDOR_POLICY DSL parser (candor-spec SPEC §6.2).
2//!
3//! This is the **single** Rust implementation of the policy grammar — shared by the nightly dylint
4//! gate (`src/lib.rs`, AS-EFF-006/008/009) and the stable `candor-query` (`whatif`, and the
5//! `parsepolicy` dump the cross-impl conformance suite diffs against the JVM engine). Keeping one
6//! parser here is what makes "the gate means the same thing in every language" a fact rather than a
7//! hope: the Rust gate, the Rust pre-edit tool, and the cross-impl differential all read THIS code.
8//!
9//! Pure, stable Rust (string parsing only — no rustc types), so it lives beside the classifier.
10
11use crate::cap_from_name;
12use std::collections::BTreeSet;
13
14/// The honesty marker (SPEC §4). Denyable so `deny Unknown <scope>` forbids the *unverifiable* case.
15pub const UNKNOWN: &str = "Unknown";
16
17/// One `deny <Effect…> [scope]` / `pure <scope>` rule (AS-EFF-006). `effects` empty ⇒ a `pure` rule
18/// (ANY effect forbidden). `scope` is a path segment-scope the rule applies to (None = whole unit).
19#[derive(Debug, Clone)]
20pub struct PolicyRule {
21    pub effects: BTreeSet<&'static str>,
22    pub scope: Option<String>,
23    pub raw: String,
24}
25
26/// One `allow <Effect> [in <scope>] <literal>…` rule (AS-EFF-008). The effect is one of the four
27/// that carry a literal surface (`Net` hosts / `Exec` commands / `Fs` paths / `Db` tables); a
28/// function in `scope` performing it may reach ONLY the listed literals. Matching is
29/// effect-specific (`literal_allowed`).
30#[derive(Debug, Clone)]
31pub struct AllowRule {
32    pub effect: &'static str,
33    pub scope: Option<String>,
34    pub literals: BTreeSet<String>,
35    pub raw: String,
36}
37
38/// One `forbid <A> -> <B>` module-layering rule (AS-EFF-009): a function in scope `A` must not
39/// transitively call into scope `B`.
40#[derive(Debug, Clone)]
41pub struct LayerRule {
42    pub from: String,
43    pub to: String,
44    pub raw: String,
45}
46
47/// The rule kinds parsed from a CANDOR_POLICY file.
48#[derive(Default, Debug)]
49pub struct ParsedPolicy {
50    pub rules: Vec<PolicyRule>,
51    pub allow_rules: Vec<AllowRule>,
52    pub layer_rules: Vec<LayerRule>,
53}
54
55/// The hostname part of a `host[:port]` literal, port stripped — so `api.stripe.com` in a rule accepts
56/// a reached `api.stripe.com:443`. IPv6-aware: a bracketed `[host]:port` yields the bracketed host, and
57/// a BARE IPv6 literal (>1 colon, no brackets) has no port to strip and is returned whole — a naive
58/// first-colon split collapsed every `2001:db8::*` to `2001`, so one allowed IPv6 accepted any address
59/// in that block (/code-review). A hostname/IPv4 `host` or `host:port` (≤1 colon) splits at the colon.
60pub fn host_part(h: &str) -> &str {
61    if let Some(rest) = h.strip_prefix('[') {
62        // `[ipv6]` or `[ipv6]:port` — the host is between the brackets.
63        return rest.split(']').next().unwrap_or(rest);
64    }
65    if h.matches(':').count() > 1 {
66        return h; // bare IPv6 literal — no port suffix to strip
67    }
68    h.split(':').next().unwrap_or(h)
69}
70
71/// The basename of a command (`/usr/bin/git` → `git`), so `allow Exec … git` accepts an absolute path.
72pub fn cmd_base(c: &str) -> &str {
73    c.rsplit(['/', '\\']).next().unwrap_or(c)
74}
75
76/// Whether an allowed path `a` covers a reached path `r` (SPEC §6.2: path-boundary-respecting prefix).
77/// A directory covers itself and everything beneath it, but NOT a sibling sharing a textual prefix
78/// (`/etc/app` ⊉ `/etc/apppwned`); a `..` that climbs out is never covered; absolute/relative are
79/// never conflated.
80pub fn fs_path_covered(a: &str, r: &str) -> bool {
81    if r.split(['/', '\\']).any(|c| c == "..") {
82        return false;
83    }
84    let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
85    if absolute(a) != absolute(r) {
86        return false;
87    }
88    let norm = |s: &str| -> Vec<String> {
89        s.split(['/', '\\'])
90            .filter(|c| !c.is_empty() && *c != ".")
91            .map(|c| c.to_string())
92            .collect()
93    };
94    let (ac, rc) = (norm(a), norm(r));
95    ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
96}
97
98/// Whether an allowed table entry `a` covers a reached table `r` (SPEC §6.2): case-insensitive
99/// exact match on the (possibly schema-qualified) name, or a `schema.*` entry covering every table
100/// in that schema. Strict on qualification — an allowed `entries` does NOT cover a reached
101/// `ledger.entries` (write both forms if your queries mix them); silent widening is the failure
102/// mode an allowlist exists to prevent.
103pub fn db_table_covered(a: &str, r: &str) -> bool {
104    let (a, r) = (a.to_lowercase(), r.to_lowercase());
105    if let Some(schema) = a.strip_suffix(".*") {
106        return r.strip_prefix(schema).is_some_and(|rest| rest.starts_with('.'));
107    }
108    a == r
109}
110
111/// Whether a reached literal is allowed under an effect-specific match (SPEC §6.2): `Net` host by
112/// name (port ignored), `Exec` command by basename, `Fs` path by boundary-respecting prefix,
113/// `Db` table by qualified name or `schema.*`.
114pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
115    match effect {
116        "Net" => allow.iter().any(|a| host_part(a) == host_part(reached)),
117        "Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
118        "Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
119        "Db" => allow.iter().any(|a| db_table_covered(a, reached)),
120        _ => allow.contains(reached),
121    }
122}
123
124/// Split a function name (or scope) into PATH SEGMENTS on either separator. Reports reach the Rust gate
125/// AND `candor-query` from BOTH the Rust engines (`::`-separated names) and the JVM/Swift/TS engines
126/// (`.`-separated names — `candor-query` is explicitly built to read them). Segmenting on `::` ALONE
127/// left a scoped `deny`/`pure` rule silently INERT on a dotted name: the scope matched nothing, so
128/// `whatif` returned a false green on the security boundary (gate-evasion). The JVM engine's own
129/// `scopeMatches` already splits on `.`; this aligns the Rust side. A `:`/`.` never appears WITHIN a
130/// real segment, so splitting on both never over-segments a Rust name (no spurious match).
131fn name_segments(s: &str) -> Vec<&str> {
132    s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
133}
134
135/// A policy scope matches a function name by **path segment** (SPEC §6.2), not substring: split both
136/// into segments (on `::` or `.`); the scope matches a contiguous run of name-segments where every
137/// segment except the last matches exactly and the last is a prefix. So `domain` matches
138/// `app::domain::h`, `com.acme.domain.h`, and `domain_logic` but not `subdomain`.
139pub fn scope_matches(name: &str, scope: &str) -> bool {
140    let segs = name_segments(name);
141    let parts = name_segments(scope);
142    if parts.is_empty() || parts.len() > segs.len() {
143        return false;
144    }
145    let (last, init) = parts.split_last().unwrap();
146    segs.windows(parts.len()).any(|w| {
147        let (w_last, w_init) = w.split_last().unwrap();
148        w_init == init && w_last.starts_with(last)
149    })
150}
151
152/// Reconstruct a rule's source form and the `Unknown`-forbidding upgrade for it: `pure <scope>` →
153/// (`"pure <scope>"`, `"deny Unknown <scope>"`); `deny <E…> <scope>` → (`"deny <E…> <scope>"`,
154/// `"deny <E…> Unknown <scope>"`). Shared so the gate note and `candor unverified` name the identical
155/// rule and upgrade — one source of truth for the disclosure's advice.
156pub fn rule_and_upgrade(r: &PolicyRule) -> (String, String) {
157    let scope = r.scope.clone().unwrap_or_default();
158    let suffix = if scope.is_empty() { String::new() } else { format!(" {scope}") };
159    if r.effects.is_empty() {
160        // `pure` forbids real effects but not Unknown; to REQUIRE provable purity, add a deny-Unknown.
161        (format!("pure{suffix}"), format!("deny Unknown{suffix}"))
162    } else {
163        let effs = r.effects.iter().copied().collect::<Vec<_>>().join(" ");
164        (format!("deny {effs}{suffix}"), format!("deny {effs} Unknown{suffix}"))
165    }
166}
167
168/// The single predicate for a provable-purity hole (eval/fixloop/DISPATCH-NOTE.md): a function that is
169/// `Unknown`, sits in a `pure`/`deny <E>` scope, and PASSES that rule (carries none of its forbidden real
170/// effects) — so its compliance is asserted but not verified (the Unknown could hide the very effect the
171/// rule forbids; the classic case is a fn/closure-injected port). A *real* violation is the gate's job, not
172/// this. Returns the first governing rule under which the function is such a hole, or `None` if it is not
173/// one. Shared by candor-scan's gate note and candor-query's `unverified` so "what a hole is" has ONE
174/// definition — the two paths can never drift (conformance PART 12d pins their agreement).
175pub fn unverified_hole_rule<'a, S: AsRef<str>>(
176    name: &str,
177    effects: &[S],
178    rules: &'a [PolicyRule],
179) -> Option<&'a PolicyRule> {
180    if !effects.iter().any(|e| e.as_ref() == UNKNOWN) {
181        return None;
182    }
183    rules.iter().find(|r| {
184        // in the rule's scope (a scopeless rule governs the whole unit) …
185        if let Some(s) = &r.scope {
186            if !scope_matches(name, s) {
187                return false;
188            }
189        }
190        // … and PASSES it (no forbidden real effect — else it is a violation the gate already reports).
191        let violates = if r.effects.is_empty() {
192            effects.iter().any(|e| e.as_ref() != UNKNOWN)
193        } else {
194            effects.iter().any(|e| r.effects.contains(e.as_ref()))
195        };
196        !violates
197    })
198}
199
200/// Parse a CANDOR_POLICY file (SPEC §6.2). One rule per line; `#` comments and blanks ignored:
201///
202/// ```text
203/// deny Net Db  domain     # functions whose path contains segment "domain" must not perform Net or Db
204/// deny Exec               # no function anywhere may perform Exec
205/// deny Unknown  api        # functions in "api" must be fully resolvable (forbid the unverifiable)
206/// pure         parse      # functions whose path contains segment "parse" must be effect-free
207/// allow Net in billing  api.stripe.com
208/// forbid domain -> infra
209/// ```
210///
211/// In a `deny` rule, leading tokens that name a known effect (or `Unknown`) are forbidden; the FIRST
212/// non-effect token is the scope and ends the rule. A `deny` naming no known effect is dropped (it is
213/// NOT a `pure` rule). Malformed/unknown lines are ignored with a warning — never silently widened.
214/// The §6.2 token separator: ASCII whitespace ONLY (space/tab/CR/LF/VT/FF). `split_whitespace`/`trim`
215/// use Unicode `White_Space`, which would split a NBSP/ideographic space that Java drops — a gateless-
216/// green cross-engine divergence (adversarial DSL review). A non-ASCII space stays part of its token, so
217/// the rule is malformed and ignored, uniformly.
218fn is_ascii_ws(c: char) -> bool {
219    matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
220}
221
222pub fn parse_policy(text: &str) -> ParsedPolicy {
223    let mut out = ParsedPolicy::default();
224    // `str::lines()` splits on \n and \r\n but NOT bare \r — a classic-Mac file then collapses to ONE
225    // line, and since \r is also an in-line ASCII-ws token separator (is_ascii_ws), every rule after the
226    // first was glued into the first rule's tokens and dropped (sweep [16], a gateless-green divergence).
227    // Java's Files.readAllLines (the reference) breaks on bare \r too — normalize to match it. Allocation
228    // only when a bare \r is actually present (the overwhelmingly-common \n / \r\n files are untouched).
229    let normalized;
230    let text = if text.contains('\r') {
231        normalized = text.replace("\r\n", "\n").replace('\r', "\n");
232        normalized.as_str()
233    } else {
234        text
235    };
236    for raw_line in text.lines() {
237        let line = raw_line.split('#').next().unwrap_or("").trim_matches(is_ascii_ws);
238        if line.is_empty() {
239            continue;
240        }
241        let mut toks = line.split(is_ascii_ws).filter(|s| !s.is_empty());
242        match toks.next().unwrap_or("") {
243            "allow" => {
244                let effect = match toks.next().unwrap_or("") {
245                    "Net" => "Net",
246                    "Exec" => "Exec",
247                    "Fs" => "Fs",
248                    "Db" => "Db",
249                    _ => {
250                        eprintln!(
251                            "candor: ignoring policy rule (allow supports only Net hosts / Exec commands / Fs paths / Db tables): {line}"
252                        );
253                        continue;
254                    }
255                };
256                let mut rest: Vec<&str> = toks.collect();
257                let scope = if rest.first() == Some(&"in") {
258                    let s = rest.get(1).map(|s| s.to_string());
259                    rest.drain(..2.min(rest.len()));
260                    s
261                } else {
262                    None
263                };
264                let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
265                if literals.is_empty() {
266                    eprintln!("candor: ignoring policy rule (allow {effect} names no values): {line}");
267                    continue;
268                }
269                out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
270            }
271            "deny" => {
272                let mut effects = BTreeSet::new();
273                let mut scope = None;
274                for t in toks {
275                    let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
276                    match e {
277                        Some(e) => {
278                            effects.insert(e);
279                        }
280                        None => {
281                            scope = Some(t.to_string());
282                            break;
283                        }
284                    }
285                }
286                if effects.is_empty() {
287                    eprintln!("candor: ignoring policy rule (no known effect named): {line}");
288                    continue;
289                }
290                out.rules.push(PolicyRule { effects, scope, raw: line.to_string() });
291            }
292            "pure" => out.rules.push(PolicyRule {
293                effects: BTreeSet::new(),
294                scope: toks.next().map(str::to_string),
295                raw: line.to_string(),
296            }),
297            "forbid" => {
298                let a = toks.next().unwrap_or("");
299                let arrow = toks.next().unwrap_or("");
300                let b = toks.next().unwrap_or("");
301                if a.is_empty() || arrow != "->" || b.is_empty() {
302                    eprintln!("candor: ignoring layering rule (want `forbid <scope> -> <scope>`): {line}");
303                    continue;
304                }
305                out.layer_rules.push(LayerRule {
306                    from: a.to_string(),
307                    to: b.to_string(),
308                    raw: line.to_string(),
309                });
310            }
311            other => eprintln!("candor: ignoring policy rule (unknown kind `{other}`): {line}"),
312        }
313    }
314    out
315}
316
317#[cfg(test)]
318mod tests {
319    #[test]
320    fn db_table_covering_is_strict() {
321        use super::db_table_covered as c;
322        assert!(c("ledger.entries", "Ledger.Entries")); // case-insensitive exact
323        assert!(c("ledger.*", "ledger.entries"));       // schema wildcard
324        assert!(!c("ledger.*", "ledgerx.entries"));     // boundary-respecting
325        assert!(!c("entries", "ledger.entries"));       // no silent qualification widening
326        assert!(c("entries", "entries"));
327    }
328
329    #[test]
330    fn allow_db_parses_and_gates() {
331        let p = super::parse_policy("allow Db in billing  ledger.* customers\n");
332        assert_eq!(p.allow_rules.len(), 1);
333        assert_eq!(p.allow_rules[0].effect, "Db");
334        assert!(super::literal_allowed("Db", "ledger.entries", &p.allow_rules[0].literals));
335        assert!(super::literal_allowed("Db", "customers", &p.allow_rules[0].literals));
336        assert!(!super::literal_allowed("Db", "audit.log", &p.allow_rules[0].literals));
337    }
338
339    use super::*;
340
341    #[test]
342    fn policy_parses() {
343        let p = parse_policy(
344            "# the domain layer must stay pure of I/O\n\
345             deny Net Db  domain\n\
346             deny Exec\n\
347             pure  parse\n\
348             nonsense line\n\
349             deny notaneffect\n",
350        );
351        let rules = &p.rules;
352        assert_eq!(rules.len(), 3);
353        assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
354        assert_eq!(rules[0].scope.as_deref(), Some("domain"));
355        assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
356        assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
357        // sweep [16]: a classic-Mac (bare \r) multi-rule policy must NOT collapse to the first rule.
358        let cr = parse_policy("deny Net a\rdeny Exec b\rdeny Db c\r");
359        assert_eq!(cr.rules.len(), 3, "bare-CR lines must each parse");
360        assert!(cr.rules.iter().any(|r| r.effects.contains("Exec") && r.scope.as_deref() == Some("b")));
361        // mixed \r\n and bare \r normalize identically.
362        assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
363        // `Unknown` is a denyable token; a bare `deny` with no effect is ignored.
364        assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
365        assert!(parse_policy("deny\ndeny   \n").rules.is_empty());
366        // a `deny` whose first token is a non-effect names no effect -> dropped, NOT a pure rule.
367        assert!(parse_policy("deny notaneffect scope").rules.is_empty());
368        // the first non-effect token ENDS the rule: a later effect token is not collected.
369        let p2 = parse_policy("deny Net foo Db");
370        assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
371        assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
372        // NBSP is NOT a token separator (only ASCII White_Space is) — pinned to MATCH Java, which
373        // drops it: a `deny\u{a0}Net` is one token `deny\u{a0}Net`, NOT `deny` + `Net`, so it names no
374        // known effect and is dropped. Splitting on Unicode whitespace here would let candor see a deny
375        // the JVM engine doesn't — a gateless-divergence between impls. (See is_ascii_ws.)
376        assert!(parse_policy("deny\u{a0}Net core").rules.is_empty(),
377                "an NBSP between deny and the effect must NOT split into separate tokens");
378        // The NBSP rides INTO the scope token rather than separating it: `deny Net\u{a0}domain` is
379        // `deny` + `Net` + `\u{a0}domain` — Net is the effect, the scope keeps the NBSP verbatim.
380        let nb = parse_policy("deny Net \u{a0}domain");
381        assert_eq!(nb.rules.len(), 1);
382        assert_eq!(nb.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
383        assert_eq!(nb.rules[0].scope.as_deref(), Some("\u{a0}domain"));
384    }
385
386    #[test]
387    fn allowlist_parses() {
388        let p = parse_policy(
389            "allow Net in billing  api.stripe.com  hooks.stripe.com\n\
390             allow Exec in ci  git\n\
391             allow Fs in config  /etc/app\n\
392             allow Net  github.com\n\
393             allow Clock  whatever\n\
394             allow Net in nohosts\n\
395             allow\n",
396        );
397        assert_eq!(p.allow_rules.len(), 4); // Clock carries no literal surface — rejected; Db now does
398        assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
399        assert_eq!(
400            p.allow_rules[0].literals,
401            ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
402        );
403        assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
404        assert!(p.allow_rules[1].literals.contains("git"));
405        assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
406        assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
407
408        let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
409        assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
410        // IPv6: a bare literal is matched WHOLE (no first-colon collapse), so a different address in the
411        // same block is NOT accepted; a bracketed `[host]:port` matches the bare host. (/code-review.)
412        assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
413        assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
414        assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
415        assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
416        assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
417        assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
418        assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
419        assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
420        assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
421        assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
422        assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
423        assert_eq!(cmd_base("/usr/bin/git"), "git");
424    }
425
426    #[test]
427    fn layering_rule_parses() {
428        let p = parse_policy(
429            "forbid domain -> infra\n\
430             forbid  app::web  ->  app::db \n\
431             forbid domain infra\n\
432             forbid domain ->\n\
433             forbid\n",
434        );
435        assert_eq!(p.layer_rules.len(), 2);
436        assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
437        assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
438    }
439
440    #[test]
441    fn scope_matches_by_segment_not_substring() {
442        assert!(scope_matches("app::domain::handle", "domain"));
443        assert!(scope_matches("domain::handle", "domain"));
444        assert!(scope_matches("app::domain", "domain"));
445        assert!(scope_matches("crate::domain_logic", "domain"));
446        assert!(!scope_matches("app::subdomain::handle", "domain"));
447        assert!(!scope_matches("app::not_my_domain::f", "domain"));
448        // multi-segment: intermediates exact, last is a prefix, contiguous.
449        assert!(scope_matches("crate::net::client::send", "net::client"));
450        assert!(scope_matches("crate::net::client_pool::get", "net::client"));
451        assert!(!scope_matches("crate::net::server::send", "net::client"));
452        assert!(!scope_matches("crate::network::client::send", "net::client"));
453        assert!(!scope_matches("crate::net::x::client", "net::client"));
454        assert!(!scope_matches("net", "net::client"));
455        // DOTTED names (JVM/Swift/TS reports `candor-query` consumes): a scope must match across `.` too,
456        // else a scoped deny/pure rule is silently inert → whatif false-green (gate-evasion). Both a
457        // `.`-written and a `::`-written scope must match a dotted name.
458        assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
459        assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
460        assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
461        assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
462        assert!(!scope_matches("com.acme.subdomain.h", "domain"));
463        assert!(!scope_matches("com.acme.domain.h", "infra"));
464    }
465
466    #[test]
467    fn fs_path_covered_respects_boundaries() {
468        assert!(fs_path_covered("/etc/app", "/etc/app"));
469        assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
470        assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
471        assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
472        assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
473        assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
474        assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
475        assert!(fs_path_covered("/", "/etc/app/x"));
476        assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
477        assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
478        assert!(fs_path_covered("etc/app", "etc/app/cfg"));
479    }
480}