1use crate::sexpr::SExpr;
71
72#[derive(Debug, Clone, PartialEq)]
76pub enum Pattern {
77 Any,
79 Symbol(String),
81 String(String),
83 Integer(i64),
85 Bool(bool),
87 Nil,
89 AnySymbol,
91 AnyString,
93 AnyInteger,
95 OneOf(Vec<String>),
97 Struct {
101 head: String,
102 fields: Vec<(String, Pattern)>,
103 },
104 ListHead { head: String, tail: Vec<Pattern> },
107}
108
109impl Pattern {
110 #[must_use]
112 pub fn matches(&self, target: &SExpr) -> bool {
113 match (self, target) {
114 (Self::Any, _) => true,
115 (Self::Symbol(s), SExpr::Symbol(t)) => s == t,
116 (Self::String(s), SExpr::String(t)) => s == t,
117 (Self::Integer(i), SExpr::Integer(t)) => i == t,
118 (Self::Bool(b), SExpr::Bool(t)) => b == t,
119 (Self::Nil, SExpr::Nil) => true,
120 (Self::AnySymbol, SExpr::Symbol(_)) => true,
121 (Self::AnyString, SExpr::String(_)) => true,
122 (Self::AnyInteger, SExpr::Integer(_)) => true,
123 (Self::OneOf(opts), SExpr::Symbol(s) | SExpr::String(s)) => opts.iter().any(|o| o == s),
124 (Self::Struct { head, fields }, SExpr::List(items)) => {
125 let Some((target_head, tail)) = items.split_first() else {
126 return false;
127 };
128 let Ok(name) = head_symbol(target_head) else {
129 return false;
130 };
131 if name != head {
132 return false;
133 }
134 let target_fields = parse_keyword_fields(tail);
135 fields
136 .iter()
137 .all(|(k, p)| match target_fields.get(k.as_str()) {
138 Some(v) => p.matches(v),
139 None => matches!(p, Pattern::Nil),
140 })
141 }
142 (Self::ListHead { head, tail }, SExpr::List(items)) => {
143 let Some((target_head, rest)) = items.split_first() else {
144 return false;
145 };
146 let Ok(name) = head_symbol(target_head) else {
147 return false;
148 };
149 if name != head {
150 return false;
151 }
152 if rest.len() != tail.len() {
153 return false;
154 }
155 tail.iter()
156 .zip(rest)
157 .all(|(pat, target)| pat.matches(target))
158 }
159 _ => false,
160 }
161 }
162}
163
164fn head_symbol(s: &SExpr) -> Result<&str, ()> {
165 match s {
166 SExpr::Symbol(s) => Ok(s),
167 _ => Err(()),
168 }
169}
170
171fn parse_keyword_fields(items: &[SExpr]) -> std::collections::BTreeMap<&str, &SExpr> {
172 let mut out = std::collections::BTreeMap::new();
173 for item in items {
174 let SExpr::List(pair) = item else { continue };
175 if pair.len() != 2 {
176 continue;
177 }
178 let SExpr::Symbol(key) = &pair[0] else {
179 continue;
180 };
181 if let Some(rest) = key.strip_prefix(':') {
182 out.insert(rest, &pair[1]);
183 }
184 }
185 out
186}
187
188#[derive(Debug, Clone, PartialEq)]
192pub enum Rule {
193 Deny(String),
195 RequireField { field: String, pattern: Pattern },
198 ForbidField { field: String },
201}
202
203#[derive(Debug, Clone, PartialEq)]
207pub struct Policy {
208 pub id: String,
210 pub description: String,
212 pub pattern: Pattern,
214 pub rule: Rule,
216}
217
218#[derive(Debug, Clone, PartialEq)]
220pub struct Finding {
221 pub policy_id: String,
222 pub path: String,
223 pub satisfied: bool,
225 pub reason: String,
226}
227
228#[derive(Debug, Clone, Default, PartialEq)]
230pub struct PolicyReport {
231 pub findings: Vec<Finding>,
232}
233
234impl PolicyReport {
235 #[must_use]
236 pub fn has_violations(&self) -> bool {
237 self.findings.iter().any(|f| !f.satisfied)
238 }
239
240 #[must_use]
241 pub fn violations(&self) -> Vec<&Finding> {
242 self.findings.iter().filter(|f| !f.satisfied).collect()
243 }
244
245 #[must_use]
246 pub fn passes(&self) -> Vec<&Finding> {
247 self.findings.iter().filter(|f| f.satisfied).collect()
248 }
249
250 #[must_use]
251 pub fn total(&self) -> usize {
252 self.findings.len()
253 }
254}
255
256#[must_use]
258pub fn evaluate(policies: &[Policy], target: &SExpr) -> PolicyReport {
259 let mut findings = Vec::new();
260 for policy in policies {
261 walk_and_eval(policy, target, "", &mut findings);
262 }
263 PolicyReport { findings }
264}
265
266fn walk_and_eval(policy: &Policy, target: &SExpr, path: &str, out: &mut Vec<Finding>) {
267 if policy.pattern.matches(target) {
268 eval_rule(policy, target, path, out);
269 }
270
271 if let SExpr::List(items) = target {
273 let struct_fields = if let Some((head, tail)) = items.split_first() {
276 if matches!(head, SExpr::Symbol(s) if !s.starts_with(':')) {
277 let head_name = match head {
279 SExpr::Symbol(s) => s.clone(),
280 _ => String::new(),
281 };
282 let labelled: Vec<(String, &SExpr)> = tail
283 .iter()
284 .enumerate()
285 .map(|(i, child)| {
286 if let SExpr::List(pair) = child {
287 if pair.len() == 2 {
288 if let SExpr::Symbol(k) = &pair[0] {
289 if let Some(name) = k.strip_prefix(':') {
290 return (name.to_string(), &pair[1]);
291 }
292 }
293 }
294 }
295 (format!("[{i}]"), child)
296 })
297 .collect();
298 Some((head_name, labelled))
299 } else {
300 None
301 }
302 } else {
303 None
304 };
305
306 if let Some((_, labelled)) = struct_fields {
307 for (name, child) in labelled {
308 let child_path = if path.is_empty() {
309 name
310 } else {
311 format!("{path}.{name}")
312 };
313 walk_and_eval(policy, child, &child_path, out);
314 }
315 } else {
316 for (i, child) in items.iter().enumerate() {
317 let child_path = if path.is_empty() {
318 format!("[{i}]")
319 } else {
320 format!("{path}[{i}]")
321 };
322 walk_and_eval(policy, child, &child_path, out);
323 }
324 }
325 }
326}
327
328fn eval_rule(policy: &Policy, target: &SExpr, path: &str, out: &mut Vec<Finding>) {
329 match &policy.rule {
330 Rule::Deny(reason) => out.push(Finding {
331 policy_id: policy.id.clone(),
332 path: path.to_string(),
333 satisfied: false,
334 reason: reason.clone(),
335 }),
336 Rule::RequireField { field, pattern } => {
337 let fields = match target {
338 SExpr::List(items) => {
339 let tail = items.split_first().map_or(&items[..], |(_, t)| t);
340 parse_keyword_fields(tail)
341 }
342 _ => std::collections::BTreeMap::new(),
343 };
344 let satisfied = match fields.get(field.as_str()) {
345 Some(v) => pattern.matches(v),
346 None => matches!(pattern, Pattern::Nil),
347 };
348 out.push(Finding {
349 policy_id: policy.id.clone(),
350 path: path.to_string(),
351 satisfied,
352 reason: if satisfied {
353 format!("required field '{field}' present and matches")
354 } else {
355 format!("required field '{field}' missing or did not match")
356 },
357 });
358 }
359 Rule::ForbidField { field } => {
360 let fields = match target {
361 SExpr::List(items) => {
362 let tail = items.split_first().map_or(&items[..], |(_, t)| t);
363 parse_keyword_fields(tail)
364 }
365 _ => std::collections::BTreeMap::new(),
366 };
367 let satisfied = match fields.get(field.as_str()) {
368 Some(SExpr::Nil) | None => true,
369 _ => false,
370 };
371 out.push(Finding {
372 policy_id: policy.id.clone(),
373 path: path.to_string(),
374 satisfied,
375 reason: if satisfied {
376 format!("forbidden field '{field}' is absent or nil")
377 } else {
378 format!("forbidden field '{field}' is present with non-nil value")
379 },
380 });
381 }
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::ir::IacType;
389 use crate::sexpr::ToSExpr;
390 use crate::testing::{TestAttributeBuilder, test_resource};
391
392 #[test]
395 fn any_matches_everything() {
396 assert!(Pattern::Any.matches(&SExpr::Integer(42)));
397 assert!(Pattern::Any.matches(&SExpr::String("x".into())));
398 assert!(Pattern::Any.matches(&SExpr::Nil));
399 }
400
401 #[test]
402 fn symbol_exact_match() {
403 assert!(Pattern::Symbol("foo".into()).matches(&SExpr::Symbol("foo".into())));
404 assert!(!Pattern::Symbol("foo".into()).matches(&SExpr::Symbol("bar".into())));
405 }
406
407 #[test]
408 fn any_string_ignores_content() {
409 assert!(Pattern::AnyString.matches(&SExpr::String("x".into())));
410 assert!(!Pattern::AnyString.matches(&SExpr::Symbol("x".into())));
411 }
412
413 #[test]
414 fn one_of_matches_strings_and_symbols() {
415 let p = Pattern::OneOf(vec!["a".into(), "b".into()]);
416 assert!(p.matches(&SExpr::String("a".into())));
417 assert!(p.matches(&SExpr::Symbol("b".into())));
418 assert!(!p.matches(&SExpr::String("c".into())));
419 }
420
421 #[test]
422 fn bool_integer_nil() {
423 assert!(Pattern::Bool(true).matches(&SExpr::Bool(true)));
424 assert!(Pattern::Integer(42).matches(&SExpr::Integer(42)));
425 assert!(Pattern::Nil.matches(&SExpr::Nil));
426 assert!(!Pattern::Bool(true).matches(&SExpr::Bool(false)));
427 }
428
429 #[test]
432 fn struct_pattern_matches_partial_fields() {
433 let target = SExpr::parse("(attribute (:sensitive true) (:immutable false))").unwrap();
436 let p = Pattern::Struct {
437 head: "attribute".into(),
438 fields: vec![("sensitive".into(), Pattern::Bool(true))],
439 };
440 assert!(p.matches(&target));
441 }
442
443 #[test]
444 fn struct_pattern_rejects_wrong_head() {
445 let target = SExpr::parse("(resource (:sensitive true))").unwrap();
446 let p = Pattern::Struct {
447 head: "attribute".into(),
448 fields: vec![("sensitive".into(), Pattern::Bool(true))],
449 };
450 assert!(!p.matches(&target));
451 }
452
453 #[test]
454 fn struct_pattern_field_value_mismatch() {
455 let target = SExpr::parse("(attribute (:sensitive false))").unwrap();
456 let p = Pattern::Struct {
457 head: "attribute".into(),
458 fields: vec![("sensitive".into(), Pattern::Bool(true))],
459 };
460 assert!(!p.matches(&target));
461 }
462
463 #[test]
466 fn list_head_positional_match() {
467 let target = SExpr::parse("(list integer)").unwrap();
468 let p = Pattern::ListHead {
469 head: "list".into(),
470 tail: vec![Pattern::Symbol("integer".into())],
471 };
472 assert!(p.matches(&target));
473 }
474
475 #[test]
476 fn list_head_arity_mismatch() {
477 let target = SExpr::parse("(list integer string)").unwrap();
478 let p = Pattern::ListHead {
479 head: "list".into(),
480 tail: vec![Pattern::Symbol("integer".into())],
481 };
482 assert!(!p.matches(&target));
483 }
484
485 fn resource_with_sensitive_mutable() -> SExpr {
488 let mut r = test_resource("x");
489 r.attributes = vec![
490 TestAttributeBuilder::new("password", IacType::String)
491 .required()
492 .sensitive()
493 .build(),
494 ];
495 r.to_sexpr()
496 }
497
498 fn resource_with_sensitive_immutable() -> SExpr {
499 let mut r = test_resource("x");
500 r.attributes = vec![
501 TestAttributeBuilder::new("password", IacType::String)
502 .required()
503 .sensitive()
504 .immutable()
505 .build(),
506 ];
507 r.to_sexpr()
508 }
509
510 fn sensitive_must_be_immutable() -> Policy {
511 Policy {
512 id: "sensitive-immutable".into(),
513 description: "every sensitive attribute must be immutable".into(),
514 pattern: Pattern::Struct {
515 head: "attribute".into(),
516 fields: vec![("sensitive".into(), Pattern::Bool(true))],
517 },
518 rule: Rule::RequireField {
519 field: "immutable".into(),
520 pattern: Pattern::Bool(true),
521 },
522 }
523 }
524
525 #[test]
526 fn policy_fires_when_rule_violated() {
527 let target = resource_with_sensitive_mutable();
528 let report = evaluate(&[sensitive_must_be_immutable()], &target);
529 assert!(report.has_violations());
530 assert_eq!(report.violations().len(), 1);
531 }
532
533 #[test]
534 fn policy_passes_when_rule_satisfied() {
535 let target = resource_with_sensitive_immutable();
536 let report = evaluate(&[sensitive_must_be_immutable()], &target);
537 assert!(!report.has_violations());
538 assert_eq!(report.passes().len(), 1);
539 }
540
541 #[test]
542 fn no_match_yields_empty_report() {
543 let mut r = test_resource("x");
546 r.attributes = vec![
547 TestAttributeBuilder::new("name", IacType::String)
548 .required()
549 .build(),
550 ];
551 let report = evaluate(&[sensitive_must_be_immutable()], &r.to_sexpr());
552 assert_eq!(report.total(), 0);
553 }
554
555 #[test]
556 fn deny_rule_produces_violation_on_every_match() {
557 let r = test_resource("x"); let policy = Policy {
559 id: "no-attributes".into(),
560 description: "forbid all attributes".into(),
561 pattern: Pattern::Struct {
562 head: "attribute".into(),
563 fields: vec![],
564 },
565 rule: Rule::Deny("no attributes allowed".into()),
566 };
567 let report = evaluate(&[policy], &r.to_sexpr());
568 assert_eq!(report.violations().len(), 3);
570 assert!(
571 report
572 .violations()
573 .iter()
574 .all(|v| v.reason == "no attributes allowed")
575 );
576 }
577
578 #[test]
579 fn forbid_field_rule() {
580 let target = resource_with_sensitive_immutable();
581 let policy = Policy {
582 id: "no-sensitive".into(),
583 description: "no sensitive flag set".into(),
584 pattern: Pattern::Struct {
585 head: "attribute".into(),
586 fields: vec![],
587 },
588 rule: Rule::ForbidField {
589 field: "sensitive".into(),
590 },
591 };
592 let report = evaluate(&[policy], &target);
593 assert!(report.has_violations());
597 }
598
599 #[test]
600 fn multiple_policies_evaluated_independently() {
601 let target = resource_with_sensitive_mutable();
602 let p1 = sensitive_must_be_immutable();
603 let p2 = Policy {
604 id: "never-fires".into(),
605 description: "impossible pattern".into(),
606 pattern: Pattern::Symbol("nonexistent-head".into()),
607 rule: Rule::Deny("x".into()),
608 };
609 let report = evaluate(&[p1, p2], &target);
610 assert_eq!(report.findings.len(), 1);
612 }
613
614 #[test]
615 fn evaluation_is_deterministic() {
616 let target = resource_with_sensitive_mutable();
617 let a = evaluate(&[sensitive_must_be_immutable()], &target);
618 let b = evaluate(&[sensitive_must_be_immutable()], &target);
619 assert_eq!(a, b);
620 }
621
622 #[test]
623 fn finding_path_points_into_struct() {
624 let target = resource_with_sensitive_mutable();
625 let report = evaluate(&[sensitive_must_be_immutable()], &target);
626 let violation = &report.violations()[0];
627 assert!(!violation.path.is_empty());
631 assert!(violation.path.contains("attributes"));
632 }
633
634 #[test]
637 fn report_counts() {
638 let target = resource_with_sensitive_mutable();
639 let report = evaluate(&[sensitive_must_be_immutable()], &target);
640 assert_eq!(report.total(), 1);
641 assert_eq!(report.violations().len(), 1);
642 assert_eq!(report.passes().len(), 0);
643 }
644
645 #[test]
646 fn empty_report_when_no_policies() {
647 let target = resource_with_sensitive_mutable();
648 let report = evaluate(&[], &target);
649 assert_eq!(report.total(), 0);
650 assert!(!report.has_violations());
651 }
652}