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/// The NORMATIVE projection of a raw `unknown_why` reason onto a fixed, cross-engine reason CLASS
18/// (candor-spec REASON-SCOPED-UNKNOWN-DESIGN.md §1). Reason-scoped policies (`deny E Unknown[class]`)
19/// quantify over these classes, so the mapping MUST be identical in every engine — this mirrors the
20/// java reference `ReasonClass` (its `classify(String)` path, since rust emits raw string reasons). The
21/// class set is CLOSED (six members); a raw reason matching no pinned prefix maps to `Unresolved` —
22/// conservative: it stays in scope of any `Unknown[*]` / `Unknown[dynamic]` policy, never silently
23/// tolerated.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub enum ReasonClass {
26    /// reflection / metaprogramming
27    Reflect,
28    /// unresolved virtual/dynamic dispatch, same-name ambiguity, invokedynamic
29    Dispatch,
30    /// callback / closure / function-value / async-continuation indirection
31    Indirect,
32    /// FFI / native boundary
33    Native,
34    /// generic unresolvable call/import, AND the catch-all for any unrecognized raw reason
35    Unresolved,
36    /// analysis not wired up (fixable, not a real dynamic hole): missing-config / no-tsconfig
37    Setup,
38}
39
40impl ReasonClass {
41    /// The lowercase policy-facing token (`deny E Unknown[<token>]`).
42    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    /// Parse a policy-facing token back to a class; `None` if it names no class.
54    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    /// Map a raw `unknown_why` reason to its normative class — prefix-based (raw reasons carry a
67    /// `kind:detail` shape, e.g. `dispatch:foo::Bar`), unrecognized → `Unresolved`. Byte-identical
68    /// intent to the java `ReasonClass.classify(String)`.
69    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    /// The `dynamic` alias — every GENUINE blind-spot class (excludes `setup`), incl. `unresolved` (the
87    /// catch-all) so `Unknown[dynamic]` never under-gates. The design's recommended usable strict gate.
88    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
101/// Parse `unknown-alias <name> = <class,…>` lines from `.candor/config` text (⟨0.19⟩, SPEC §6.2) into a
102/// name→classes map. A name that shadows a built-in (`*`/`dynamic`/a class token) is warned-and-skipped (a
103/// config alias may not redefine a built-in), as is a definition naming no valid class. Byte-shape with the
104/// java reference `Config.addAlias`.
105pub 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        // Case-INSENSITIVE key match, like the config loaders in java/ts/swift (which lowercase the key) —
114        // a case-sensitive match here made `Unknown-Alias …` define an alias everywhere BUT rust (a four-way
115        // parse divergence; caught in review). The rest of the line (name + classes) stays case-sensitive.
116        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
152/// ⟨0.20⟩ Parse `net-partner <host>` lines from `.candor/config` (NET-DESTINATION-CLASS-DESIGN.md): the
153/// per-project set of business-partner hosts the `Net` destination-class classifier treats as
154/// `known-partner`. Multi-value (repeatable key); the value is host-normalized (`:port` stripped,
155/// lowercased) like `MODEL_HOSTS`. Case-insensitive key match, mirroring `parse_unknown_aliases` + the
156/// java/ts/swift config loaders. A partner is per-project — never a universal list.
157pub 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
176/// Discover `.candor/config` text for a policy/scan anchored at `start`: `$CANDOR_CONFIG` if set + readable,
177/// else the nearest `.candor/config` walking UP from `start`, else `None`. Read-only + lenient (no
178/// process-exit — the caller decides fail-closed); used to resolve `unknown-alias` for the §6.2 gate +
179/// `parsepolicy` so both reflect the same checked-in config.
180pub 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/// One `deny <Effect…> [scope]` / `pure <scope>` rule (AS-EFF-006). `effects` empty ⇒ a `pure` rule
197/// (ANY effect forbidden). `scope` is a path segment-scope the rule applies to (None = whole unit).
198#[derive(Debug, Clone)]
199pub struct PolicyRule {
200    pub effects: BTreeSet<&'static str>,
201    pub scope: Option<String>,
202    /// Reason-class filter on an `Unknown` membership (REASON-SCOPED-UNKNOWN-DESIGN.md): empty ⇒
203    /// `Unknown[*]` (any reason — the bare form); non-empty ⇒ the Unknown hit fires ONLY for a fn whose
204    /// (transitive) reason classes include one of these. Ignored when `effects` doesn't contain `Unknown`.
205    pub unknown_classes: BTreeSet<ReasonClass>,
206    /// Destination-class filter on a `Net` membership (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
207    /// (any destination — the bare form); non-empty ⇒ the Net hit fires ONLY for a fn whose (transitive)
208    /// destination classes include one of these. Ignored when `effects` doesn't contain `Net`.
209    pub net_classes: BTreeSet<String>,
210    pub raw: String,
211}
212
213/// One `allow <Effect> [in <scope>] <literal>…` rule (AS-EFF-008). The effect is one of the four
214/// that carry a literal surface (`Net` hosts / `Exec` commands / `Fs` paths / `Db` tables); a
215/// function in `scope` performing it may reach ONLY the listed literals. Matching is
216/// effect-specific (`literal_allowed`).
217#[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/// One `forbid <A> -> <B>` module-layering rule (AS-EFF-009): a function in scope `A` must not
226/// transitively call into scope `B`.
227#[derive(Debug, Clone)]
228pub struct LayerRule {
229    pub from: String,
230    pub to: String,
231    pub raw: String,
232}
233
234/// The rule kinds parsed from a CANDOR_POLICY file.
235#[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
242/// The hostname part of a `host[:port]` literal, port stripped — so `api.stripe.com` in a rule accepts
243/// a reached `api.stripe.com:443`. IPv6-aware: a bracketed `[host]:port` yields the bracketed host, and
244/// a BARE IPv6 literal (>1 colon, no brackets) has no port to strip and is returned whole — a naive
245/// first-colon split collapsed every `2001:db8::*` to `2001`, so one allowed IPv6 accepted any address
246/// in that block (/code-review). A hostname/IPv4 `host` or `host:port` (≤1 colon) splits at the colon.
247pub fn host_part(h: &str) -> &str {
248    if let Some(rest) = h.strip_prefix('[') {
249        // `[ipv6]` or `[ipv6]:port` — the host is between the brackets.
250        return rest.split(']').next().unwrap_or(rest);
251    }
252    if h.matches(':').count() > 1 {
253        return h; // bare IPv6 literal — no port suffix to strip
254    }
255    h.split(':').next().unwrap_or(h)
256}
257
258/// The basename of a command (`/usr/bin/git` → `git`), so `allow Exec … git` accepts an absolute path.
259pub fn cmd_base(c: &str) -> &str {
260    c.rsplit(['/', '\\']).next().unwrap_or(c)
261}
262
263/// Whether an allowed path `a` covers a reached path `r` (SPEC §6.2: path-boundary-respecting prefix).
264/// A directory covers itself and everything beneath it, but NOT a sibling sharing a textual prefix
265/// (`/etc/app` ⊉ `/etc/apppwned`); a `..` that climbs out is never covered; absolute/relative are
266/// never conflated.
267pub 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
285/// Whether an allowed table entry `a` covers a reached table `r` (SPEC §6.2): case-insensitive
286/// exact match on the (possibly schema-qualified) name, or a `schema.*` entry covering every table
287/// in that schema. Strict on qualification — an allowed `entries` does NOT cover a reached
288/// `ledger.entries` (write both forms if your queries mix them); silent widening is the failure
289/// mode an allowlist exists to prevent.
290pub 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
298/// Whether a reached literal is allowed under an effect-specific match (SPEC §6.2): `Net` host by
299/// name (port ignored), `Exec` command by basename, `Fs` path by boundary-respecting prefix,
300/// `Db` table by qualified name or `schema.*`.
301pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
302    match effect {
303        // `Llm` ⟨0.13⟩ rides the Net host literal (SPEC §1) — matched by hostname like `Net`.
304        "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
312/// Split a function name (or scope) into PATH SEGMENTS on either separator. Reports reach the Rust gate
313/// AND `candor-query` from BOTH the Rust engines (`::`-separated names) and the JVM/Swift/TS engines
314/// (`.`-separated names — `candor-query` is explicitly built to read them). Segmenting on `::` ALONE
315/// left a scoped `deny`/`pure` rule silently INERT on a dotted name: the scope matched nothing, so
316/// `whatif` returned a false green on the security boundary (gate-evasion). The JVM engine's own
317/// `scopeMatches` already splits on `.`; this aligns the Rust side. A `:`/`.` never appears WITHIN a
318/// real segment, so splitting on both never over-segments a Rust name (no spurious match).
319fn name_segments(s: &str) -> Vec<&str> {
320    s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
321}
322
323/// A policy scope matches a function name by **path segment** (SPEC §6.2), not substring: split both
324/// into segments (on `::` or `.`); the scope matches a contiguous run of name-segments where every
325/// segment except the last matches exactly and the last is a prefix. So `domain` matches
326/// `app::domain::h`, `com.acme.domain.h`, and `domain_logic` but not `subdomain`.
327pub 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
340/// Reconstruct a rule's source form and the `Unknown`-forbidding upgrade for it: `pure <scope>` →
341/// (`"pure <scope>"`, `"deny Unknown <scope>"`); `deny <E…> <scope>` → (`"deny <E…> <scope>"`,
342/// `"deny <E…> Unknown <scope>"`). Shared so the gate note and `candor unverified` name the identical
343/// rule and upgrade — one source of truth for the disclosure's advice.
344pub 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        // `pure` forbids real effects but not Unknown; to REQUIRE provable purity, add a deny-Unknown.
349        (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
356/// The single predicate for a provable-purity hole (eval/fixloop/DISPATCH-NOTE.md): a function that is
357/// `Unknown`, sits in a `pure`/`deny <E>` scope, and PASSES that rule (carries none of its forbidden real
358/// effects) — so its compliance is asserted but not verified (the Unknown could hide the very effect the
359/// rule forbids; the classic case is a fn/closure-injected port). A *real* violation is the gate's job, not
360/// this. Returns the first governing rule under which the function is such a hole, or `None` if it is not
361/// one. Shared by candor-scan's gate note and candor-query's `unverified` so "what a hole is" has ONE
362/// definition — the two paths can never drift (conformance PART 12d pins their agreement).
363pub 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        // in the rule's scope (a scopeless rule governs the whole unit) …
373        if let Some(s) = &r.scope {
374            if !scope_matches(name, s) {
375                return false;
376            }
377        }
378        // … and PASSES it (no forbidden real effect — else it is a violation the gate already reports).
379        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
388/// Parse a CANDOR_POLICY file (SPEC §6.2). One rule per line; `#` comments and blanks ignored:
389///
390/// ```text
391/// deny Net Db  domain     # functions whose path contains segment "domain" must not perform Net or Db
392/// deny Exec               # no function anywhere may perform Exec
393/// deny Unknown  api        # functions in "api" must be fully resolvable (forbid the unverifiable)
394/// pure         parse      # functions whose path contains segment "parse" must be effect-free
395/// allow Net in billing  api.stripe.com
396/// forbid domain -> infra
397/// ```
398///
399/// In a `deny` rule, leading tokens that name a known effect (or `Unknown`) are forbidden; the FIRST
400/// non-effect token is the scope and ends the rule. A `deny` naming no known effect is dropped (it is
401/// NOT a `pure` rule). Malformed/unknown lines are ignored with a warning — never silently widened.
402/// The §6.2 token separator: ASCII whitespace ONLY (space/tab/CR/LF/VT/FF). `split_whitespace`/`trim`
403/// use Unicode `White_Space`, which would split a NBSP/ideographic space that Java drops — a gateless-
404/// green cross-engine divergence (adversarial DSL review). A non-ASCII space stays part of its token, so
405/// the rule is malformed and ignored, uniformly.
406fn 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}
413/// As [`parse_policy`] but with `.candor/config` `unknown-alias` definitions (⟨0.19⟩, SPEC §6.2): an
414/// `Unknown[<name>]` filter resolves a user-defined `<name>` to its reason classes. The gate + `parsepolicy`
415/// pass the discovered aliases (via [`parse_unknown_aliases`]); a config alias never changes what bare
416/// `deny E Unknown` means (always `Unknown[*]`), so the rule stays legible from the policy alone.
417pub 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}
420/// Same as [`parse_policy`] but SILENT about malformed rules — for a SECOND, advisory re-parse within the
421/// same run (candor-scan parses once for the gate check and again for the `unverified` disclosure), so the
422/// CI log doesn't print every "ignoring policy rule …" warning twice (#21). The first parse already warned.
423pub 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    // `str::lines()` splits on \n and \r\n but NOT bare \r — a classic-Mac file then collapses to ONE
430    // line, and since \r is also an in-line ASCII-ws token separator (is_ascii_ws), every rule after the
431    // first was glued into the first rule's tokens and dropped (sweep [16], a gateless-green divergence).
432    // Java's Files.readAllLines (the reference) breaks on bare \r too — normalize to match it. Allocation
433    // only when a bare \r is actually present (the overwhelmingly-common \n / \r\n files are untouched).
434    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` ⟨0.13⟩ rides the Net host literal (SPEC §1) — `allow Llm <host…>` restricts which
452                    // MODEL hosts a scope may reach, matched by hostname like Net (its reached surface IS the
453                    // Net host surface). Match candor-java's Policy.parsePolicy.
454                    "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                // Reason-class filter on `Unknown` (REASON-SCOPED-UNKNOWN-DESIGN.md): empty ⇒ `Unknown[*]`
484                // (any reason — the bare form); non-empty ⇒ only those classes. `*` = all.
485                let mut unknown_classes: BTreeSet<ReasonClass> = BTreeSet::new();
486                let mut unknown_star = false;
487                // Destination-class filter on `Net` (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
488                // (any destination — the bare form); non-empty ⇒ only those classes. `*` = all.
489                let mut net_classes: BTreeSet<String> = BTreeSet::new();
490                let mut net_star = false;
491                for t in toks {
492                    // `Net[unknown-host]` / `Net[*]` / `Net[known-telemetry,unknown-host]`: the destination-scoped form.
493                    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                    // `Unknown[dispatch,reflect]` / `Unknown[*]` / `Unknown[dynamic]`: the reason-scoped form.
511                    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()); // ⟨0.19⟩ config `unknown-alias`
526                            } 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; // bare Unknown ⇒ all classes
538                            }
539                            if e == "Net" {
540                                net_star = true; // bare Net ⇒ all destinations
541                            }
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                // `*` (or bare `Unknown`) means all classes ⇒ empty filter (matches any Unknown).
554                if unknown_star {
555                    unknown_classes.clear();
556                } else if !unknown_classes.is_empty() && !unknown_classes.contains(&ReasonClass::Unresolved) {
557                    // A2 under-gating lint: a narrowed scope that omits `unresolved` (the catch-all for holes
558                    // an engine couldn't classify) may silently tolerate exactly those — flag it (advisory).
559                    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                // `*` (or bare `Net`) means all destinations ⇒ empty filter (matches any Net).
562                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")); // case-insensitive exact
600        assert!(c("ledger.*", "ledger.entries"));       // schema wildcard
601        assert!(!c("ledger.*", "ledgerx.entries"));     // boundary-respecting
602        assert!(!c("entries", "ledger.entries"));       // no silent qualification widening
603        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        // sweep [16]: a classic-Mac (bare \r) multi-rule policy must NOT collapse to the first rule.
635        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        // mixed \r\n and bare \r normalize identically.
639        assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
640        // `Unknown` is a denyable token; a bare `deny` with no effect is ignored.
641        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        // a `deny` whose first token is a non-effect names no effect -> dropped, NOT a pure rule.
644        assert!(parse_policy("deny notaneffect scope").rules.is_empty());
645        // the first non-effect token ENDS the rule: a later effect token is not collected.
646        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        // NBSP is NOT a token separator (only ASCII White_Space is) — pinned to MATCH Java, which
650        // drops it: a `deny\u{a0}Net` is one token `deny\u{a0}Net`, NOT `deny` + `Net`, so it names no
651        // known effect and is dropped. Splitting on Unicode whitespace here would let candor see a deny
652        // the JVM engine doesn't — a gateless-divergence between impls. (See is_ascii_ws.)
653        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        // The NBSP rides INTO the scope token rather than separating it: `deny Net\u{a0}domain` is
656        // `deny` + `Net` + `\u{a0}domain` — Net is the effect, the scope keeps the NBSP verbatim.
657        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        // `Unknown[dispatch,indirect]` narrows the Unknown membership to those classes.
667        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        // bare `Unknown` and `Unknown[*]` ⇒ empty filter (all classes).
673        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        // `dynamic` alias = every genuine class incl. unresolved, excl. setup.
676        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        // config `unknown-alias` (⟨0.19⟩): a user-defined name resolves; a reserved name is rejected.
681        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        // the `unknown-alias` KEY matches case-insensitively (parity with java/ts/swift, which lowercase it)
687        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        // an UNDEFINED alias name is dropped-with-warning → empty filter (behaves like bare Unknown[*])
692        assert!(super::parse_policy_with_aliases("deny Net Unknown[nope] api\n", &aliases).rules[0].unknown_classes.is_empty());
693        // classify: raw reason tokens → normative classes (mirrors java ReasonClass.classify).
694        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); // conservative catch-all
700    }
701
702    #[test]
703    fn net_destination_class_parses_and_classifies() {
704        // `Net[unknown-host,known-telemetry]` narrows the Net membership to those destination classes.
705        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        // bare `Net` and `Net[*]` ⇒ empty filter (all destinations).
714        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        // an unknown destination-class is dropped-with-warning → empty filter (behaves like bare Net[*]).
717        assert!(parse_policy("deny Net[nope] dom\n").rules[0].net_classes.is_empty());
718        // the classifier: telemetry (subdomain-aware), model host, unresolved, and the config-partner path.
719        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("us.i.posthog.com", &no_partners), "known-telemetry"); // 0.20.1 corpus-grown
722        assert_eq!(crate::net_dest_class("o1.ingest.sentry.io", &no_partners), "known-telemetry");
723        assert_eq!(crate::net_dest_class("api.openai.com", &no_partners), "known-partner", "a model host is known-partner");
724        assert_eq!(crate::net_dest_class("evil.example.com", &no_partners), "unknown-host");
725        let partners: BTreeSet<String> = ["api.stripe.com".to_string()].into_iter().collect();
726        assert_eq!(crate::net_dest_class("api.stripe.com", &partners), "known-partner", "config-declared partner");
727        assert_eq!(crate::net_dest_class("api.stripe.com", &no_partners), "unknown-host", "partner is config-only");
728        // `net-partner` config parsing: host-normalized, case-insensitive key, multi-value.
729        let pset = super::parse_net_partners("net-partner Api.Stripe.com:443\nNET-PARTNER hooks.stripe.com\n");
730        assert!(pset.contains("api.stripe.com") && pset.contains("hooks.stripe.com"));
731    }
732
733    #[test]
734    fn allowlist_parses() {
735        let p = parse_policy(
736            "allow Net in billing  api.stripe.com  hooks.stripe.com\n\
737             allow Exec in ci  git\n\
738             allow Fs in config  /etc/app\n\
739             allow Net  github.com\n\
740             allow Clock  whatever\n\
741             allow Net in nohosts\n\
742             allow\n",
743        );
744        assert_eq!(p.allow_rules.len(), 4); // Clock carries no literal surface — rejected; Db now does
745        assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
746        assert_eq!(
747            p.allow_rules[0].literals,
748            ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
749        );
750        assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
751        assert!(p.allow_rules[1].literals.contains("git"));
752        assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
753        assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
754
755        let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
756        assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
757        // IPv6: a bare literal is matched WHOLE (no first-colon collapse), so a different address in the
758        // same block is NOT accepted; a bracketed `[host]:port` matches the bare host. (/code-review.)
759        assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
760        assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
761        assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
762        assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
763        assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
764        assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
765        assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
766        assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
767        assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
768        assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
769        assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
770        assert_eq!(cmd_base("/usr/bin/git"), "git");
771    }
772
773    #[test]
774    fn layering_rule_parses() {
775        let p = parse_policy(
776            "forbid domain -> infra\n\
777             forbid  app::web  ->  app::db \n\
778             forbid domain infra\n\
779             forbid domain ->\n\
780             forbid\n",
781        );
782        assert_eq!(p.layer_rules.len(), 2);
783        assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
784        assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
785    }
786
787    #[test]
788    fn scope_matches_by_segment_not_substring() {
789        assert!(scope_matches("app::domain::handle", "domain"));
790        assert!(scope_matches("domain::handle", "domain"));
791        assert!(scope_matches("app::domain", "domain"));
792        assert!(scope_matches("crate::domain_logic", "domain"));
793        assert!(!scope_matches("app::subdomain::handle", "domain"));
794        assert!(!scope_matches("app::not_my_domain::f", "domain"));
795        // multi-segment: intermediates exact, last is a prefix, contiguous.
796        assert!(scope_matches("crate::net::client::send", "net::client"));
797        assert!(scope_matches("crate::net::client_pool::get", "net::client"));
798        assert!(!scope_matches("crate::net::server::send", "net::client"));
799        assert!(!scope_matches("crate::network::client::send", "net::client"));
800        assert!(!scope_matches("crate::net::x::client", "net::client"));
801        assert!(!scope_matches("net", "net::client"));
802        // DOTTED names (JVM/Swift/TS reports `candor-query` consumes): a scope must match across `.` too,
803        // else a scoped deny/pure rule is silently inert → whatif false-green (gate-evasion). Both a
804        // `.`-written and a `::`-written scope must match a dotted name.
805        assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
806        assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
807        assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
808        assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
809        assert!(!scope_matches("com.acme.subdomain.h", "domain"));
810        assert!(!scope_matches("com.acme.domain.h", "infra"));
811    }
812
813    #[test]
814    fn fs_path_covered_respects_boundaries() {
815        assert!(fs_path_covered("/etc/app", "/etc/app"));
816        assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
817        assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
818        assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
819        assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
820        assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
821        assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
822        assert!(fs_path_covered("/", "/etc/app/x"));
823        assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
824        assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
825        assert!(fs_path_covered("etc/app", "etc/app/cfg"));
826    }
827}