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::{BTreeMap, 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 ///
70 /// ⟨0.24⟩ THIS IS THE ONLY PLACE THIS ENGINE HOLDS SPEC §4's KIND VOCABULARY. Every other reference
71 /// to a kind is either a raw string being emitted or the `dispatch:` prefix test in candor-query's
72 /// dispatch frontier; there is no typed kind enum here. §4's "AN ENGINE HOLDS THIS VOCABULARY TWICE,
73 /// AND THE HALVES DRIFT" paragraph records the JVM engine classifying `ambiguous` correctly HERE
74 /// while its typed `Kind` enum lacked the kind entirely — one token, two answers, inside one engine,
75 /// concealed precisely because this half was right. Holding it once is why that is not reachable in
76 /// this engine; a future typed representation must be added at the same commit as its control
77 /// (`off_vocabulary_kinds_round_trip_and_classify_through_the_catch_all`).
78 ///
79 /// The five §4 kinds are `reflect`/`native`/`dispatch`/`callback`/`ambiguous`. `ambiguous` maps to
80 /// `dispatch` and rust is its only PRODUCER; `indy`/`task-handoff` are candor-java's migration kinds
81 /// and `dep:`/`dep-stale:` are swift's registered per-dependency-ENTRY kinds, reaching `Unresolved`
82 /// through the catch-all, which is the class §6.2 prescribes for them.
83 ///
84 /// ⟨0.35⟩ **AND THIS ENGINE PRODUCES A SIXTH SPELLING OF ITS OWN, `macro:`, WHICH THE PARAGRAPH
85 /// ABOVE DID NOT NAME UNTIL SOUNDNESS R270.** It is off-vocabulary as a §4 kind — legal under §2
86 /// forward-compatibility, round-tripped verbatim, and reaching `Unresolved` through the catch-all
87 /// below, deliberately: it marks the ZERO-READABLE-DEFS case (a macro this engine declined to
88 /// expand), which is "a limit of its own resolution rather than an ambiguity in the program" in
89 /// §4 ⟨0.25⟩'s words, and `dispatch` is the wrong class for that. Four sites emit it, and they are
90 /// listed here rather than left to a grep because the whole hazard this doc comment exists for is a
91 /// vocabulary held in one place going stale while the code moves:
92 ///
93 /// * `collector.rs` `macro:unexpanded multi-arm macro_rules! <name>` (R257)
94 /// * `collector.rs` `macro:unreadable macro_rules! template <name>` (R257)
95 /// * `collector.rs` `macro:re-export key a macro-hidden module could also own` (R270)
96 /// * `scan.rs` `macro:module items hidden by an unexpanded macro` (R270, R128's site)
97 ///
98 /// R257 minted the spelling at exactly the two lines it was reported for and this comment kept
99 /// asserting the file held the vocabulary once; R270 is the sweep, and the census that justified it
100 /// enumerates every `ambiguous:`-emitting site rather than the ones in hand. The six that REMAIN
101 /// `ambiguous:` are each genuinely two-separately-written-definitions — including
102 /// `ambiguous:same-name macro_rules! definitions`, which a sweep keyed on the WORD "macro" would
103 /// have re-kinded wrongly and cost 15 crates their `deny Unknown[dispatch]` gate. The kind is
104 /// decided by the CONDITION (two readable defs vs none), never by the name.
105 ///
106 /// A dedicated §4 kind for this state is a SPEC clause plus a conformance PART before any engine
107 /// emits it, and candor-rust does not own that file — filed, not invented here.
108 pub fn classify(why: &str) -> ReasonClass {
109 let w = why.trim().to_ascii_lowercase();
110 if w.starts_with("reflect") || w == "dynamicmemberlookup" {
111 ReasonClass::Reflect
112 } else if w.starts_with("native") {
113 ReasonClass::Native
114 } else if w.starts_with("callback") || w.starts_with("closure") || w.starts_with("task-handoff") {
115 ReasonClass::Indirect
116 } else if w.starts_with("dispatch") || w.starts_with("indy") || w.starts_with("ambiguous") {
117 ReasonClass::Dispatch
118 } else if w.starts_with("missing-config") || w.starts_with("no-tsconfig") || w.starts_with("no-node_modules") {
119 ReasonClass::Setup
120 } else {
121 ReasonClass::Unresolved
122 }
123 }
124
125 /// The `dynamic` alias — every GENUINE blind-spot class (excludes `setup`), incl. `unresolved` (the
126 /// catch-all) so `Unknown[dynamic]` never under-gates. The design's recommended usable strict gate.
127 pub fn dynamic_set() -> BTreeSet<ReasonClass> {
128 [
129 ReasonClass::Reflect,
130 ReasonClass::Dispatch,
131 ReasonClass::Indirect,
132 ReasonClass::Native,
133 ReasonClass::Unresolved,
134 ]
135 .into_iter()
136 .collect()
137 }
138}
139
140/// SPEC §6.2 ⟨0.24⟩ — does a function's TRANSITIVE reason-class set intersect `want` (class tokens)?
141///
142/// THE `None`/EMPTY ARM IS THE FAIL-CLOSED NET, and it is why this lives here rather than being
143/// open-coded twice. §6.2: "a function whose `Unknown` carries no recorded reason CONTRIBUTES
144/// `unresolved` to its class set — so a narrowed filter never *silently* tolerates a hole it failed to
145/// classify." Read the other way round, `!contains ⇒ exclude` over an empty set drops the entry from
146/// EVERY filter including one naming its own class: a silent under-report wearing a filter.
147///
148/// `classes` is the ACCUMULATED (post-`propagate_str`) set, never the direct `unknownWhy`: a reason
149/// names a site in the function's OWN body (§4), so a function whose `Unknown` is purely inherited
150/// carries none, and matching against the direct field answers a different question. SHARED by the
151/// `deny E Unknown[class]` gate (candor-scan) and `unverified --class` (candor-query) so a gate and the
152/// disclosure explaining it cannot disagree about which holes a class names.
153///
154/// The empty arm is a NET, not a route: it keys on the ABSENCE of a class set, so any other reason on
155/// the same function hides whatever it was covering. The reasonless case that CAN co-occur with a
156/// reason — a direct `Unknown` the unit did not name — must therefore contribute `unresolved` into the
157/// DIRECT map before propagation (candor-scan's `unknown_via_dep`, candor-query's report-side signature),
158/// not arrive here by absence.
159pub fn reason_class_matches(classes: Option<&BTreeSet<String>>, want: &BTreeSet<&str>) -> bool {
160 match classes {
161 None => want.contains(ReasonClass::Unresolved.token()),
162 Some(cs) if cs.is_empty() => want.contains(ReasonClass::Unresolved.token()),
163 Some(cs) => cs.iter().any(|t| want.contains(t.as_str())),
164 }
165}
166
167/// Parse `unknown-alias <name> = <class,…>` lines from `.candor/config` text (⟨0.19⟩, SPEC §6.2) into a
168/// name→classes map. A name that shadows a built-in (`*`/`dynamic`/a class token) is warned-and-skipped (a
169/// config alias may not redefine a built-in), as is a definition naming no valid class. Byte-shape with the
170/// java reference `Config.addAlias`.
171pub fn parse_unknown_aliases(config_text: &str) -> std::collections::BTreeMap<String, BTreeSet<ReasonClass>> {
172 let mut out = std::collections::BTreeMap::new();
173 for raw in config_text.lines() {
174 let line = raw.split('#').next().unwrap_or("").trim();
175 if line.is_empty() {
176 continue;
177 }
178 let mut it = line.splitn(2, char::is_whitespace);
179 // Case-INSENSITIVE key match, like the config loaders in java/ts/swift (which lowercase the key) —
180 // a case-sensitive match here made `Unknown-Alias …` define an alias everywhere BUT rust (a four-way
181 // parse divergence; caught in review). The rest of the line (name + classes) stays case-sensitive.
182 if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("unknown-alias")) {
183 continue;
184 }
185 let val = it.next().unwrap_or("").trim();
186 let Some((name, classes)) = val.split_once('=') else {
187 eprintln!("candor: ignoring `unknown-alias` (want `unknown-alias <name> = <class,…>`): {val}");
188 continue;
189 };
190 let name = name.trim();
191 if name.is_empty() || name == "*" || name == "dynamic" || ReasonClass::from_token(name).is_some() {
192 eprintln!("candor: ignoring `unknown-alias` with reserved/empty name `{name}` (may not shadow `*`/`dynamic`/a class token)");
193 continue;
194 }
195 let mut set = BTreeSet::new();
196 let mut bad: Vec<&str> = Vec::new();
197 for cn in classes.split(',') {
198 let cn = cn.trim();
199 if cn.is_empty() {
200 continue;
201 }
202 if cn == "dynamic" {
203 set.extend(ReasonClass::dynamic_set());
204 } else if let Some(rc) = ReasonClass::from_token(cn) {
205 set.insert(rc);
206 } else {
207 bad.push(cn);
208 }
209 }
210 // ⟨0.24⟩ **AN UNRECOGNISED TOKEN REFUSES THE WHOLE DEFINITION** — SPEC §6.2 `be0b9a9`: the rule
211 // binds every policy value list, and *"the second is the sharper one: the typo is in the
212 // vocabulary the policy is written against rather than in the policy itself, and it fails open
213 // identically."*
214 //
215 // MEASURED: `unknown-alias corp = dispatch,nativ` → the DEFINITION silently became `{dispatch}`,
216 // so `deny Unknown[corp]` exited 0 over a native-caused hole that `= dispatch,native` catches.
217 // The alias resolved, `used_aliases` recorded it, the verdict named the config — every disclosure
218 // fired correctly ABOUT A DEFINITION THAT WAS NOT THE ONE ON DISK.
219 //
220 // REFUSING THE DEFINITION rather than minting a new error channel is what makes this fit: an
221 // alias that does not exist is one the policy's own `Unknown[<name>]` cannot resolve, so it lands
222 // on the `errors` path already there and the gate routes refuse with exit 2 — naming the token
223 // AND the accepted set, as §6.2 requires. It also keeps the blast radius honest: a config
224 // defining ten aliases, one of them typo'd and NONE of them mentioned by this policy, changed
225 // nothing, and turning that into a red gate would be the mirror over-reach. The refusal is
226 // triggered by USE, exactly like `used_aliases` (recorded at the point of use, for the same
227 // reason).
228 if !bad.is_empty() {
229 eprintln!(
230 "candor: REFUSING `unknown-alias {name}` — it names unrecognised reason-class(es) `{}` \
231 (accepted: reflect, dispatch, indirect, native, unresolved, setup, plus `dynamic`). The \
232 definition is refused WHOLE rather than narrowed to the tokens that parsed: keeping the \
233 rest would silently redefine `{name}` as something narrower than the config says, and a \
234 policy using it would gate less than it reads. `{name}` is now undefined, so a policy \
235 naming it is a policy error (exit 2).",
236 bad.join("`, `")
237 );
238 } else if set.is_empty() {
239 eprintln!("candor: ignoring `unknown-alias {name}` — no valid reason-class");
240 } else {
241 out.insert(name.to_string(), set);
242 }
243 }
244 out
245}
246
247/// ⟨0.20⟩ Parse `net-partner <host>` lines from `.candor/config` (NET-DESTINATION-CLASS-DESIGN.md): the
248/// per-project set of business-partner hosts the `Net` destination-class classifier treats as
249/// `known-partner`. Multi-value (repeatable key); the value is host-normalized (`:port` stripped,
250/// lowercased) like `MODEL_HOSTS`. Case-insensitive key match, mirroring `parse_unknown_aliases` + the
251/// java/ts/swift config loaders. A partner is per-project — never a universal list.
252pub fn parse_net_partners(config_text: &str) -> BTreeSet<String> {
253 let mut out = BTreeSet::new();
254 for raw in config_text.lines() {
255 let line = raw.split('#').next().unwrap_or("").trim();
256 if line.is_empty() {
257 continue;
258 }
259 let mut it = line.splitn(2, char::is_whitespace);
260 if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("net-partner")) {
261 continue;
262 }
263 let val = it.next().unwrap_or("").trim();
264 if val.is_empty() {
265 continue;
266 }
267 // ⟨0.29⟩ A MALFORMED VALUE IS DISCLOSED, NOT SILENTLY KEPT AS JUNK. The grammar is
268 // `net-partner <host>`; the `=` spelling an operator reaches for by habit
269 // (`net-partner = partner.example`) parsed as the HOST `"= partner.example"`, which entered the
270 // set and matched nothing for the rest of the run. The direction is SAFE — the gate stays armed,
271 // so nothing is certified that should not be — which is precisely why it sat unnoticed: the
272 // operator believes a partner is declared, the verdict disagrees, and no line connects the two.
273 // ⟨0.28⟩ gave POLICY files an `ignored` block for exactly this; config files had none.
274 if val.contains(char::is_whitespace) || val.starts_with('=') {
275 eprintln!(
276 "candor: net-partner takes a bare host — `net-partner <host>`, one per line; \
277 '{val}' is not one and was IGNORED (an '=' or extra words is the usual cause) — {}",
278 raw.trim()
279 );
280 continue;
281 }
282 out.insert(host_part(val).to_ascii_lowercase());
283 }
284 out
285}
286
287/// Discover `.candor/config` text for a policy/scan anchored at `start`: `$CANDOR_CONFIG` if set + readable,
288/// else the nearest `.candor/config` walking UP from `start`, else `None`. Read-only + lenient (no
289/// process-exit — the caller decides fail-closed); used to resolve `unknown-alias` for the §6.2 gate +
290/// `parsepolicy` so both reflect the same checked-in config.
291pub fn discover_config_text(start: &std::path::Path) -> Option<String> {
292 discover_config(start).map(|(_, t)| t)
293}
294
295/// THE RUNNING BINARY'S REFUSAL WRITER, registered by whichever entry armed a sink.
296///
297/// (These paragraphs once sat ABOVE this static while documenting `discover_config`, which is below it —
298/// so rustdoc rendered that function's path/canonicalization rationale as a description of this hook.
299/// They now live on the item they describe: a doc block belongs to the next item, and separating it with
300/// a blank line is not a fix — clippy's `empty_line_after_doc_comments` rejects that, and CI said so.)
301///
302/// [`discover_config`] is SHARED and sits BELOW every gate sink, so its `exit(2)` for an unreadable
303/// config could not write the refusal document `--gate-json` was promised. Measured: on the `gate`
304/// verb route an unreadable `CANDOR_CONFIG` exited 2 with an EMPTY stream in candor-scan and
305/// candor-ts while candor-java and candor-swift wrote the refusal. The FILE sink was covered — the
306/// armed placeholder survives an exit that writes nothing — so only the stream, which cannot be
307/// pre-armed, was exposed.
308///
309/// NOT registered at startup: `set_refusal_sink` is called by the gate entries that arm a sink, so a
310/// process that never arms one still exits 2 plainly. The comment inside `discover_config` already
311/// records this cause being fixed once, for the EXIT
312/// CODE: the scan route refused and the query route did not. That fix stopped at the exit code and
313/// left the machine channel, which is exactly the split conformance PART 35 and PART 36 exist to
314/// keep apart. A hook rather than a Result because every caller of this function wants the same
315/// answer — refuse through whatever sink this process armed — and threading a new error type through
316/// all of them would create the second copy of a rule that this project keeps getting bitten by.
317static REFUSAL_SINK: std::sync::OnceLock<fn(&str) -> !> = std::sync::OnceLock::new();
318
319/// Register the process's refusal writer. Idempotent; the first registration wins.
320pub fn set_refusal_sink(f: fn(&str) -> !) {
321 let _ = REFUSAL_SINK.set(f);
322}
323
324/// Exit 2 through the registered sink when there is one, and plainly when there is not — a library
325/// consumer that never armed a sink must still get the documented exit code.
326fn refuse_unevaluable(reason: &str) -> ! {
327 if let Some(f) = REFUSAL_SINK.get() {
328 f(reason)
329 }
330 std::process::exit(2)
331}
332
333/// ⟨0.24⟩ As [`discover_config_text`], but ALSO the PATH the text came from, canonicalized.
334///
335/// **WHY THE PATH IS NOW LOAD-BEARING** (SPEC §3.1): a `.candor/config` supplying `unknown-alias`
336/// vocabulary can move a verdict 0→1, and discovery walks PARENT DIRECTORIES, so a file anywhere above
337/// the policy participates — ambient, and until now invisible in the output. *"A verdict changed by a
338/// file the operator cannot see named in the output is the ambient-input failure this whole format
339/// exists to refuse; the remedy is the same one used everywhere else here — not to forbid the input, but
340/// to make it impossible for it to act unnamed."* So the gate document NAMES it, and the path has to
341/// travel out of discovery for that to be possible.
342///
343/// CANONICALIZED because the two routes reach the same file from different working directories, and
344/// §3.1's byte-equality MUST is about the DOCUMENT: a relative path would differ between them for no
345/// reason other than where each was invoked.
346pub fn discover_config(start: &std::path::Path) -> Option<(std::path::PathBuf, String)> {
347 let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
348 // CONFIGURED-BUT-UNUSABLE FAILS LOUD, ON THIS ROUTE TOO. `.ok()` turned an unreadable config into
349 // "no config", so the run continued WITHOUT whatever it declared — a policy, a baseline, an engine
350 // pin, an `unknown-alias` vocabulary. The SCAN route already refuses; this QUERY route did not, so
351 // `gate --report R --policy P` with a broken CANDOR_CONFIG exited 1 here and 2 in java and ts on the
352 // same input. §3.4's posture does not vary by verb.
353 if let Ok(p) = std::env::var("CANDOR_CONFIG") {
354 let p = std::path::PathBuf::from(p);
355 match std::fs::read_to_string(&p) {
356 Ok(t) => return Some((canon(&p), t)),
357 Err(e) => {
358 eprintln!("candor: CANDOR_CONFIG set but {} could not be read ({e}) — failing (exit 2,", p.display());
359 eprintln!(" unevaluable). A config that cannot be read is a guard the operator believes is on.");
360 refuse_unevaluable(&format!("CANDOR_CONFIG set but {} could not be read ({e})", p.display()));
361 }
362 }
363 }
364 let start = canon(start);
365 let mut cur = if start.is_dir() { Some(start.as_path()) } else { start.parent() };
366 while let Some(d) = cur {
367 let cand = d.join(".candor/config");
368 if cand.is_file() {
369 match std::fs::read_to_string(&cand) {
370 Ok(t) => return Some((canon(&cand), t)),
371 Err(e) => {
372 eprintln!("candor: {} exists but could not be read ({e}) — failing (exit 2,", cand.display());
373 eprintln!(" unevaluable). Treating it as absent would run without what it declares.");
374 refuse_unevaluable(&format!("{} exists but could not be read ({e})", cand.display()));
375 }
376 }
377 }
378 cur = d.parent();
379 }
380 None
381}
382
383/// One `deny <Effect…> [scope]` / `pure <scope>` rule (AS-EFF-006). `effects` empty ⇒ a `pure` rule
384/// (ANY effect forbidden). `scope` is a path segment-scope the rule applies to (None = whole unit).
385#[derive(Debug, Clone)]
386pub struct PolicyRule {
387 pub effects: BTreeSet<&'static str>,
388 pub scope: Option<String>,
389 /// Reason-class filter on an `Unknown` membership (REASON-SCOPED-UNKNOWN-DESIGN.md): empty ⇒
390 /// `Unknown[*]` (any reason — the bare form); non-empty ⇒ the Unknown hit fires ONLY for a fn whose
391 /// (transitive) reason classes include one of these. Ignored when `effects` doesn't contain `Unknown`.
392 pub unknown_classes: BTreeSet<ReasonClass>,
393 /// Destination-class filter on a `Net` membership (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
394 /// (any destination — the bare form); non-empty ⇒ the Net hit fires ONLY for a fn whose (transitive)
395 /// destination classes include one of these. Ignored when `effects` doesn't contain `Net`.
396 pub net_classes: BTreeSet<String>,
397 pub raw: String,
398}
399
400/// ⟨0.33⟩ ONE `deny`/`pure` RULE IN ITS CANONICAL EXPANDED FORM — the §6.2 spelling of the rule as THE
401/// MATCHER USED it, after alias resolution (`unknown_classes` is already the resolved token set, not the
402/// raw `Unknown[<alias>]` text).
403///
404/// This is what SPEC §2 ⟨0.33⟩'s `scannedUnder.deny` records: the deny set a policy-scanned peek was
405/// BOUNDED BY, so a consumer gating with a different deny set can tell whether the producer was even
406/// asked its question (candor-java's `Policy.canonicalDenyRule` is the reference sibling — this is the
407/// same rendering, ported).
408///
409/// **NOT effect NAMES.** `pure` is a rule with an EMPTY effect set ("every effect except `Unknown`"), so
410/// flattening the rule to a set of names loses it entirely and a flattened STRICTEST policy compares
411/// equal to the EMPTY set — the four-way false all-clear ⟨0.30⟩ closed on the peek itself, one layer in.
412///
413/// **NOT the raw line.** §3.1 already records that alias expansion breaks byte-equality: two configs
414/// defining `unknown-alias corp` DIFFERENTLY give the raw line `deny Unknown[corp]` two meanings, so
415/// comparing raw text would read a producer that asked the WEAKER question as having answered the
416/// stronger one. Recording the EXPANDED, resolved form is the only rendering that is one meaning per
417/// string.
418pub fn canonical_deny_rule(r: &PolicyRule) -> String {
419 let suffix = r.scope.as_deref().map(|s| format!(" {s}")).unwrap_or_default();
420 if r.effects.is_empty() {
421 return format!("pure{suffix}");
422 }
423 // `effects` is a `BTreeSet<&'static str>`, so this iterates in one fixed (alphabetical) order for
424 // any given set — deterministic across calls and across which order the policy TEXT listed them in,
425 // which is what lets `canonical_deny_set` below produce ONE string per rule regardless of how the
426 // operator wrote it.
427 let parts: Vec<String> = r
428 .effects
429 .iter()
430 .map(|&e| {
431 if e == "Unknown" && !r.unknown_classes.is_empty() {
432 let mut toks: Vec<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
433 toks.sort_unstable();
434 format!("Unknown[{}]", toks.join(","))
435 } else if e == "Net" && !r.net_classes.is_empty() {
436 // `net_classes` is a `BTreeSet<String>`, already code-point sorted.
437 format!("Net[{}]", r.net_classes.iter().cloned().collect::<Vec<_>>().join(","))
438 } else {
439 e.to_string()
440 }
441 })
442 .collect();
443 format!("deny {}{suffix}", parts.join(" "))
444}
445
446/// ⟨0.33⟩ A rule LIST as a canonical SET — [`canonical_deny_rule`] over every entry, deduplicated and
447/// code-point sorted (`BTreeSet<String>`'s own order, the same collation the verdict's `zeroMatch` list
448/// uses), so one policy produces ONE document however its lines were ordered and a consumer's SUBSET
449/// test (SPEC §2 ⟨0.33⟩) is a plain membership test rather than an order-sensitive comparison.
450pub fn canonical_deny_set(rules: &[PolicyRule]) -> Vec<String> {
451 rules.iter().map(canonical_deny_rule).collect::<BTreeSet<String>>().into_iter().collect()
452}
453
454/// One `allow <Effect> [in <scope>] <literal>…` rule (AS-EFF-008). The effect is one of the four
455/// that carry a literal surface (`Net` hosts / `Exec` commands / `Fs` paths / `Db` tables); a
456/// function in `scope` performing it may reach ONLY the listed literals. Matching is
457/// effect-specific (`literal_allowed`).
458#[derive(Debug, Clone)]
459pub struct AllowRule {
460 pub effect: &'static str,
461 pub scope: Option<String>,
462 pub literals: BTreeSet<String>,
463 pub raw: String,
464}
465
466/// One `forbid <A> -> <B>` module-layering rule (AS-EFF-009): a function in scope `A` must not
467/// transitively call into scope `B`.
468#[derive(Debug, Clone)]
469pub struct LayerRule {
470 pub from: String,
471 pub to: String,
472 pub raw: String,
473}
474
475/// ⟨0.29⟩ One `only <A> -> <B> [<C> …]` PERMISSION rule (AS-EFF-011): a function in scope `A` may reach
476/// `A` itself and the listed scopes, and NOTHING else.
477///
478/// **`forbid` FAILS OPEN; `only` FAILS SAFE, and that is the whole reason the form exists.** A dependency
479/// you forgot to prohibit is silently permitted, so "this package is a leaf" can only be spelled by
480/// enumerating what it must not reach — a list that does not cover a package added later, and nothing says
481/// so. That is the allowlist hazard this project refuses everywhere in the analysis, living in the POLICY
482/// LANGUAGE instead. Under `only`, a dependency you forgot to permit is a loud violation.
483///
484/// Found by pointing candor's own architecture gate at candor: the natural
485/// `forbid io.poly.candor.model -> io.poly.candor` SELF-FIRES at 58 violations, because a scope matches a
486/// contiguous run of segments and `model` sits under the very prefix it is trying to protect itself from.
487///
488/// THE WALK STOPS AT A PERMITTED SCOPE, and that is a semantic decision rather than an optimisation: a
489/// permitted callee's own dependencies are governed by the rules about IT, not by this one. Descending
490/// past it would make `only` require the transitive closure of everything you permit — which is the
491/// enumeration-that-rots this form exists to replace, one level down.
492#[derive(Debug, Clone)]
493pub struct OnlyRule {
494 /// The scope being constrained. It may always reach ITSELF: an explicit `A -> A` would be noise, and
495 /// without the implicit permission the form is unusable for exactly the case it exists for.
496 pub from: String,
497 /// The scopes `from` may reach. At least one — `only A ->` with nothing after the arrow is DROPPED as
498 /// malformed rather than read as "A may reach nothing at all", which is a different rule (and one a
499 /// reader is far more likely to have typed by accident than to have meant).
500 pub to: Vec<String>,
501 pub raw: String,
502}
503
504/// ⟨0.24⟩ ONE POLICY LINE THE PARSER DID NOT HONOUR AS WRITTEN (SPEC §3.1 `901f14d` / `195d45a`).
505///
506/// **THE DEFECT.** `parsepolicy` emitted **no `errors` key at all** (measured 2026-07-28 on the
507/// conformance battery: java 10, ts 4, rust 0, swift 0). Every one of these facts existed — they were
508/// printed to stderr as "ignoring policy rule …" — so the verb whose entire purpose is to let a consumer
509/// diff what an engine made of a policy answered the question with the not-honoured half deleted. Worse,
510/// it was INCONSISTENT with this engine's own gate: the gate refuses an unrecognised class token while
511/// the parse narrowed it silently, which is two answers to one question.
512///
513/// **`kind` IS A CLOSED SET, AND IT IS THE SPEC'S, NOT THE REFERENCE ENGINE'S.** `901f14d` pins four
514/// values: `reason-class/alias`, `Net destination-class`, `effect-name`, `rule-kind`. Measured, candor-java
515/// emits `forbid form`, `allow values` and `rule kind` (space, not hyphen) — three values outside the set
516/// and one spelling divergence — and candor-ts renames `kind`→`vocabulary` and `rule`→`where` and emits
517/// `accepted` as a PROSE STRING. This engine follows the clause: a line that names a rule keyword but does
518/// not form that keyword's rule formed no rule kind, so it is `rule-kind`.
519///
520/// `accepted` is an ARRAY OF TOKENS — the tokens that WOULD have been honoured in the position the bad one
521/// occupies. Empty where the position is open-ended (a host, a path), which is a fact about the grammar
522/// rather than a gap in the report.
523#[derive(Debug, Clone)]
524pub struct PolicyError {
525 /// One of [`PolicyError::KIND_REASON_CLASS`], [`PolicyError::KIND_NET_CLASS`],
526 /// [`PolicyError::KIND_EFFECT_NAME`], [`PolicyError::KIND_RULE_KIND`].
527 pub kind: &'static str,
528 /// The offending token, verbatim. Empty when the position was EMPTY (a missing arrow, an `allow` with
529 /// no values) — which is itself the finding.
530 pub token: String,
531 /// The tokens accepted in that position. Empty ⇒ the position takes an open-ended literal.
532 pub accepted: Vec<String>,
533 /// The raw policy line, verbatim.
534 pub rule: String,
535 /// The human sentence — the same text the stderr channel carries, so the two cannot disagree.
536 pub message: String,
537 /// ⟨0.28⟩ The 1-based SOURCE LINE the error sits on — SPEC §6.2 pins the verdict's `ignored`
538 /// disclosure as `[{ line, text, reason }]`, and a consumer's next action is to go to that line.
539 /// Counted over the normalized text (bare `\r` line breaks count like `\n`, matching the split).
540 pub line: usize,
541 /// ⟨0.28⟩ The source line VERBATIM — before comment-stripping and trimming, unlike `rule`, because
542 /// §6.2's `text` is "the source line, verbatim" and the operator matches it against their file.
543 pub text: String,
544 /// ⟨0.24⟩ Does this error make the policy UNHONOURABLE, so every gate route must refuse (exit 2)?
545 ///
546 /// FATAL and REPORTED are different questions and this field is the only place they are told apart.
547 /// A dropped `nonsense line` is reported and survivable — the rest of the policy means what it says.
548 /// A rewritten `deny Unknown[dispatch,nativ]` is not: the rule that RAN is not the rule that was
549 /// written, and the direction that matters NARROWS it.
550 pub fatal: bool,
551}
552
553impl PolicyError {
554 pub const KIND_REASON_CLASS: &'static str = "reason-class/alias";
555 pub const KIND_NET_CLASS: &'static str = "Net destination-class";
556 pub const KIND_EFFECT_NAME: &'static str = "effect-name";
557 pub const KIND_RULE_KIND: &'static str = "rule-kind";
558}
559
560/// The rule kinds parsed from a CANDOR_POLICY file.
561#[derive(Default, Debug)]
562pub struct ParsedPolicy {
563 pub rules: Vec<PolicyRule>,
564 pub allow_rules: Vec<AllowRule>,
565 pub layer_rules: Vec<LayerRule>,
566 /// ⟨0.29⟩ the `only <A> -> <B> …` permission rules — see [`OnlyRule`]. Kept in their own list rather
567 /// than folded into `layer_rules` because the two read OPPOSITE ways: a `forbid` names what must not
568 /// happen, an `only` names the complete set of what may, so a route that handled one as the other
569 /// would inverse the verdict rather than approximate it.
570 pub only_rules: Vec<OnlyRule>,
571 /// ⟨0.24⟩ POLICY ERRORS — a policy that cannot be honoured AS WRITTEN (SPEC §6.2). Non-empty ⇒ every
572 /// gate route MUST refuse: exit 2, the unreadable-policy posture. Not a warning list: the rules in
573 /// `rules` are what the text would mean if the error were tolerated, and tolerating it is the defect.
574 ///
575 /// Today the only member is an unrecognised reason-class/alias token in an `Unknown[…]` filter. The
576 /// asymmetry that used to justify a warning — "a dropped policy token leaves a WIDER rule standing,
577 /// so the failure is loud" — is false in the case that matters, and the false half is FAIL-OPEN:
578 ///
579 /// - `deny Unknown[corp]` (sole unrecognised token) — the filter empties and the rule WIDENS to a
580 /// bare `deny Unknown`, while the engine prints "ignoring policy rule" and then KEEPS and
581 /// re-scopes it. Merely surprising, but a FALSE DISCLOSURE.
582 /// - `deny Unknown[dispatch,nativ]` (a typo BESIDE valid tokens) — the token is dropped, the rule
583 /// NARROWS to `[dispatch]`, and it stops gating native-caused holes entirely while the operator
584 /// reads a gate that looks armed. **That is the fail-open, and it is the common case: a typo
585 /// lands beside correct tokens far more often than alone.**
586 ///
587 /// A policy that cannot be honoured as written is not silently rewritten into a different policy.
588 ///
589 /// ⟨0.24⟩ THIS LIST NOW HOLDS EVERY LINE THE PARSER DID NOT HONOUR, fatal or not (SPEC §3.1
590 /// `195d45a`) — `parsepolicy` reports them all, and the gate routes refuse on
591 /// [`ParsedPolicy::fatal_messages`] alone. Widening the LIST without widening what REFUSES is the
592 /// whole of the change: a dropped `nonsense line` was always survivable and stays so.
593 pub errors: Vec<PolicyError>,
594 /// ⟨0.24⟩ The `.candor/config` `unknown-alias` definitions this policy actually resolved a token
595 /// through (SPEC §3.1) — **name → the reason-class TOKENS it expanded to**, not a bare name list.
596 /// Non-empty ⇒ a config file supplied vocabulary that PARTICIPATED in the verdict, and the
597 /// `--gate-json` document MUST name that file. Recorded at the point of USE, not from the alias map:
598 /// a config defining ten aliases none of which the policy mentions changed nothing, and naming it
599 /// would train the reader to ignore the field.
600 ///
601 /// ⟨0.24⟩ **THE VALUE TRAVELS WITH THE NAME, AND THAT IS A SPEC MUST** (§3.1, candor-spec `7f5b5ba`).
602 /// This engine shipped the bare name — as did java and swift — and candor-ts kept the map and won the
603 /// argument from the clause's OWN sentence: `configSources: [path]` is rejected there because *a
604 /// disclosure that names the source but not the content leaves the reader knowing they were affected
605 /// and not how*, and `["corp"]` fails that same test one level down. **`corp = reflect` and
606 /// `corp = reflect,native` gate DIFFERENTLY under one unchanged policy line**, so a reader given only
607 /// the name cannot tell which gate ran. The map is a strict superset — the keys recover the old array.
608 ///
609 /// Class TOKENS rather than `ReasonClass`, so the wire order is the token's alphabetical one (which
610 /// is what candor-ts's `[...set].sort()` produces) and not `ReasonClass`'s declaration order.
611 pub used_aliases: BTreeMap<String, BTreeSet<String>>,
612}
613
614impl ParsedPolicy {
615 /// ⟨0.24⟩ The messages of the errors that make the policy UNHONOURABLE — what every gate route
616 /// refuses on. Non-empty ⇒ exit 2, the unreadable-policy posture.
617 ///
618 /// Separate from `errors` because REPORTED and FATAL are different questions, and conflating them in
619 /// either direction is a defect: refusing on every dropped line would make `nonsense line` fail a
620 /// build, and reporting only the fatal ones is the silent narrowing this rung exists to close.
621 pub fn fatal_messages(&self) -> Vec<&str> {
622 self.errors.iter().filter(|e| e.fatal).map(|e| e.message.as_str()).collect()
623 }
624}
625
626/// The hostname part of a `host[:port]` literal, port stripped — so `api.stripe.com` in a rule accepts
627/// a reached `api.stripe.com:443`. IPv6-aware: a bracketed `[host]:port` yields the bracketed host, and
628/// a BARE IPv6 literal (>1 colon, no brackets) has no port to strip and is returned whole — a naive
629/// first-colon split collapsed every `2001:db8::*` to `2001`, so one allowed IPv6 accepted any address
630/// in that block (/code-review). A hostname/IPv4 `host` or `host:port` (≤1 colon) splits at the colon.
631pub fn host_part(h: &str) -> &str {
632 if let Some(rest) = h.strip_prefix('[') {
633 // `[ipv6]` or `[ipv6]:port` — the host is between the brackets.
634 return rest.split(']').next().unwrap_or(rest);
635 }
636 if h.matches(':').count() > 1 {
637 return h; // bare IPv6 literal — no port suffix to strip
638 }
639 h.split(':').next().unwrap_or(h)
640}
641
642/// The basename of a command (`/usr/bin/git` → `git`), so `allow Exec … git` accepts an absolute path.
643pub fn cmd_base(c: &str) -> &str {
644 c.rsplit(['/', '\\']).next().unwrap_or(c)
645}
646
647/// Whether an allowed path `a` covers a reached path `r` (SPEC §6.2: path-boundary-respecting prefix).
648/// A directory covers itself and everything beneath it, but NOT a sibling sharing a textual prefix
649/// (`/etc/app` ⊉ `/etc/apppwned`); a `..` that climbs out is never covered; absolute/relative are
650/// never conflated.
651pub fn fs_path_covered(a: &str, r: &str) -> bool {
652 if r.split(['/', '\\']).any(|c| c == "..") {
653 return false;
654 }
655 let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
656 if absolute(a) != absolute(r) {
657 return false;
658 }
659 let norm = |s: &str| -> Vec<String> {
660 s.split(['/', '\\'])
661 .filter(|c| !c.is_empty() && *c != ".")
662 .map(|c| c.to_string())
663 .collect()
664 };
665 let (ac, rc) = (norm(a), norm(r));
666 ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
667}
668
669/// Whether an allowed table entry `a` covers a reached table `r` (SPEC §6.2): case-insensitive
670/// exact match on the (possibly schema-qualified) name, or a `schema.*` entry covering every table
671/// in that schema. Strict on qualification — an allowed `entries` does NOT cover a reached
672/// `ledger.entries` (write both forms if your queries mix them); silent widening is the failure
673/// mode an allowlist exists to prevent.
674pub fn db_table_covered(a: &str, r: &str) -> bool {
675 let (a, r) = (a.to_lowercase(), r.to_lowercase());
676 if let Some(schema) = a.strip_suffix(".*") {
677 return r.strip_prefix(schema).is_some_and(|rest| rest.starts_with('.'));
678 }
679 a == r
680}
681
682/// Whether a reached literal is allowed under an effect-specific match (SPEC §6.2): `Net` host by
683/// name (port ignored), `Exec` command by basename, `Fs` path by boundary-respecting prefix,
684/// `Db` table by qualified name or `schema.*`.
685pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
686 match effect {
687 // `Llm` ⟨0.13⟩ rides the Net host literal (SPEC §1) — matched by hostname like `Net`.
688 "Net" | "Llm" => allow.iter().any(|a| host_part(a) == host_part(reached)),
689 "Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
690 "Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
691 "Db" => allow.iter().any(|a| db_table_covered(a, reached)),
692 _ => allow.contains(reached),
693 }
694}
695
696/// Split a function name (or scope) into PATH SEGMENTS on either separator. Reports reach the Rust gate
697/// AND `candor-query` from BOTH the Rust engines (`::`-separated names) and the JVM/Swift/TS engines
698/// (`.`-separated names — `candor-query` is explicitly built to read them). Segmenting on `::` ALONE
699/// left a scoped `deny`/`pure` rule silently INERT on a dotted name: the scope matched nothing, so
700/// `whatif` returned a false green on the security boundary (gate-evasion). The JVM engine's own
701/// `scopeMatches` already splits on `.`; this aligns the Rust side. A `:`/`.` never appears WITHIN a
702/// real segment, so splitting on both never over-segments a Rust name (no spurious match).
703fn name_segments(s: &str) -> Vec<&str> {
704 s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
705}
706
707/// A policy scope matches a function name by **path segment** (SPEC §6.2), not substring: split both
708/// into segments (on `::` or `.`); the scope matches a contiguous run of name-segments where every
709/// segment except the last matches exactly and the last is a prefix. So `domain` matches
710/// `app::domain::h`, `com.acme.domain.h`, and `domain_logic` but not `subdomain`.
711/// ⟨0.29⟩ SCOPE MATCHING FOR A **PERMISSION**, where the prefix rule is FAIL-OPEN.
712///
713/// [`scope_matches`]'s last segment is a PREFIX of its name-segment, so `util` matches `utilities`. For
714/// `deny`/`pure`/`forbid` that widening is FAIL-CLOSED — a scope that matches more forbids more — and it
715/// is why the rule exists. For the `to` list of an `only` rule it is the exact inverse: a permitted scope
716/// that matches more PERMITS more, so the matcher that keeps every other rule kind safe silently widens
717/// the one form whose entire purpose is to fail safe.
718///
719/// MEASURED on the shipped ⟨0.29⟩ implementation, before this function existed:
720///
721/// ```text
722/// only model -> util `model::go` reaching `utilities_untrusted::exfil` → policy ✓
723/// forbid model -> util the identical reach → AS-EFF-009
724/// ```
725///
726/// The operator wrote a complete permission list and the matcher quietly extended it to anything sharing
727/// a prefix with an entry. So a `to` scope matches by EXACT segment run, with no prefix on the last —
728/// `only a -> util` permits `util::x` and `crate::util::x`, and does NOT permit `utilities_untrusted::x`.
729///
730/// THE `from` SIDE KEEPS THE PREFIX RULE, deliberately: `from` selects which functions the rule BINDS, so
731/// matching more constrains more, which is the safe direction. The asymmetry is the point — each side
732/// takes the matcher whose over-approximation errs toward the gate firing.
733pub fn scope_matches_permitted(name: &str, scope: &str) -> bool {
734 let segs = name_segments(name);
735 let parts = name_segments(scope);
736 if parts.is_empty() || parts.len() > segs.len() {
737 return false;
738 }
739 segs.windows(parts.len()).any(|w| w == parts.as_slice())
740}
741
742/// A TRAILING SEPARATOR MEANS EXACT SEGMENT — `app::` matches the segment `app` and nothing else,
743/// where bare `app` keeps the documented prefix behaviour and still matches `application_name`.
744///
745/// REPORTED FROM THE FIELD (ebman CI adoption, 2026-08-23) and reproduced: `forbid aws -> app` fired
746/// 14 times on honest AWS SDK calls because `app` prefix-matched `application_name`, and writing
747/// `app::` did not help — `name_segments` drops empty parts, so `app::` segmented to exactly `["app"]`
748/// and the separator never survived to mean anything. The reporter deleted the rule, which is the real
749/// cost: **the genuine `aws -> app` violation it existed to catch will now never fire, and nothing in
750/// the policy file records that a boundary stopped being checked.** The two available responses to the
751/// false positives — delete the rule, or widen the scope until it stops firing — end in the same place.
752///
753/// ADDITIVE ON PURPOSE. Bare `app` is unchanged, so no existing verdict moves; only `app::`, which
754/// today silently behaves as `app`, starts meaning what everyone who writes it intends. Silence was
755/// the worst of the three options available (match exactly / error as unsupported / quietly do
756/// nothing), because ⟨0.24⟩ §3.1 already rules that an unanswerable condition must be DISCLOSED rather
757/// than scored as satisfied — and a scope token that segments away is exactly that.
758fn scope_is_exact(scope: &str) -> bool {
759 let t = scope.trim_end();
760 t.ends_with("::") || t.ends_with('.')
761}
762
763pub fn scope_matches(name: &str, scope: &str) -> bool {
764 let segs = name_segments(name);
765 let parts = name_segments(scope);
766 if parts.is_empty() || parts.len() > segs.len() {
767 return false;
768 }
769 let exact = scope_is_exact(scope);
770 let (last, init) = parts.split_last().unwrap();
771 segs.windows(parts.len()).any(|w| {
772 let (w_last, w_init) = w.split_last().unwrap();
773 w_init == init && if exact { w_last == last } else { w_last.starts_with(last) }
774 })
775}
776
777/// Reconstruct a rule's source form and the `Unknown`-forbidding upgrade for it: `pure <scope>` →
778/// (`"pure <scope>"`, `"deny Unknown <scope>"`); `deny <E…> <scope>` → (`"deny <E…> <scope>"`,
779/// `"deny <E…> Unknown <scope>"`). Shared so the gate note and `candor unverified` name the identical
780/// rule and upgrade — one source of truth for the disclosure's advice.
781///
782/// ⟨0.24⟩ **THE NARROWING FILTERS ARE RENDERED, and they had to start being rendered in the same commit
783/// that made them REACHABLE here.** Making `unverified_hole_rule` filter-aware is what first lets a
784/// `deny Unknown[reflect]` / `deny Net[unknown-host]` rule be the rule a hole is disclosed under — and
785/// this reconstruction dropped the bracket, so the fix would have printed the operator's narrowed rule
786/// back to them as the WIDE one (`deny Unknown`) and advised the nonsense upgrade `deny Unknown
787/// Unknown`. That is the same mis-attribution `whatif` carries, arriving through the fix for a different
788/// defect: the hazard on this rung is that each correction manufactures its own mirror, so the rendering
789/// moves with the predicate rather than after it.
790///
791/// A rule carrying NO filter renders byte-identically to before, which is what keeps conformance PARTs
792/// 12c/12d (`deny Db Net Unknown domain`, four-way) unmoved.
793///
794/// THE UPGRADE SPLITS on whether the rule already denies `Unknown`. If it does, it can only be here
795/// NARROWED — a bare `deny … Unknown` fires on every `Unknown`, so the function would be a violation and
796/// not a hole — and the upgrade is that term WIDENED to bare `Unknown`, not a second `Unknown` appended.
797pub fn rule_and_upgrade(r: &PolicyRule) -> (String, String) {
798 let scope = r.scope.clone().unwrap_or_default();
799 let suffix = if scope.is_empty() { String::new() } else { format!(" {scope}") };
800 if r.effects.is_empty() {
801 // `pure` forbids real effects but not Unknown; to REQUIRE provable purity, add a deny-Unknown.
802 return (format!("pure{suffix}"), format!("deny Unknown{suffix}"));
803 }
804 // One effect term, with its narrowing filter if it has one. Class tokens sorted by TOKEN string, as
805 // `parsepolicy` sorts them and as the java reference's `.sorted()` does — the dump and the
806 // disclosure must spell one rule one way.
807 let term = |e: &str| -> String {
808 if e == UNKNOWN && !r.unknown_classes.is_empty() {
809 let mut t: Vec<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
810 t.sort_unstable();
811 format!("{UNKNOWN}[{}]", t.join(","))
812 } else if e == "Net" && !r.net_classes.is_empty() {
813 format!("Net[{}]", r.net_classes.iter().map(String::as_str).collect::<Vec<_>>().join(","))
814 } else {
815 e.to_string()
816 }
817 };
818 let effs = r.effects.iter().map(|e| term(e)).collect::<Vec<_>>().join(" ");
819 if r.effects.contains(UNKNOWN) {
820 let widened =
821 r.effects.iter().map(|e| if *e == UNKNOWN { UNKNOWN.to_string() } else { term(e) }).collect::<Vec<_>>();
822 (format!("deny {effs}{suffix}"), format!("deny {}{suffix}", widened.join(" ")))
823 } else {
824 (format!("deny {effs}{suffix}"), format!("deny {effs} {UNKNOWN}{suffix}"))
825 }
826}
827
828/// The single predicate for a provable-purity hole (eval/fixloop/DISPATCH-NOTE.md): a function that is
829/// `Unknown`, sits in a `pure`/`deny <E>` scope, and PASSES that rule (carries none of its forbidden real
830/// effects) — so its compliance is asserted but not verified (the Unknown could hide the very effect the
831/// rule forbids; the classic case is a fn/closure-injected port). A *real* violation is the gate's job, not
832/// this. Returns the first governing rule under which the function is such a hole, or `None` if it is not
833/// one. Shared by candor-scan's gate note and candor-query's `unverified` so "what a hole is" has ONE
834/// definition — the two paths can never drift (conformance PART 12d pins their agreement).
835///
836/// ⟨0.24⟩ **"PASSES" IS NOW ASKED OF THE GATE, NOT OF A SECOND COPY OF IT.** This predicate computed the
837/// passing test from `r.effects` alone — "does the rule NAME an effect this function has?" — which is
838/// the pre-⟨0.19⟩ question, still being asked after two rungs gave rules a NARROWING FILTER. So on
839/// `deny Unknown[reflect]` over an `indirect` hole the gate TOLERATED (exit 0, the class does not match)
840/// while this read the same rule as violated; and a hole is *by definition* a function that PASSES its
841/// rule while `Unknown`, so the real hole was reclassified as a violation-that-isn't and **deleted from
842/// the disclosure** — `unverified` answering "every function in a pure/deny layer is PROVABLY clean ✓"
843/// over a function the gate had just declined to clear. MEASURED 2026-07-28, and reachable with no alias
844/// in play at all: one layer below the widening `ea0df4f` closed, in the same four verbs.
845///
846/// It now calls [`crate::gate::rule_hits`] — the gate's own firing decision — so the two cannot disagree
847/// again. That needs the function's TRANSITIVE reason classes and its ⟨0.20⟩ destination classes, the
848/// same two accumulators the gate reads, which is why they are parameters now rather than derivable.
849///
850/// **THE DIRECTION IS THE MIRROR ARGUMENT.** A filter can only ever SHRINK what a rule charges, so a
851/// filter-aware pass test can only ever find MORE holes — this cannot silently suppress a disclosure
852/// that used to appear. Pinned in both directions by
853/// `a_narrowed_rule_the_gate_tolerates_is_a_hole_and_the_one_it_fires_on_is_not`, in one run, because
854/// the fixture proving the fabrication is closed cannot show the reach closed with it. A WITHHELD filter
855/// — nothing to read — likewise counts as PASSING here: the gate did not clear the function, and an
856/// advisory note's fail-safe direction is to disclose.
857pub fn unverified_hole_rule<'a, S: AsRef<str>>(
858 name: &str,
859 effects: &[S],
860 reason_classes: Option<&BTreeSet<String>>,
861 net_classes: &[String],
862 rules: &'a [PolicyRule],
863) -> Option<&'a PolicyRule> {
864 if !effects.iter().any(|e| e.as_ref() == UNKNOWN) {
865 return None;
866 }
867 let effs: Vec<&str> = effects.iter().map(AsRef::as_ref).collect();
868 rules.iter().find(|r| {
869 // in the rule's scope (a scopeless rule governs the whole unit) …
870 if let Some(s) = &r.scope {
871 if !scope_matches(name, s) {
872 return false;
873 }
874 }
875 // … and PASSES it — the gate's own answer, narrowing filters and all. Empty `hits` IS passing.
876 crate::gate::rule_hits(r, &effs, reason_classes, net_classes).hits.is_empty()
877 })
878}
879
880/// R443 — THE PROVABLE-PURITY ADVISORY'S LINES, ONE RENDERER FOR BOTH GATE ROUTES.
881///
882/// `candor-scan --policy` printed this note and `candor-query gate --report` printed NOTHING over the
883/// identical tree, the identical rule and the identical verdict (MEASURED 2026-09-15 on
884/// `pub fn via_callback(f: impl Fn()) { f(); }` under `pure via_callback`: route B three lines naming
885/// `deny Unknown via_callback`, route A `policy ✓` and silence). **The silent route is the DEPLOYED
886/// one** — gating a precomputed report is what the query layer is for, so it is what CI runs.
887///
888/// The SET is already single-sourced ([`unverified_hole_rule`], which both routes have always called);
889/// this is the other half — the rendering, which would otherwise be the same three sentences written
890/// twice in two crates, i.e. the R288/R347 shape arriving through the fix for it. Only the `prefix`
891/// differs, because the two routes must name themselves (an operator has to know which command to
892/// re-run). `holes` is `(fn, upgrade)` in the caller's own order — already the shape candor-scan's
893/// `unverified_holes` returns.
894///
895/// ADVISORY: the caller prints these and does NOT touch its verdict or exit code. Whether `pure`/`deny`
896/// should FAIL CLOSED on `Unknown` is a SPEC question (R443's expensive half) and is deliberately not
897/// decided here.
898pub fn unverified_note_lines(prefix: &str, holes: &[(String, String)]) -> Vec<String> {
899 if holes.is_empty() {
900 return Vec::new();
901 }
902 let mut out = vec![format!(
903 "{prefix}: note — {} function(s) PASS the policy but are Unknown (purity NOT verified — the Unknown could hide a forbidden effect):",
904 holes.len()
905 )];
906 for (fq, up) in holes {
907 out.push(format!(" `{fq}` → add `{up}`"));
908 }
909 out.push(
910 " (advisory; add the upgrade(s) to REQUIRE provable purity, or run `candor-query unverified` for detail — the gate verdict is unchanged)"
911 .to_string(),
912 );
913 out
914}
915
916/// Parse a CANDOR_POLICY file (SPEC §6.2). One rule per line; `#` comments and blanks ignored:
917///
918/// ```text
919/// deny Net Db domain # functions whose path contains segment "domain" must not perform Net or Db
920/// deny Exec # no function anywhere may perform Exec
921/// deny Unknown api # functions in "api" must be fully resolvable (forbid the unverifiable)
922/// pure parse # functions whose path contains segment "parse" must be effect-free
923/// allow Net in billing api.stripe.com
924/// forbid domain -> infra
925/// ```
926///
927/// In a `deny` rule, leading tokens that name a known effect (or `Unknown`) are forbidden; the FIRST
928/// non-effect token is the scope and ends the rule. A `deny` naming no known effect is dropped (it is
929/// NOT a `pure` rule). Malformed/unknown lines are ignored with a warning — never silently widened.
930/// The §6.2 token separator: ASCII whitespace ONLY (space/tab/CR/LF/VT/FF). `split_whitespace`/`trim`
931/// use Unicode `White_Space`, which would split a NBSP/ideographic space that Java drops — a gateless-
932/// green cross-engine divergence (adversarial DSL review). A non-ASCII space stays part of its token, so
933/// the rule is malformed and ignored, uniformly.
934fn is_ascii_ws(c: char) -> bool {
935 matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
936}
937
938pub fn parse_policy(text: &str) -> ParsedPolicy {
939 parse_policy_impl(text, true, &std::collections::BTreeMap::new())
940}
941/// As [`parse_policy`] but with `.candor/config` `unknown-alias` definitions (⟨0.19⟩, SPEC §6.2): an
942/// `Unknown[<name>]` filter resolves a user-defined `<name>` to its reason classes. The gate + `parsepolicy`
943/// pass the discovered aliases (via [`parse_unknown_aliases`]); a config alias never changes what bare
944/// `deny E Unknown` means (always `Unknown[*]`), so the rule stays legible from the policy alone.
945pub fn parse_policy_with_aliases(text: &str, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
946 parse_policy_impl(text, true, aliases)
947}
948/// ⟨0.24⟩ [`parse_policy_with_aliases`] but SILENT — for a caller that must inspect
949/// [`ParsedPolicy::errors`] / [`ParsedPolicy::used_aliases`] BEFORE it parses for real (candor-scan
950/// refuses before touching the classifier's accumulators). Silent so the ordinary parse warnings are not
951/// printed twice on the same text.
952pub fn parse_policy_silent(
953 text: &str,
954 aliases: &std::collections::BTreeMap<String, BTreeSet<ReasonClass>>,
955) -> ParsedPolicy {
956 parse_policy_impl(text, false, aliases)
957}
958/// Same as [`parse_policy`] but SILENT about malformed rules — for a SECOND, advisory re-parse within the
959/// same run (candor-scan parses once for the gate check and again for the `unverified` disclosure), so the
960/// CI log doesn't print every "ignoring policy rule …" warning twice (#21). The first parse already warned.
961pub fn parse_policy_quiet(text: &str) -> ParsedPolicy {
962 parse_policy_impl(text, false, &std::collections::BTreeMap::new())
963}
964fn parse_policy_impl(text: &str, warn: bool, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
965 macro_rules! warn_ignore { ($($a:tt)*) => { if warn { eprintln!($($a)*); } } }
966 let mut out = ParsedPolicy::default();
967 // ⟨0.28⟩ The source position of the line under the cursor, for `PolicyError::{line, text}` — bound
968 // BEFORE the macro below so its body (whose free identifiers resolve at definition scope) can read
969 // them; the loop updates both per iteration. §6.2's `ignored` disclosure is `[{line, text, reason}]`.
970 let mut cur_line: usize;
971 let mut cur_text: &str;
972 // ⟨0.24⟩ Record a line the parser did not honour (SPEC §3.1 `195d45a`), on the ONE list `parsepolicy`
973 // reports and the gate routes filter for `fatal`. The stderr sentence and `message` are the SAME
974 // string by construction — a disclosure that can drift from the one beside it is how this family
975 // produced a FALSE disclosure once already (conformance PART 13b).
976 macro_rules! not_honoured {
977 ($fatal:expr, $kind:expr, $token:expr, $accepted:expr, $rule:expr, $msg:expr) => {{
978 let message: String = $msg;
979 out.errors.push(PolicyError {
980 kind: $kind,
981 token: ($token).to_string(),
982 accepted: ($accepted).iter().map(|s: &&str| s.to_string()).collect(),
983 rule: ($rule).to_string(),
984 message,
985 line: cur_line,
986 text: cur_text.to_string(),
987 fatal: $fatal,
988 });
989 }};
990 }
991 // `str::lines()` splits on \n and \r\n but NOT bare \r — a classic-Mac file then collapses to ONE
992 // line, and since \r is also an in-line ASCII-ws token separator (is_ascii_ws), every rule after the
993 // first was glued into the first rule's tokens and dropped (sweep [16], a gateless-green divergence).
994 // Java's Files.readAllLines (the reference) breaks on bare \r too — normalize to match it. Allocation
995 // only when a bare \r is actually present (the overwhelmingly-common \n / \r\n files are untouched).
996 let normalized;
997 let text = if text.contains('\r') {
998 normalized = text.replace("\r\n", "\n").replace('\r', "\n");
999 normalized.as_str()
1000 } else {
1001 text
1002 };
1003 for (line_idx, raw_line) in text.lines().enumerate() {
1004 cur_line = line_idx + 1;
1005 cur_text = raw_line;
1006 let line = raw_line.split('#').next().unwrap_or("").trim_matches(is_ascii_ws);
1007 if line.is_empty() {
1008 continue;
1009 }
1010 let mut toks = line.split(is_ascii_ws).filter(|s| !s.is_empty());
1011 match toks.next().unwrap_or("") {
1012 "allow" => {
1013 let effect = match toks.next().unwrap_or("") {
1014 "Net" => "Net",
1015 // `Llm` ⟨0.13⟩ rides the Net host literal (SPEC §1) — `allow Llm <host…>` restricts which
1016 // MODEL hosts a scope may reach, matched by hostname like Net (its reached surface IS the
1017 // Net host surface). Match candor-java's Policy.parsePolicy.
1018 "Llm" => "Llm",
1019 "Exec" => "Exec",
1020 "Fs" => "Fs",
1021 "Db" => "Db",
1022 other => {
1023 let msg = format!(
1024 "unknown effect-name `{other}` in `allow` (accepted: Db, Exec, Fs, Llm, Net \
1025 \u{2014} `allow` covers only the effects carrying a literal surface: Net/Llm \
1026 hosts, Exec commands, Fs paths, Db tables): {line}"
1027 );
1028 warn_ignore!("candor: policy error — {msg}");
1029 // ⟨0.24⟩ FATAL (SPEC §6.2 `1e1748a`). MEASURED four-way before this:
1030 // `allow Nett host.example` -> exit 0 on rust, ts, java AND swift. The rule is
1031 // DELETED and the certification silently vanishes, so the operator reads an
1032 // armed allowlist that does not exist.
1033 //
1034 // The grammar defence that kept the token rule inside the bracket does NOT
1035 // reach here: `allow`'s effect position is a fixed, closed set with **no scope
1036 // reading available**, so an unrecognised token there is unambiguously a typo
1037 // and there is no legitimate policy it could be. This document already calls a
1038 // dropped rule "the limit case of silently rewritten into a different policy…
1039 // a bigger rewrite than a narrowed filter" — and the bigger rewrite was
1040 // warning-only while the smaller one was already exit 2.
1041 not_honoured!(
1042 true,
1043 PolicyError::KIND_EFFECT_NAME,
1044 other,
1045 ["Db", "Exec", "Fs", "Llm", "Net"],
1046 line,
1047 msg
1048 );
1049 continue;
1050 }
1051 };
1052 let mut rest: Vec<&str> = toks.collect();
1053 let scope = if rest.first() == Some(&"in") {
1054 let s = rest.get(1).map(|s| s.to_string());
1055 rest.drain(..2.min(rest.len()));
1056 s
1057 } else {
1058 None
1059 };
1060 let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
1061 if literals.is_empty() {
1062 let msg = format!("`allow {effect}` names no values: {line}");
1063 warn_ignore!("candor: ignoring policy rule ({msg})");
1064 // `accepted` is EMPTY on purpose: the position takes an open-ended literal (a host, a
1065 // path, a command, a table), so there is no token list to offer. A fact about the
1066 // grammar, not a gap in the report.
1067 not_honoured!(false, PolicyError::KIND_RULE_KIND, "", [], line, msg);
1068 continue;
1069 }
1070 out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
1071 }
1072 "deny" => {
1073 let mut effects = BTreeSet::new();
1074 let mut scope = None;
1075 // Reason-class filter on `Unknown` (REASON-SCOPED-UNKNOWN-DESIGN.md): empty ⇒ `Unknown[*]`
1076 // (any reason — the bare form); non-empty ⇒ only those classes. `*` = all.
1077 let mut unknown_classes: BTreeSet<ReasonClass> = BTreeSet::new();
1078 let mut unknown_star = false;
1079 // Destination-class filter on `Net` (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
1080 // (any destination — the bare form); non-empty ⇒ only those classes. `*` = all.
1081 let mut net_classes: BTreeSet<String> = BTreeSet::new();
1082 let mut net_star = false;
1083 for t in toks {
1084 // `Net[unknown-host]` / `Net[*]` / `Net[known-telemetry,unknown-host]`: the destination-scoped form.
1085 if let Some(inner) = t.strip_prefix("Net[").and_then(|s| s.strip_suffix(']')) {
1086 effects.insert("Net");
1087 for cn in inner.split(',') {
1088 let cn = cn.trim();
1089 if cn.is_empty() {
1090 continue;
1091 }
1092 if cn == "*" {
1093 net_star = true;
1094 } else if crate::NET_DEST_CLASSES.contains(&cn) {
1095 net_classes.insert(cn.to_string());
1096 } else {
1097 // ⟨0.24⟩ A POLICY ERROR, not a warning — SPEC §6.2 `be0b9a9`. Byte-identical
1098 // in shape to the reason-class arm below, and byte-identical in harm:
1099 // MEASURED `deny Net[known-telemetry,unknown-hosst]` → exit 0 where the
1100 // correctly-spelled rule exits 1. The typo is dropped, the filter NARROWS
1101 // to `[known-telemetry]`, and the gate stops covering unidentifiable
1102 // destinations while the operator reads a gate that looks armed.
1103 not_honoured!(
1104 true,
1105 PolicyError::KIND_NET_CLASS,
1106 cn,
1107 ["known-telemetry", "known-partner", "unknown-host", "*"],
1108 line,
1109 format!(
1110 "unrecognised Net destination-class `{cn}` in `{line}` — \
1111 accepted: known-telemetry, known-partner, unknown-host, plus `*`"
1112 )
1113 );
1114 }
1115 }
1116 continue;
1117 }
1118 // `Unknown[dispatch,reflect]` / `Unknown[*]` / `Unknown[dynamic]`: the reason-scoped form.
1119 if let Some(inner) = t.strip_prefix("Unknown[").and_then(|s| s.strip_suffix(']')) {
1120 effects.insert(UNKNOWN);
1121 for cn in inner.split(',') {
1122 let cn = cn.trim();
1123 if cn.is_empty() {
1124 continue;
1125 }
1126 if cn == "*" {
1127 unknown_star = true;
1128 } else if cn == "dynamic" {
1129 unknown_classes.extend(ReasonClass::dynamic_set());
1130 } else if let Some(rc) = ReasonClass::from_token(cn) {
1131 unknown_classes.insert(rc);
1132 } else if let Some(a) = aliases.get(cn) {
1133 unknown_classes.extend(a.iter().copied()); // ⟨0.19⟩ config `unknown-alias`
1134 // ⟨0.24⟩ → the verdict names it AND what it expanded to (SPEC §3.1
1135 // `7f5b5ba`): the NAME alone cannot tell a reader which gate ran.
1136 out.used_aliases
1137 .insert(cn.to_string(), a.iter().map(|c| c.token().to_string()).collect());
1138 } else {
1139 // ⟨0.24⟩ A POLICY ERROR, not a warning — see `ParsedPolicy::errors`. The
1140 // token is still dropped below so `rules` stays well-formed for the
1141 // advisory readers (`unverified`, `parsepolicy`); the gate routes refuse
1142 // on `errors` before any of it is used as a verdict.
1143 not_honoured!(
1144 true,
1145 PolicyError::KIND_REASON_CLASS,
1146 cn,
1147 [
1148 "reflect",
1149 "dispatch",
1150 "indirect",
1151 "native",
1152 "unresolved",
1153 "setup",
1154 "dynamic",
1155 "*"
1156 ],
1157 line,
1158 format!(
1159 "unrecognised reason-class/alias `{cn}` in `{line}` — accepted: \
1160 reflect, dispatch, indirect, native, unresolved, setup, plus the \
1161 aliases `dynamic` and `*`, plus any `unknown-alias` defined in \
1162 the `.candor/config` beside the policy. (⟨0.24⟩ an \
1163 `unknown-alias` whose OWN definition names an unrecognised class \
1164 is refused WHOLE, so a typo in the config surfaces as an \
1165 undefined alias here — check the `unknown-alias` lines too, and \
1166 the line above this one.)"
1167 )
1168 );
1169 }
1170 }
1171 continue;
1172 }
1173 let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
1174 match e {
1175 Some(e) => {
1176 effects.insert(e);
1177 if e == UNKNOWN {
1178 unknown_star = true; // bare Unknown ⇒ all classes
1179 }
1180 if e == "Net" {
1181 net_star = true; // bare Net ⇒ all destinations
1182 }
1183 }
1184 None => {
1185 scope = Some(t.to_string());
1186 break;
1187 }
1188 }
1189 }
1190 if effects.is_empty() {
1191 // The accepted set is the §1 effect vocabulary plus `Unknown` — SORTED, so the
1192 // document is deterministic and diffable across engines.
1193 let mut acc: Vec<&str> = candor_report::EFFECTS.to_vec();
1194 acc.push(UNKNOWN);
1195 acc.sort_unstable();
1196 let msg = format!(
1197 "`deny` names no known effect (accepted: {}): {line}",
1198 acc.join(", ")
1199 );
1200 warn_ignore!("candor: policy error — {msg}");
1201 // ⟨0.24⟩ FATAL (SPEC §6.2 `1e1748a`). MEASURED four-way: `deny Nett app` -> exit 0
1202 // on all four; the rule is DELETED and the gate is green. `Nett` is read as the
1203 // SCOPE (the first unrecognised token ends the effect list), so the line parses to
1204 // a deny of NOTHING.
1205 //
1206 // **A `deny` whose effect list ends up EMPTY is malformed under EITHER reading** —
1207 // typo-in-the-effect or scope-with-no-effect are both nonsense — so there is no
1208 // legitimate policy it could be and refusing it loses nothing. What stays open is
1209 // only the genuinely ambiguous middle (`deny Net Exex app`: at least one valid
1210 // effect plus an unrecognised trailing token that MIGHT be a scope), which the
1211 // parser cannot tell from a legitimate scope and which `parsepolicy` shows either
1212 // way by dumping the `scope` it read.
1213 not_honoured!(
1214 true,
1215 PolicyError::KIND_EFFECT_NAME,
1216 scope.as_deref().unwrap_or(""),
1217 acc,
1218 line,
1219 msg
1220 );
1221 continue;
1222 }
1223 // `*` (or bare `Unknown`) means all classes ⇒ empty filter (matches any Unknown).
1224 if unknown_star {
1225 unknown_classes.clear();
1226 } else if !unknown_classes.is_empty() && !unknown_classes.contains(&ReasonClass::Unresolved) {
1227 // A2 under-gating lint: a narrowed scope that omits `unresolved` (the catch-all for holes
1228 // an engine couldn't classify) may silently tolerate exactly those — flag it (advisory).
1229 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}");
1230 }
1231 // `*` (or bare `Net`) means all destinations ⇒ empty filter (matches any Net).
1232 if net_star {
1233 net_classes.clear();
1234 }
1235 out.rules.push(PolicyRule { effects, scope, unknown_classes, net_classes, raw: line.to_string() });
1236 }
1237 "pure" => out.rules.push(PolicyRule {
1238 effects: BTreeSet::new(),
1239 scope: toks.next().map(str::to_string),
1240 unknown_classes: BTreeSet::new(),
1241 net_classes: BTreeSet::new(),
1242 raw: line.to_string(),
1243 }),
1244 "forbid" => {
1245 let a = toks.next().unwrap_or("");
1246 let arrow = toks.next().unwrap_or("");
1247 let b = toks.next().unwrap_or("");
1248 if a.is_empty() || arrow != "->" || b.is_empty() {
1249 let msg = format!("`forbid` is malformed (want `forbid <scope> -> <scope>`): {line}");
1250 warn_ignore!("candor: ignoring layering rule ({msg})");
1251 // The token reported is whatever sat in the ARROW position — `->` must be its own
1252 // token, so `forbid glued->arrow` finds nothing there and that absence is the finding.
1253 not_honoured!(false, PolicyError::KIND_RULE_KIND, arrow, ["->"], line, msg);
1254 continue;
1255 }
1256 out.layer_rules.push(LayerRule {
1257 from: a.to_string(),
1258 to: b.to_string(),
1259 raw: line.to_string(),
1260 });
1261 }
1262 // ⟨0.29⟩ `only <A> -> <B> [<C> …]` — the PERMISSION form. Everything after the arrow is a
1263 // scope, so the rule takes a LIST where `forbid` takes one destination; that is the whole
1264 // ergonomic difference, and it is why the tail is not read left-to-right for anything else.
1265 "only" => {
1266 let a = toks.next().unwrap_or("");
1267 let arrow = toks.next().unwrap_or("");
1268 let to: Vec<String> = toks.map(str::to_string).collect();
1269 if a.is_empty() || arrow != "->" || to.is_empty() {
1270 let msg = format!(
1271 "`only` is malformed (want `only <scope> -> <scope> [<scope> …]`): {line}"
1272 );
1273 warn_ignore!("candor: ignoring permission rule ({msg})");
1274 // Same witness as `forbid`: whatever sat in the ARROW position, since `->` must be
1275 // its own token and `only glued->arrow` finds nothing there.
1276 not_honoured!(false, PolicyError::KIND_RULE_KIND, arrow, ["->"], line, msg);
1277 continue;
1278 }
1279 out.only_rules.push(OnlyRule {
1280 from: a.to_string(),
1281 to,
1282 raw: line.to_string(),
1283 });
1284 }
1285 other => {
1286 let msg = format!(
1287 "unknown rule kind `{other}` (accepted: allow, deny, forbid, only, pure): {line}"
1288 );
1289 warn_ignore!("candor: ignoring policy rule ({msg})");
1290 not_honoured!(
1291 false,
1292 PolicyError::KIND_RULE_KIND,
1293 other,
1294 ["allow", "deny", "forbid", "only", "pure"],
1295 line,
1296 msg
1297 );
1298 }
1299 }
1300 }
1301 out
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306 /// R443 — the note's bytes, pinned. Both gate routes render through this one function now, so the
1307 /// only thing a route may vary is the PREFIX; anything else drifting is this assertion failing.
1308 /// The strings are the ones candor-scan has printed since ⟨0.19⟩, character for character, because
1309 /// the point of the change was to give the SILENT route the EXISTING disclosure — not a new one.
1310 #[test]
1311 fn unverified_note_lines_render_one_note_for_both_routes() {
1312 let holes = vec![
1313 ("via_callback".to_string(), "deny Unknown via_callback".to_string()),
1314 ("domain::price".to_string(), "deny Net Unknown domain".to_string()),
1315 ];
1316 let scan = super::unverified_note_lines("candor-scan", &holes);
1317 let query = super::unverified_note_lines("candor-query gate", &holes);
1318 assert_eq!(scan.len(), 4, "header + one line per hole + the advisory footer");
1319 assert_eq!(
1320 scan[0],
1321 "candor-scan: note — 2 function(s) PASS the policy but are Unknown (purity NOT verified — the Unknown could hide a forbidden effect):"
1322 );
1323 assert_eq!(scan[1], " `via_callback` → add `deny Unknown via_callback`");
1324 assert_eq!(scan[2], " `domain::price` → add `deny Net Unknown domain`");
1325 assert_eq!(
1326 scan[3],
1327 " (advisory; add the upgrade(s) to REQUIRE provable purity, or run `candor-query unverified` for detail — the gate verdict is unchanged)"
1328 );
1329 // The prefix, and NOTHING else, is what the two routes differ in.
1330 assert_eq!(scan[1..], query[1..]);
1331 assert_eq!(query[0], scan[0].replacen("candor-scan", "candor-query gate", 1));
1332 // A clean pass renders NO lines at all — the over-charge control, at the renderer.
1333 assert!(super::unverified_note_lines("candor-scan", &[]).is_empty());
1334 }
1335
1336 #[test]
1337 fn db_table_covering_is_strict() {
1338 use super::db_table_covered as c;
1339 assert!(c("ledger.entries", "Ledger.Entries")); // case-insensitive exact
1340 assert!(c("ledger.*", "ledger.entries")); // schema wildcard
1341 assert!(!c("ledger.*", "ledgerx.entries")); // boundary-respecting
1342 assert!(!c("entries", "ledger.entries")); // no silent qualification widening
1343 assert!(c("entries", "entries"));
1344 }
1345
1346 #[test]
1347 fn allow_db_parses_and_gates() {
1348 let p = super::parse_policy("allow Db in billing ledger.* customers\n");
1349 assert_eq!(p.allow_rules.len(), 1);
1350 assert_eq!(p.allow_rules[0].effect, "Db");
1351 assert!(super::literal_allowed("Db", "ledger.entries", &p.allow_rules[0].literals));
1352 assert!(super::literal_allowed("Db", "customers", &p.allow_rules[0].literals));
1353 assert!(!super::literal_allowed("Db", "audit.log", &p.allow_rules[0].literals));
1354 }
1355
1356 use super::*;
1357
1358 #[test]
1359 fn policy_parses() {
1360 let p = parse_policy(
1361 "# the domain layer must stay pure of I/O\n\
1362 deny Net Db domain\n\
1363 deny Exec\n\
1364 pure parse\n\
1365 nonsense line\n\
1366 deny notaneffect\n",
1367 );
1368 let rules = &p.rules;
1369 assert_eq!(rules.len(), 3);
1370 assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
1371 assert_eq!(rules[0].scope.as_deref(), Some("domain"));
1372 assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
1373 assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
1374 // sweep [16]: a classic-Mac (bare \r) multi-rule policy must NOT collapse to the first rule.
1375 let cr = parse_policy("deny Net a\rdeny Exec b\rdeny Db c\r");
1376 assert_eq!(cr.rules.len(), 3, "bare-CR lines must each parse");
1377 assert!(cr.rules.iter().any(|r| r.effects.contains("Exec") && r.scope.as_deref() == Some("b")));
1378 // mixed \r\n and bare \r normalize identically.
1379 assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
1380 // `Unknown` is a denyable token; a bare `deny` with no effect is ignored.
1381 assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
1382 assert!(parse_policy("deny\ndeny \n").rules.is_empty());
1383 // a `deny` whose first token is a non-effect names no effect -> dropped, NOT a pure rule.
1384 assert!(parse_policy("deny notaneffect scope").rules.is_empty());
1385 // the first non-effect token ENDS the rule: a later effect token is not collected.
1386 let p2 = parse_policy("deny Net foo Db");
1387 assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
1388 assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
1389 // NBSP is NOT a token separator (only ASCII White_Space is) — pinned to MATCH Java, which
1390 // drops it: a `deny\u{a0}Net` is one token `deny\u{a0}Net`, NOT `deny` + `Net`, so it names no
1391 // known effect and is dropped. Splitting on Unicode whitespace here would let candor see a deny
1392 // the JVM engine doesn't — a gateless-divergence between impls. (See is_ascii_ws.)
1393 assert!(parse_policy("deny\u{a0}Net core").rules.is_empty(),
1394 "an NBSP between deny and the effect must NOT split into separate tokens");
1395 // The NBSP rides INTO the scope token rather than separating it: `deny Net\u{a0}domain` is
1396 // `deny` + `Net` + `\u{a0}domain` — Net is the effect, the scope keeps the NBSP verbatim.
1397 let nb = parse_policy("deny Net \u{a0}domain");
1398 assert_eq!(nb.rules.len(), 1);
1399 assert_eq!(nb.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
1400 assert_eq!(nb.rules[0].scope.as_deref(), Some("\u{a0}domain"));
1401 }
1402
1403 #[test]
1404 fn reason_scoped_unknown_parses() {
1405 use super::ReasonClass::*;
1406 // `Unknown[dispatch,indirect]` narrows the Unknown membership to those classes.
1407 let p = parse_policy("deny Net Unknown[dispatch,indirect] dom\n");
1408 let r = &p.rules[0];
1409 assert!(r.effects.contains("Unknown") && r.effects.contains("Net"));
1410 assert_eq!(r.scope.as_deref(), Some("dom"));
1411 assert_eq!(r.unknown_classes, [Dispatch, Indirect].into_iter().collect());
1412 // bare `Unknown` and `Unknown[*]` ⇒ empty filter (all classes).
1413 assert!(parse_policy("deny Net Unknown dom\n").rules[0].unknown_classes.is_empty(), "bare Unknown ⇒ all");
1414 assert!(parse_policy("deny Net Unknown[*] dom\n").rules[0].unknown_classes.is_empty(), "Unknown[*] ⇒ all");
1415 // `dynamic` alias = every genuine class incl. unresolved, excl. setup.
1416 assert_eq!(
1417 parse_policy("deny Net Unknown[dynamic] dom\n").rules[0].unknown_classes,
1418 [Reflect, Dispatch, Indirect, Native, Unresolved].into_iter().collect()
1419 );
1420 // config `unknown-alias` (⟨0.19⟩): a user-defined name resolves; a reserved name is rejected.
1421 let aliases = super::parse_unknown_aliases(
1422 "unknown-alias risky = reflect,native\nunknown-alias telemetry = indirect\nunknown-alias reflect = native\n");
1423 assert_eq!(aliases.get("risky"), Some(&[Reflect, Native].into_iter().collect()));
1424 assert_eq!(aliases.get("telemetry"), Some(&[Indirect].into_iter().collect()));
1425 assert!(!aliases.contains_key("reflect"), "a config alias may not shadow a class token");
1426 // the `unknown-alias` KEY matches case-insensitively (parity with java/ts/swift, which lowercase it)
1427 assert_eq!(super::parse_unknown_aliases("Unknown-Alias hot = native\n").get("hot"),
1428 Some(&[Native].into_iter().collect()), "the unknown-alias key must match case-insensitively");
1429 let pr = super::parse_policy_with_aliases("deny Net Unknown[risky] api\n", &aliases);
1430 assert_eq!(pr.rules[0].unknown_classes, [Reflect, Native].into_iter().collect());
1431 // an UNDEFINED alias name is dropped-with-warning → empty filter (behaves like bare Unknown[*])
1432 assert!(super::parse_policy_with_aliases("deny Net Unknown[nope] api\n", &aliases).rules[0].unknown_classes.is_empty());
1433 // classify: raw reason tokens → normative classes (mirrors java ReasonClass.classify).
1434 assert_eq!(ReasonClass::classify("reflect:Class.forName"), Reflect);
1435 assert_eq!(ReasonClass::classify("native:extern fn"), Native);
1436 assert_eq!(ReasonClass::classify("callback:unresolved call"), Indirect);
1437 assert_eq!(ReasonClass::classify("ambiguous:same-name local defs"), Dispatch);
1438 assert_eq!(ReasonClass::classify("unresolved"), Unresolved);
1439 assert_eq!(ReasonClass::classify("whatever-new"), Unresolved); // conservative catch-all
1440 }
1441
1442 /// THE CONTROL SPEC §4 ⟨0.24⟩ MAKES A SHOULD: a FABRICATED, off-vocabulary kind must still behave as
1443 /// §2 forward-compatibility requires. Without it, "added a fifth kind" and "stopped checking the kind
1444 /// set" are the same diff — the classifier is one `_ =>` arm away from either.
1445 ///
1446 /// This engine holds the §4 vocabulary ONCE (the raw `kind:detail` string, read back only through
1447 /// `classify`), so there is no typed half here to drift from it. That is why the JVM engine's failure
1448 /// — a string classifier correct on `ambiguous` since July while its typed `Kind` enum lacked the kind
1449 /// entirely, one token classified two ways inside one engine — is not reproducible here. If a typed
1450 /// kind representation is ever added, this test is where its half gets its control.
1451 #[test]
1452 fn off_vocabulary_kinds_round_trip_and_classify_through_the_catch_all() {
1453 use ReasonClass::*;
1454 // A kind no engine emits and no section names. §2: tolerated, and classified CONSERVATIVELY —
1455 // `unresolved`, the catch-all, so a narrowed `Unknown[unresolved]`/`[dynamic]`/`[*]` still bites it.
1456 assert_eq!(ReasonClass::classify("banana:whatever"), Unresolved);
1457 assert_eq!(ReasonClass::classify("banana:dispatch of a banana"), Unresolved,
1458 "a canonical kind appearing in the DETAIL must not leak into the classification");
1459 // …and it must not be swallowed into a narrower class. These are the four wrong answers.
1460 for wrong in [Reflect, Dispatch, Indirect, Native] {
1461 assert_ne!(ReasonClass::classify("banana:whatever"), wrong);
1462 }
1463 // The five §4 ⟨0.24⟩ kinds all classify, and `ambiguous` is the fifth — pinned beside the
1464 // fabricated one deliberately: one arm chain answers both, so a change that stops distinguishing
1465 // them fails here rather than in the field.
1466 assert_eq!(ReasonClass::classify("reflect:x"), Reflect);
1467 assert_eq!(ReasonClass::classify("native:x"), Native);
1468 assert_eq!(ReasonClass::classify("dispatch:Owner.member"), Dispatch);
1469 assert_eq!(ReasonClass::classify("callback:x"), Indirect);
1470 assert_eq!(ReasonClass::classify("ambiguous:x"), Dispatch);
1471 // ⟨0.24⟩ `dep:<hash>` / `dep-stale:<pkg>` are REGISTERED §4 kinds, not migration ones — swift
1472 // emits them per dependency ENTRY, and this engine CONSUMES swift/ts reports through
1473 // candor-query. §6.2 pins their class as `unresolved`, which is where the catch-all lands them;
1474 // pinned so a future prefix arm cannot move them without saying so.
1475 assert_eq!(ReasonClass::classify("dep:9f2c1a"), Unresolved);
1476 assert_eq!(ReasonClass::classify("dep-stale:somepkg"), Unresolved);
1477 }
1478
1479 #[test]
1480 fn net_destination_class_parses_and_classifies() {
1481 // `Net[unknown-host,known-telemetry]` narrows the Net membership to those destination classes.
1482 let p = parse_policy("deny Net[unknown-host,known-telemetry] dom\n");
1483 let r = &p.rules[0];
1484 assert!(r.effects.contains("Net"));
1485 assert_eq!(r.scope.as_deref(), Some("dom"));
1486 assert_eq!(
1487 r.net_classes,
1488 ["unknown-host", "known-telemetry"].iter().map(|s| s.to_string()).collect()
1489 );
1490 // bare `Net` and `Net[*]` ⇒ empty filter (all destinations).
1491 assert!(parse_policy("deny Net dom\n").rules[0].net_classes.is_empty(), "bare Net ⇒ all");
1492 assert!(parse_policy("deny Net[*] dom\n").rules[0].net_classes.is_empty(), "Net[*] ⇒ all");
1493 // an unknown destination-class is dropped-with-warning → empty filter (behaves like bare Net[*]).
1494 assert!(parse_policy("deny Net[nope] dom\n").rules[0].net_classes.is_empty());
1495 // the classifier: telemetry (subdomain-aware), model host, unresolved, and the config-partner path.
1496 let no_partners = BTreeSet::new();
1497 assert_eq!(crate::net_dest_class("sentry.io", &no_partners), "known-telemetry");
1498 assert_eq!(crate::net_dest_class("us.i.posthog.com", &no_partners), "known-telemetry"); // 0.20.1 corpus-grown
1499 assert_eq!(crate::net_dest_class("o1.ingest.sentry.io", &no_partners), "known-telemetry");
1500 assert_eq!(crate::net_dest_class("api.openai.com", &no_partners), "known-partner", "a model host is known-partner");
1501 assert_eq!(crate::net_dest_class("evil.example.com", &no_partners), "unknown-host");
1502 let partners: BTreeSet<String> = ["api.stripe.com".to_string()].into_iter().collect();
1503 assert_eq!(crate::net_dest_class("api.stripe.com", &partners), "known-partner", "config-declared partner");
1504 assert_eq!(crate::net_dest_class("api.stripe.com", &no_partners), "unknown-host", "partner is config-only");
1505 // `net-partner` config parsing: host-normalized, case-insensitive key, multi-value.
1506 let pset = super::parse_net_partners("net-partner Api.Stripe.com:443\nNET-PARTNER hooks.stripe.com\n");
1507 assert!(pset.contains("api.stripe.com") && pset.contains("hooks.stripe.com"));
1508 }
1509
1510 #[test]
1511 fn allowlist_parses() {
1512 let p = parse_policy(
1513 "allow Net in billing api.stripe.com hooks.stripe.com\n\
1514 allow Exec in ci git\n\
1515 allow Fs in config /etc/app\n\
1516 allow Net github.com\n\
1517 allow Clock whatever\n\
1518 allow Net in nohosts\n\
1519 allow\n",
1520 );
1521 assert_eq!(p.allow_rules.len(), 4); // Clock carries no literal surface — rejected; Db now does
1522 assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
1523 assert_eq!(
1524 p.allow_rules[0].literals,
1525 ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
1526 );
1527 assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
1528 assert!(p.allow_rules[1].literals.contains("git"));
1529 assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
1530 assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
1531
1532 let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
1533 assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
1534 // IPv6: a bare literal is matched WHOLE (no first-colon collapse), so a different address in the
1535 // same block is NOT accepted; a bracketed `[host]:port` matches the bare host. (/code-review.)
1536 assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
1537 assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
1538 assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
1539 assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
1540 assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
1541 assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
1542 assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
1543 assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
1544 assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
1545 assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
1546 assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
1547 assert_eq!(cmd_base("/usr/bin/git"), "git");
1548 }
1549
1550 #[test]
1551 fn layering_rule_parses() {
1552 let p = parse_policy(
1553 "forbid domain -> infra\n\
1554 forbid app::web -> app::db \n\
1555 forbid domain infra\n\
1556 forbid domain ->\n\
1557 forbid\n",
1558 );
1559 assert_eq!(p.layer_rules.len(), 2);
1560 assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
1561 assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
1562 }
1563
1564 /// ⟨0.29⟩ `only <A> -> <B> [<C> …]` — the PERMISSION form. The tail is a LIST, which is the whole
1565 /// ergonomic difference from `forbid`; a missing arrow or an EMPTY tail is dropped rather than read
1566 /// as "A may reach nothing", a rule far likelier to be a typo than an intention.
1567 #[test]
1568 fn parses_the_only_permission_form_and_drops_the_malformed() {
1569 let p = parse_policy(
1570 "only model -> util\n\
1571 only app::web -> app::db app::dto \n\
1572 only model util\n\
1573 only model ->\n\
1574 only\n",
1575 );
1576 assert_eq!(p.only_rules.len(), 2, "the two well-formed lines, and only those");
1577 assert_eq!(p.only_rules[0].from.as_str(), "model");
1578 assert_eq!(p.only_rules[0].to, vec!["util".to_string()]);
1579 assert_eq!(p.only_rules[1].from.as_str(), "app::web");
1580 assert_eq!(p.only_rules[1].to, vec!["app::db".to_string(), "app::dto".to_string()],
1581 "every token after the arrow is a permitted scope");
1582 // …and an `only` line is a RULE for the zero-rule refusal: a policy holding one is ARMED, so a
1583 // route that counted only deny/allow/forbid would call this file empty and refuse a live gate.
1584 assert!(!p.rules.is_empty() || !p.only_rules.is_empty(),
1585 "an only-only policy must not read as a zero-rule file");
1586 }
1587
1588 #[test]
1589 fn scope_matches_by_segment_not_substring() {
1590 assert!(scope_matches("app::domain::handle", "domain"));
1591 assert!(scope_matches("domain::handle", "domain"));
1592 assert!(scope_matches("app::domain", "domain"));
1593 assert!(scope_matches("crate::domain_logic", "domain"));
1594 assert!(!scope_matches("app::subdomain::handle", "domain"));
1595 assert!(!scope_matches("app::not_my_domain::f", "domain"));
1596 // multi-segment: intermediates exact, last is a prefix, contiguous.
1597 assert!(scope_matches("crate::net::client::send", "net::client"));
1598 assert!(scope_matches("crate::net::client_pool::get", "net::client"));
1599 assert!(!scope_matches("crate::net::server::send", "net::client"));
1600 assert!(!scope_matches("crate::network::client::send", "net::client"));
1601 assert!(!scope_matches("crate::net::x::client", "net::client"));
1602 assert!(!scope_matches("net", "net::client"));
1603 // DOTTED names (JVM/Swift/TS reports `candor-query` consumes): a scope must match across `.` too,
1604 // else a scoped deny/pure rule is silently inert → whatif false-green (gate-evasion). Both a
1605 // `.`-written and a `::`-written scope must match a dotted name.
1606 assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
1607 assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
1608 assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
1609 assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
1610 assert!(!scope_matches("com.acme.subdomain.h", "domain"));
1611 assert!(!scope_matches("com.acme.domain.h", "infra"));
1612 }
1613
1614 #[test]
1615 fn fs_path_covered_respects_boundaries() {
1616 assert!(fs_path_covered("/etc/app", "/etc/app"));
1617 assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
1618 assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
1619 assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
1620 assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
1621 assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
1622 assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
1623 assert!(fs_path_covered("/", "/etc/app/x"));
1624 assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
1625 assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
1626 assert!(fs_path_covered("etc/app", "etc/app/cfg"));
1627 }
1628
1629 /// ⟨0.24⟩ THE PROVABLE-PURITY DISCLOSURE MUST ASK THE GATE WHAT "PASSES" MEANS — SPEC §6.2, and both
1630 /// directions in ONE test, because killing an over-charge is exactly where a silent under-report gets
1631 /// introduced and the fixture proving the fabrication is closed cannot show the reach closed with it.
1632 ///
1633 /// A hole is a function that PASSES its rule while `Unknown`. `unverified_hole_rule` used to compute
1634 /// PASSES from `r.effects` alone — the pre-⟨0.19⟩ question, asked after two rungs gave rules a
1635 /// NARROWING FILTER — so a rule the gate TOLERATES was read here as violated and the hole was DELETED
1636 /// from the disclosure. MEASURED 2026-07-28: `deny Unknown[reflect]` over an `indirect` hole → gate
1637 /// exit 0, `unverified` "every function in a pure/deny layer is PROVABLY clean ✓".
1638 ///
1639 /// ROW 1 (the fix) — the filter does NOT match, so the gate tolerates and this IS a hole.
1640 /// ROW 2 (the mirror) — the SAME rule, SAME function, filter spelled to MATCH: the gate fires, so it
1641 /// is a violation and NOT a hole. Without row 2 the fix is satisfied by a predicate that calls
1642 /// everything a hole, which is the mirror over-report of the thing being fixed.
1643 /// ROW 3 — the ⟨0.20⟩ `Net[dest…]` filter, the same shape on the other narrowing axis.
1644 /// ROW 4 — no filter at all: byte-identical to pre-⟨0.24⟩, which is what keeps conformance PARTs
1645 /// 12c/12d (four-way) from moving.
1646 #[test]
1647 fn a_narrowed_rule_the_gate_tolerates_is_a_hole_and_the_one_it_fires_on_is_not() {
1648 let effs = ["Unknown"];
1649 let indirect: BTreeSet<String> = ["indirect".to_string()].into_iter().collect();
1650 let hole = |src: &str, classes: Option<&BTreeSet<String>>, nets: &[String]| -> Option<String> {
1651 let p = parse_policy(src);
1652 unverified_hole_rule("app::go", &effs, classes, nets, &p.rules).map(|r| rule_and_upgrade(r).1)
1653 };
1654
1655 // ROW 1 — tolerated by the gate (indirect ∉ {reflect}) ⇒ a hole, and the upgrade WIDENS the
1656 // filter rather than appending a second `Unknown`.
1657 assert_eq!(
1658 hole("deny Unknown[reflect]\n", Some(&indirect), &[]).as_deref(),
1659 Some("deny Unknown"),
1660 "a rule the gate TOLERATES leaves the function unproven — that is the disclosure's whole subject"
1661 );
1662
1663 // ROW 2 — THE MIRROR. Same rule, same signature, filter spelled to match: the gate FIRES, so this
1664 // is a violation and the disclosure must stay silent about it.
1665 assert_eq!(
1666 hole("deny Unknown[indirect]\n", Some(&indirect), &[]),
1667 None,
1668 "a rule the gate FIRES on is a violation, not a hole — filter-awareness must not start \
1669 disclosing the gate's own findings back as unproven passes"
1670 );
1671 assert_eq!(hole("deny Unknown[dynamic]\n", Some(&indirect), &[]), None, "`dynamic` covers indirect");
1672
1673 // ROW 3 — the ⟨0.20⟩ destination filter, both ways, on a fn carrying Net BESIDE its Unknown.
1674 let netfn = ["Net".to_string(), "Unknown".to_string()];
1675 let telemetry = vec!["known-telemetry".to_string()];
1676 let p = parse_policy("deny Net[unknown-host]\n");
1677 assert_eq!(
1678 unverified_hole_rule("app::go", &netfn, Some(&indirect), &telemetry, &p.rules)
1679 .map(|r| rule_and_upgrade(r).1)
1680 .as_deref(),
1681 Some("deny Net[unknown-host] Unknown"),
1682 "a `Net[dest…]` the fn's destinations do not match is tolerated, so the Unknown beside it is a hole"
1683 );
1684 let p = parse_policy("deny Net[known-telemetry]\n");
1685 assert_eq!(
1686 unverified_hole_rule("app::go", &netfn, Some(&indirect), &telemetry, &p.rules).map(|r| r.raw.clone()),
1687 None,
1688 "MIRROR: the matching destination filter FIRES, so it is a violation and not a hole"
1689 );
1690
1691 // ROW 4 — UNFILTERED, unchanged. `deny Unknown` fires on every Unknown (never a hole); `pure` and
1692 // `deny Fs` pass a fn with no real effect (always a hole) — the forms PARTs 12c/12d pin four-way.
1693 assert_eq!(hole("deny Unknown\n", Some(&indirect), &[]), None);
1694 assert_eq!(hole("pure\n", Some(&indirect), &[]).as_deref(), Some("deny Unknown"));
1695 assert_eq!(hole("deny Net Db domain\ndeny Fs\n", Some(&indirect), &[]).as_deref(), Some("deny Fs Unknown"));
1696 assert_eq!(
1697 hole("deny Net Db go\n", Some(&indirect), &[]).as_deref(),
1698 Some("deny Db Net Unknown go"),
1699 "the sorted multi-effect upgrade PART 12c-deny pins in all four engines"
1700 );
1701
1702 // A WITHHELD filter — no class set to read — counts as PASSING, so the hole is disclosed rather
1703 // than dropped. The gate withholds there too; between an advisory note that speaks and one that
1704 // goes quiet over a rule that never ran, only the first stays true.
1705 assert_eq!(hole("deny Unknown[reflect]\n", None, &[]).as_deref(), Some("deny Unknown"));
1706 }
1707}
1708
1709// ── ⟨0.27⟩ SPEC §3.4 `engine` — the engine↔baseline coupling ─────────────────────────────────────
1710/// What an `engine` pin says about the build that is running. Data, not a print-and-exit, so every
1711/// branch is testable — including the two that MUST NOT change the exit code.
1712#[derive(Debug, PartialEq, Eq)]
1713pub enum PinVerdict {
1714 /// No pin, or a pin qualified for another implementation. Today's behaviour, exactly.
1715 Absent,
1716 Match,
1717 /// A different version — the engine↔baseline coupling is broken. Exit 2 (UNEVALUABLE, never 1).
1718 Mismatch,
1719 /// Present but unreadable (`engine latest`, a bare `engine`, trailing junk). Exit 2: a pin that
1720 /// cannot be read is a guard the operator believes is on. This is the one place §6.2's
1721 /// warn-and-skip posture INVERTS — skipping a key that ADDS something costs that key; skipping a
1722 /// PIN costs the guard.
1723 Malformed,
1724 /// Well-formed, and this build cannot state its own release. UNANSWERABLE — §3.1's rule applies:
1725 /// disclosed, never scored, INCLUDING as satisfied.
1726 Undetermined,
1727}
1728
1729/// The pin that applies to `impl_name` — the qualified form wins over the unqualified one, and the
1730/// LAST occurrence wins (matching candor-java's map semantics). Two lines that DISAGREE about the same
1731/// key return a value that cannot parse, so they surface as [`PinVerdict::Malformed`] rather than one
1732/// silently discarding the other: two lines disagreeing about which engine to run is not a preference
1733/// to resolve, it is a question the config leaves unanswered.
1734pub fn engine_pin_for(text: &str, impl_name: &str) -> Option<String> {
1735 const IMPLS: [&str; 5] = ["java", "rust", "ts", "swift", "agents"];
1736 let (mut wild, mut qual): (Option<String>, Option<String>) = (None, None);
1737 let mut bad = false;
1738 for raw in text.lines() {
1739 let line = raw.split('#').next().unwrap_or("").trim();
1740 if line.is_empty() {
1741 continue;
1742 }
1743 let mut it = line.split_whitespace();
1744 if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("engine")) {
1745 continue;
1746 }
1747 let rest: Vec<&str> = it.collect();
1748 let slot = |cur: &mut Option<String>, v: String| {
1749 if cur.as_ref().is_some_and(|p| *p != v) {
1750 *cur = Some(format!("{} / {v}", cur.as_ref().unwrap()));
1751 } else {
1752 *cur = Some(v);
1753 }
1754 };
1755 // A KNOWN QUALIFIER DECIDES OWNERSHIP BEFORE ARITY. Checking the one-token case first made `engine swift` a WILDCARD pin whose version is the literal "swift" -> MALFORMED -> exit 2 in every engine, so one operator forgetting a version on a qualified line killed the whole family. SPEC 3.4 says the skip is whole-line 'whatever follows it' -- and nothing following it is a case of that too.
1756 if let Some(head) = rest.first() {
1757 if IMPLS.contains(&head.to_ascii_lowercase().as_str()) {
1758 if head.eq_ignore_ascii_case(impl_name) {
1759 if rest.len() == 2 { slot(&mut qual, rest[1].to_string()); } else { bad = true; }
1760 }
1761 continue; // another impl's line, whatever follows it
1762 }
1763 }
1764 match rest.len() {
1765 0 => bad = true, // a bare `engine` line
1766 1 => slot(&mut wild, rest[0].to_string()), // engine <version>
1767 _ => bad = true, // trailing junk / unknown qualifier
1768 }
1769 }
1770 if bad {
1771 return Some("<unreadable>".to_string());
1772 }
1773 // AN UNREADABLE UNQUALIFIED LINE IS NOT HIDDEN BY A QUALIFIED PIN. `qual ?? wild` returned the qua
1774 // lified value, so `engine garbage` beside a good qualified line passed SILENTLY here while candor-java ex
1775 // ited 2 — the exact mirror of the bug just fixed in java, four engines the other way. Unreadability is a property of the LINE; precedence only decides which VERSION applies.
1776 if let Some(w) = &wild {
1777 if normalize_version(w).is_none() { return Some(w.clone()); }
1778 }
1779 qual.or(wild)
1780}
1781
1782/// [`PinVerdict`] for `pin` against `running`. Pure: no printing, no exit.
1783pub fn pin_verdict(pin: Option<&str>, running: &str) -> PinVerdict {
1784 let Some(pin) = pin else { return PinVerdict::Absent };
1785 let Some(want) = normalize_version(pin) else { return PinVerdict::Malformed };
1786 if running.trim().is_empty() || running == "unknown" {
1787 return PinVerdict::Undetermined;
1788 }
1789 if want == normalize_version(running).unwrap_or_else(|| running.trim().to_string()) {
1790 PinVerdict::Match
1791 } else {
1792 PinVerdict::Mismatch
1793 }
1794}
1795
1796/// A pin token → its comparable form, or None when it is not a version at all. A leading `v` is
1797/// optional (the GitHub-tag `v0.27.0` and the crate `0.27.0` are the same pin) and a two-part `0.27`
1798/// means `0.27.0`. Anything else — `latest`, a branch name — is MALFORMED rather than a version that
1799/// can never match: the difference decides whether the operator reads "wrong version" or "that is not
1800/// a version".
1801fn normalize_version(raw: &str) -> Option<String> {
1802 let s = raw.trim().strip_prefix(['v', 'V']).unwrap_or_else(|| raw.trim());
1803 let parts: Vec<&str> = s.split('.').collect();
1804 if !(parts.len() == 2 || parts.len() == 3) || !parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) {
1805 return None;
1806 }
1807 Some(if parts.len() == 2 { format!("{s}.0") } else { s.to_string() })
1808}