1use crate::cap_from_name;
12use std::collections::BTreeSet;
13
14pub const UNKNOWN: &str = "Unknown";
16
17#[derive(Debug, Clone)]
20pub struct PolicyRule {
21 pub effects: BTreeSet<&'static str>,
22 pub scope: Option<String>,
23 pub raw: String,
24}
25
26#[derive(Debug, Clone)]
31pub struct AllowRule {
32 pub effect: &'static str,
33 pub scope: Option<String>,
34 pub literals: BTreeSet<String>,
35 pub raw: String,
36}
37
38#[derive(Debug, Clone)]
41pub struct LayerRule {
42 pub from: String,
43 pub to: String,
44 pub raw: String,
45}
46
47#[derive(Default, Debug)]
49pub struct ParsedPolicy {
50 pub rules: Vec<PolicyRule>,
51 pub allow_rules: Vec<AllowRule>,
52 pub layer_rules: Vec<LayerRule>,
53}
54
55pub fn host_part(h: &str) -> &str {
61 if let Some(rest) = h.strip_prefix('[') {
62 return rest.split(']').next().unwrap_or(rest);
64 }
65 if h.matches(':').count() > 1 {
66 return h; }
68 h.split(':').next().unwrap_or(h)
69}
70
71pub fn cmd_base(c: &str) -> &str {
73 c.rsplit(['/', '\\']).next().unwrap_or(c)
74}
75
76pub fn fs_path_covered(a: &str, r: &str) -> bool {
81 if r.split(['/', '\\']).any(|c| c == "..") {
82 return false;
83 }
84 let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
85 if absolute(a) != absolute(r) {
86 return false;
87 }
88 let norm = |s: &str| -> Vec<String> {
89 s.split(['/', '\\'])
90 .filter(|c| !c.is_empty() && *c != ".")
91 .map(|c| c.to_string())
92 .collect()
93 };
94 let (ac, rc) = (norm(a), norm(r));
95 ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
96}
97
98pub fn db_table_covered(a: &str, r: &str) -> bool {
104 let (a, r) = (a.to_lowercase(), r.to_lowercase());
105 if let Some(schema) = a.strip_suffix(".*") {
106 return r.strip_prefix(schema).is_some_and(|rest| rest.starts_with('.'));
107 }
108 a == r
109}
110
111pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
115 match effect {
116 "Net" | "Llm" => allow.iter().any(|a| host_part(a) == host_part(reached)),
118 "Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
119 "Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
120 "Db" => allow.iter().any(|a| db_table_covered(a, reached)),
121 _ => allow.contains(reached),
122 }
123}
124
125fn name_segments(s: &str) -> Vec<&str> {
133 s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
134}
135
136pub fn scope_matches(name: &str, scope: &str) -> bool {
141 let segs = name_segments(name);
142 let parts = name_segments(scope);
143 if parts.is_empty() || parts.len() > segs.len() {
144 return false;
145 }
146 let (last, init) = parts.split_last().unwrap();
147 segs.windows(parts.len()).any(|w| {
148 let (w_last, w_init) = w.split_last().unwrap();
149 w_init == init && w_last.starts_with(last)
150 })
151}
152
153pub fn rule_and_upgrade(r: &PolicyRule) -> (String, String) {
158 let scope = r.scope.clone().unwrap_or_default();
159 let suffix = if scope.is_empty() { String::new() } else { format!(" {scope}") };
160 if r.effects.is_empty() {
161 (format!("pure{suffix}"), format!("deny Unknown{suffix}"))
163 } else {
164 let effs = r.effects.iter().copied().collect::<Vec<_>>().join(" ");
165 (format!("deny {effs}{suffix}"), format!("deny {effs} Unknown{suffix}"))
166 }
167}
168
169pub fn unverified_hole_rule<'a, S: AsRef<str>>(
177 name: &str,
178 effects: &[S],
179 rules: &'a [PolicyRule],
180) -> Option<&'a PolicyRule> {
181 if !effects.iter().any(|e| e.as_ref() == UNKNOWN) {
182 return None;
183 }
184 rules.iter().find(|r| {
185 if let Some(s) = &r.scope {
187 if !scope_matches(name, s) {
188 return false;
189 }
190 }
191 let violates = if r.effects.is_empty() {
193 effects.iter().any(|e| e.as_ref() != UNKNOWN)
194 } else {
195 effects.iter().any(|e| r.effects.contains(e.as_ref()))
196 };
197 !violates
198 })
199}
200
201fn is_ascii_ws(c: char) -> bool {
220 matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
221}
222
223pub fn parse_policy(text: &str) -> ParsedPolicy {
224 parse_policy_impl(text, true)
225}
226pub fn parse_policy_quiet(text: &str) -> ParsedPolicy {
230 parse_policy_impl(text, false)
231}
232fn parse_policy_impl(text: &str, warn: bool) -> ParsedPolicy {
233 macro_rules! warn_ignore { ($($a:tt)*) => { if warn { eprintln!($($a)*); } } }
234 let mut out = ParsedPolicy::default();
235 let normalized;
241 let text = if text.contains('\r') {
242 normalized = text.replace("\r\n", "\n").replace('\r', "\n");
243 normalized.as_str()
244 } else {
245 text
246 };
247 for raw_line in text.lines() {
248 let line = raw_line.split('#').next().unwrap_or("").trim_matches(is_ascii_ws);
249 if line.is_empty() {
250 continue;
251 }
252 let mut toks = line.split(is_ascii_ws).filter(|s| !s.is_empty());
253 match toks.next().unwrap_or("") {
254 "allow" => {
255 let effect = match toks.next().unwrap_or("") {
256 "Net" => "Net",
257 "Llm" => "Llm",
261 "Exec" => "Exec",
262 "Fs" => "Fs",
263 "Db" => "Db",
264 _ => {
265 warn_ignore!(
266"candor: ignoring policy rule (allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables): {line}"
267 );
268 continue;
269 }
270 };
271 let mut rest: Vec<&str> = toks.collect();
272 let scope = if rest.first() == Some(&"in") {
273 let s = rest.get(1).map(|s| s.to_string());
274 rest.drain(..2.min(rest.len()));
275 s
276 } else {
277 None
278 };
279 let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
280 if literals.is_empty() {
281 warn_ignore!("candor: ignoring policy rule (allow {effect} names no values): {line}");
282 continue;
283 }
284 out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
285 }
286 "deny" => {
287 let mut effects = BTreeSet::new();
288 let mut scope = None;
289 for t in toks {
290 let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
291 match e {
292 Some(e) => {
293 effects.insert(e);
294 }
295 None => {
296 scope = Some(t.to_string());
297 break;
298 }
299 }
300 }
301 if effects.is_empty() {
302 warn_ignore!("candor: ignoring policy rule (no known effect named): {line}");
303 continue;
304 }
305 out.rules.push(PolicyRule { effects, scope, raw: line.to_string() });
306 }
307 "pure" => out.rules.push(PolicyRule {
308 effects: BTreeSet::new(),
309 scope: toks.next().map(str::to_string),
310 raw: line.to_string(),
311 }),
312 "forbid" => {
313 let a = toks.next().unwrap_or("");
314 let arrow = toks.next().unwrap_or("");
315 let b = toks.next().unwrap_or("");
316 if a.is_empty() || arrow != "->" || b.is_empty() {
317 warn_ignore!("candor: ignoring layering rule (want `forbid <scope> -> <scope>`): {line}");
318 continue;
319 }
320 out.layer_rules.push(LayerRule {
321 from: a.to_string(),
322 to: b.to_string(),
323 raw: line.to_string(),
324 });
325 }
326 other => warn_ignore!("candor: ignoring policy rule (unknown kind `{other}`): {line}"),
327 }
328 }
329 out
330}
331
332#[cfg(test)]
333mod tests {
334 #[test]
335 fn db_table_covering_is_strict() {
336 use super::db_table_covered as c;
337 assert!(c("ledger.entries", "Ledger.Entries")); assert!(c("ledger.*", "ledger.entries")); assert!(!c("ledger.*", "ledgerx.entries")); assert!(!c("entries", "ledger.entries")); assert!(c("entries", "entries"));
342 }
343
344 #[test]
345 fn allow_db_parses_and_gates() {
346 let p = super::parse_policy("allow Db in billing ledger.* customers\n");
347 assert_eq!(p.allow_rules.len(), 1);
348 assert_eq!(p.allow_rules[0].effect, "Db");
349 assert!(super::literal_allowed("Db", "ledger.entries", &p.allow_rules[0].literals));
350 assert!(super::literal_allowed("Db", "customers", &p.allow_rules[0].literals));
351 assert!(!super::literal_allowed("Db", "audit.log", &p.allow_rules[0].literals));
352 }
353
354 use super::*;
355
356 #[test]
357 fn policy_parses() {
358 let p = parse_policy(
359 "# the domain layer must stay pure of I/O\n\
360 deny Net Db domain\n\
361 deny Exec\n\
362 pure parse\n\
363 nonsense line\n\
364 deny notaneffect\n",
365 );
366 let rules = &p.rules;
367 assert_eq!(rules.len(), 3);
368 assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
369 assert_eq!(rules[0].scope.as_deref(), Some("domain"));
370 assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
371 assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
372 let cr = parse_policy("deny Net a\rdeny Exec b\rdeny Db c\r");
374 assert_eq!(cr.rules.len(), 3, "bare-CR lines must each parse");
375 assert!(cr.rules.iter().any(|r| r.effects.contains("Exec") && r.scope.as_deref() == Some("b")));
376 assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
378 assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
380 assert!(parse_policy("deny\ndeny \n").rules.is_empty());
381 assert!(parse_policy("deny notaneffect scope").rules.is_empty());
383 let p2 = parse_policy("deny Net foo Db");
385 assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
386 assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
387 assert!(parse_policy("deny\u{a0}Net core").rules.is_empty(),
392 "an NBSP between deny and the effect must NOT split into separate tokens");
393 let nb = parse_policy("deny Net \u{a0}domain");
396 assert_eq!(nb.rules.len(), 1);
397 assert_eq!(nb.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
398 assert_eq!(nb.rules[0].scope.as_deref(), Some("\u{a0}domain"));
399 }
400
401 #[test]
402 fn allowlist_parses() {
403 let p = parse_policy(
404 "allow Net in billing api.stripe.com hooks.stripe.com\n\
405 allow Exec in ci git\n\
406 allow Fs in config /etc/app\n\
407 allow Net github.com\n\
408 allow Clock whatever\n\
409 allow Net in nohosts\n\
410 allow\n",
411 );
412 assert_eq!(p.allow_rules.len(), 4); assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
414 assert_eq!(
415 p.allow_rules[0].literals,
416 ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
417 );
418 assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
419 assert!(p.allow_rules[1].literals.contains("git"));
420 assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
421 assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
422
423 let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
424 assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
425 assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
428 assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
429 assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
430 assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
431 assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
432 assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
433 assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
434 assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
435 assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
436 assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
437 assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
438 assert_eq!(cmd_base("/usr/bin/git"), "git");
439 }
440
441 #[test]
442 fn layering_rule_parses() {
443 let p = parse_policy(
444 "forbid domain -> infra\n\
445 forbid app::web -> app::db \n\
446 forbid domain infra\n\
447 forbid domain ->\n\
448 forbid\n",
449 );
450 assert_eq!(p.layer_rules.len(), 2);
451 assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
452 assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
453 }
454
455 #[test]
456 fn scope_matches_by_segment_not_substring() {
457 assert!(scope_matches("app::domain::handle", "domain"));
458 assert!(scope_matches("domain::handle", "domain"));
459 assert!(scope_matches("app::domain", "domain"));
460 assert!(scope_matches("crate::domain_logic", "domain"));
461 assert!(!scope_matches("app::subdomain::handle", "domain"));
462 assert!(!scope_matches("app::not_my_domain::f", "domain"));
463 assert!(scope_matches("crate::net::client::send", "net::client"));
465 assert!(scope_matches("crate::net::client_pool::get", "net::client"));
466 assert!(!scope_matches("crate::net::server::send", "net::client"));
467 assert!(!scope_matches("crate::network::client::send", "net::client"));
468 assert!(!scope_matches("crate::net::x::client", "net::client"));
469 assert!(!scope_matches("net", "net::client"));
470 assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
474 assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
475 assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
476 assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
477 assert!(!scope_matches("com.acme.subdomain.h", "domain"));
478 assert!(!scope_matches("com.acme.domain.h", "infra"));
479 }
480
481 #[test]
482 fn fs_path_covered_respects_boundaries() {
483 assert!(fs_path_covered("/etc/app", "/etc/app"));
484 assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
485 assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
486 assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
487 assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
488 assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
489 assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
490 assert!(fs_path_covered("/", "/etc/app/x"));
491 assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
492 assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
493 assert!(fs_path_covered("etc/app", "etc/app/cfg"));
494 }
495}