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)]
30pub struct AllowRule {
31 pub effect: &'static str,
32 pub scope: Option<String>,
33 pub literals: BTreeSet<String>,
34 pub raw: String,
35}
36
37#[derive(Debug, Clone)]
40pub struct LayerRule {
41 pub from: String,
42 pub to: String,
43 pub raw: String,
44}
45
46#[derive(Default, Debug)]
48pub struct ParsedPolicy {
49 pub rules: Vec<PolicyRule>,
50 pub allow_rules: Vec<AllowRule>,
51 pub layer_rules: Vec<LayerRule>,
52}
53
54pub fn host_part(h: &str) -> &str {
60 if let Some(rest) = h.strip_prefix('[') {
61 return rest.split(']').next().unwrap_or(rest);
63 }
64 if h.matches(':').count() > 1 {
65 return h; }
67 h.split(':').next().unwrap_or(h)
68}
69
70pub fn cmd_base(c: &str) -> &str {
72 c.rsplit(['/', '\\']).next().unwrap_or(c)
73}
74
75pub fn fs_path_covered(a: &str, r: &str) -> bool {
80 if r.split(['/', '\\']).any(|c| c == "..") {
81 return false;
82 }
83 let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
84 if absolute(a) != absolute(r) {
85 return false;
86 }
87 let norm = |s: &str| -> Vec<String> {
88 s.split(['/', '\\'])
89 .filter(|c| !c.is_empty() && *c != ".")
90 .map(|c| c.to_string())
91 .collect()
92 };
93 let (ac, rc) = (norm(a), norm(r));
94 ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
95}
96
97pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
100 match effect {
101 "Net" => allow.iter().any(|a| host_part(a) == host_part(reached)),
102 "Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
103 "Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
104 _ => allow.contains(reached),
105 }
106}
107
108pub fn scope_matches(name: &str, scope: &str) -> bool {
113 let segs: Vec<&str> = name.split("::").collect();
114 let parts: Vec<&str> = scope.split("::").collect();
115 if parts.is_empty() || parts.len() > segs.len() {
116 return false;
117 }
118 let (last, init) = parts.split_last().unwrap();
119 segs.windows(parts.len()).any(|w| {
120 let (w_last, w_init) = w.split_last().unwrap();
121 w_init == init && w_last.starts_with(last)
122 })
123}
124
125pub fn parse_policy(text: &str) -> ParsedPolicy {
140 let mut out = ParsedPolicy::default();
141 for raw_line in text.lines() {
142 let line = raw_line.split('#').next().unwrap_or("").trim();
143 if line.is_empty() {
144 continue;
145 }
146 let mut toks = line.split_whitespace();
147 match toks.next().unwrap_or("") {
148 "allow" => {
149 let effect = match toks.next().unwrap_or("") {
150 "Net" => "Net",
151 "Exec" => "Exec",
152 "Fs" => "Fs",
153 _ => {
154 eprintln!(
155 "candor: ignoring policy rule (allow supports only Net hosts / Exec commands / Fs paths): {line}"
156 );
157 continue;
158 }
159 };
160 let mut rest: Vec<&str> = toks.collect();
161 let scope = if rest.first() == Some(&"in") {
162 let s = rest.get(1).map(|s| s.to_string());
163 rest.drain(..2.min(rest.len()));
164 s
165 } else {
166 None
167 };
168 let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
169 if literals.is_empty() {
170 eprintln!("candor: ignoring policy rule (allow {effect} names no values): {line}");
171 continue;
172 }
173 out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
174 }
175 "deny" => {
176 let mut effects = BTreeSet::new();
177 let mut scope = None;
178 for t in toks {
179 let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
180 match e {
181 Some(e) => {
182 effects.insert(e);
183 }
184 None => {
185 scope = Some(t.to_string());
186 break;
187 }
188 }
189 }
190 if effects.is_empty() {
191 eprintln!("candor: ignoring policy rule (no known effect named): {line}");
192 continue;
193 }
194 out.rules.push(PolicyRule { effects, scope, raw: line.to_string() });
195 }
196 "pure" => out.rules.push(PolicyRule {
197 effects: BTreeSet::new(),
198 scope: toks.next().map(str::to_string),
199 raw: line.to_string(),
200 }),
201 "forbid" => {
202 let a = toks.next().unwrap_or("");
203 let arrow = toks.next().unwrap_or("");
204 let b = toks.next().unwrap_or("");
205 if a.is_empty() || arrow != "->" || b.is_empty() {
206 eprintln!("candor: ignoring layering rule (want `forbid <scope> -> <scope>`): {line}");
207 continue;
208 }
209 out.layer_rules.push(LayerRule {
210 from: a.to_string(),
211 to: b.to_string(),
212 raw: line.to_string(),
213 });
214 }
215 other => eprintln!("candor: ignoring policy rule (unknown kind `{other}`): {line}"),
216 }
217 }
218 out
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn policy_parses() {
227 let p = parse_policy(
228 "# the domain layer must stay pure of I/O\n\
229 deny Net Db domain\n\
230 deny Exec\n\
231 pure parse\n\
232 nonsense line\n\
233 deny notaneffect\n",
234 );
235 let rules = &p.rules;
236 assert_eq!(rules.len(), 3);
237 assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
238 assert_eq!(rules[0].scope.as_deref(), Some("domain"));
239 assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
240 assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
241 assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
243 assert!(parse_policy("deny\ndeny \n").rules.is_empty());
244 assert!(parse_policy("deny notaneffect scope").rules.is_empty());
246 let p2 = parse_policy("deny Net foo Db");
248 assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
249 assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
250 }
251
252 #[test]
253 fn allowlist_parses() {
254 let p = parse_policy(
255 "allow Net in billing api.stripe.com hooks.stripe.com\n\
256 allow Exec in ci git\n\
257 allow Fs in config /etc/app\n\
258 allow Net github.com\n\
259 allow Db whatever\n\
260 allow Net in nohosts\n\
261 allow\n",
262 );
263 assert_eq!(p.allow_rules.len(), 4);
264 assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
265 assert_eq!(
266 p.allow_rules[0].literals,
267 ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
268 );
269 assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
270 assert!(p.allow_rules[1].literals.contains("git"));
271 assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
272 assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
273
274 let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
275 assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
276 assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
279 assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
280 assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
281 assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
282 assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
283 assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
284 assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
285 assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
286 assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
287 assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
288 assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
289 assert_eq!(cmd_base("/usr/bin/git"), "git");
290 }
291
292 #[test]
293 fn layering_rule_parses() {
294 let p = parse_policy(
295 "forbid domain -> infra\n\
296 forbid app::web -> app::db \n\
297 forbid domain infra\n\
298 forbid domain ->\n\
299 forbid\n",
300 );
301 assert_eq!(p.layer_rules.len(), 2);
302 assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
303 assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
304 }
305
306 #[test]
307 fn scope_matches_by_segment_not_substring() {
308 assert!(scope_matches("app::domain::handle", "domain"));
309 assert!(scope_matches("domain::handle", "domain"));
310 assert!(scope_matches("app::domain", "domain"));
311 assert!(scope_matches("crate::domain_logic", "domain"));
312 assert!(!scope_matches("app::subdomain::handle", "domain"));
313 assert!(!scope_matches("app::not_my_domain::f", "domain"));
314 assert!(scope_matches("crate::net::client::send", "net::client"));
316 assert!(scope_matches("crate::net::client_pool::get", "net::client"));
317 assert!(!scope_matches("crate::net::server::send", "net::client"));
318 assert!(!scope_matches("crate::network::client::send", "net::client"));
319 assert!(!scope_matches("crate::net::x::client", "net::client"));
320 assert!(!scope_matches("net", "net::client"));
321 }
322
323 #[test]
324 fn fs_path_covered_respects_boundaries() {
325 assert!(fs_path_covered("/etc/app", "/etc/app"));
326 assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
327 assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
328 assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
329 assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
330 assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
331 assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
332 assert!(fs_path_covered("/", "/etc/app/x"));
333 assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
334 assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
335 assert!(fs_path_covered("etc/app", "etc/app/cfg"));
336 }
337}