1use crate::budget::{BudgetKind, BudgetTracker};
2use crate::consts::{
3 CONDITION_REF_PREFIX, FN_AND, FN_CONDITION, FN_EQUALS, FN_NOT, FN_OR, MAX_PARAM_COMBINATIONS, MAX_SAT_ITERATIONS,
4 MAX_TOTAL_SAT_ITERATIONS, PARAM_UNKNOWN_SENTINEL, PSEUDO_PREFIX, PSEUDO_REGION, SSM_PARAMETER_VALUE_TYPE_PREFIX,
5};
6use crate::ir::*;
7use crate::model::PseudoParameterOverrides;
8use crate::resolver::{MappingData, ParameterInfo};
9use log::{debug, info};
10use std::collections::{HashMap, HashSet};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, OnceLock};
13
14const MAX_REPORTED_BUDGET_EXHAUSTED_QUERIES: usize = 5;
19
20const WHOLE_CONDITION_SET_DESCRIPTION: &str =
24 "this template's condition set as a whole (remaining conditions were assumed compatible)";
25const INLINE_CONDITION_DESCRIPTION: &str = "<inline condition>";
26const SYNTHETIC_CONDITION_PREFIX: &str = "__";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Satisfiability {
32 Satisfiable,
33 Unsatisfiable,
34 Unknown,
35}
36
37#[derive(Debug, Clone)]
38pub enum ConditionExpr {
39 Equals(ValueExpr, ValueExpr),
40 And(Vec<ConditionExpr>),
41 Or(Vec<ConditionExpr>),
42 Not(Box<ConditionExpr>),
43 ConditionRef(String),
44 Invalid,
50}
51
52#[derive(Debug, Clone)]
53pub enum ValueExpr {
54 ParamRef(String),
55 Literal(String),
56 PseudoParam(String),
57 MappingLookup { map_name: String, key1: Box<ValueExpr>, key2: Box<ValueExpr> },
58 Other,
59}
60
61#[derive(Debug, Clone)]
62pub struct MutexGroup {
63 pub conditions: Vec<String>,
64 pub parameter: String,
65 pub values: Vec<String>,
66}
67
68#[derive(Debug, Clone)]
69pub struct Implication {
70 pub antecedent: String,
71 pub consequent: String,
72}
73
74#[derive(Debug)]
75pub struct ConditionModel {
76 pub conditions: HashMap<String, ConditionExpr>,
77 pub parameters: HashMap<String, ParameterInfo>,
78 pub mutex_groups: Vec<MutexGroup>,
79 pub implications: Vec<Implication>,
80 rule_implications: Vec<Implication>,
89 parameter_overrides: HashMap<String, String>,
90 pseudo_overrides: PseudoParameterOverrides,
91 mappings: MappingData,
92 sat_iterations_used: AtomicU64,
97 referenced_param_values: OnceLock<HashMap<String, Vec<String>>>,
104 budget_exhausted_queries: std::sync::Mutex<Vec<String>>,
105 budget_tracker: Arc<BudgetTracker>,
108}
109
110pub fn format_condition_expr(expr: &ConditionExpr) -> String {
111 match expr {
112 ConditionExpr::Equals(a, b) => {
113 format!("Equals({}, {})", format_value_expr(a), format_value_expr(b))
114 }
115 ConditionExpr::And(exprs) => {
116 let items: Vec<String> = exprs.iter().map(format_condition_expr).collect();
117 format!("And({})", items.join(", "))
118 }
119 ConditionExpr::Or(exprs) => {
120 let items: Vec<String> = exprs.iter().map(format_condition_expr).collect();
121 format!("Or({})", items.join(", "))
122 }
123 ConditionExpr::Not(e) => format!("Not({})", format_condition_expr(e)),
124 ConditionExpr::ConditionRef(name) => format!("Condition({})", name),
125 ConditionExpr::Invalid => "Invalid".to_string(),
126 }
127}
128
129fn format_value_expr(expr: &ValueExpr) -> String {
130 match expr {
131 ValueExpr::ParamRef(name) => format!("Param({})", name),
132 ValueExpr::Literal(s) => format!("\"{}\"", s),
133 ValueExpr::PseudoParam(name) => name.clone(),
134 ValueExpr::MappingLookup { map_name, key1, key2 } => {
135 format!("FindInMap({}, {}, {})", map_name, format_value_expr(key1), format_value_expr(key2))
136 }
137 ValueExpr::Other => "?".into(),
138 }
139}
140
141fn format_satisfiability_assumptions(assumptions: &[(String, bool)]) -> String {
142 let mut descriptions = assumptions
143 .iter()
144 .map(|(name, value)| {
145 let condition_description =
146 if name.starts_with(SYNTHETIC_CONDITION_PREFIX) { INLINE_CONDITION_DESCRIPTION } else { name };
147 format!("{condition_description}={value}")
148 })
149 .collect::<Vec<_>>();
150 descriptions.sort_unstable();
151 descriptions.join(", ")
152}
153
154impl ConditionModel {
155 pub fn from_ir(
156 ir: &TemplateIR,
157 parameters: &HashMap<String, ParameterInfo>,
158 pseudo_overrides: &PseudoParameterOverrides,
159 mappings: &MappingData,
160 ) -> Self {
161 Self::from_ir_with_tracker(
162 ir,
163 parameters,
164 pseudo_overrides,
165 mappings,
166 &HashMap::new(),
167 Arc::new(BudgetTracker::new()),
168 )
169 }
170
171 pub(crate) fn from_ir_with_tracker(
175 ir: &TemplateIR,
176 parameters: &HashMap<String, ParameterInfo>,
177 pseudo_overrides: &PseudoParameterOverrides,
178 mappings: &MappingData,
179 parameter_overrides: &HashMap<String, String>,
180 tracker: Arc<BudgetTracker>,
181 ) -> Self {
182 let mut conditions = HashMap::new();
183
184 if ir.conditions != NULL_REF
185 && let Some(entries) = ir.arena.as_map(ir.conditions)
186 {
187 for (name, node_ref) in entries {
188 let expr = parse_condition_expr(&ir.arena, *node_ref, parameters);
189 conditions.insert(name.clone(), expr);
190 }
191 }
192
193 let mutex_groups = extract_mutex_groups(&conditions);
194 let implications = extract_implications(&conditions);
195
196 info!(
197 "Condition model: {} conditions, {} mutex groups (params: {:?}), {} implications",
198 conditions.len(),
199 mutex_groups.len(),
200 mutex_groups.iter().map(|g| g.parameter.as_str()).collect::<Vec<_>>(),
201 implications.len()
202 );
203 ConditionModel {
204 conditions,
205 parameters: parameters.clone(),
206 mutex_groups,
207 implications,
208 rule_implications: Vec::new(),
209 parameter_overrides: parameter_overrides.clone(),
210 pseudo_overrides: pseudo_overrides.clone(),
211 mappings: mappings.clone(),
212 sat_iterations_used: AtomicU64::new(0),
213 referenced_param_values: OnceLock::new(),
214 budget_exhausted_queries: std::sync::Mutex::new(Vec::new()),
215 budget_tracker: tracker,
216 }
217 }
218
219 #[must_use]
260 pub fn is_satisfiable(&self, assumptions: &[(String, bool)]) -> bool {
261 self.satisfiability(assumptions) != Satisfiability::Unsatisfiable
262 }
263
264 #[must_use]
269 pub fn satisfiability(&self, assumptions: &[(String, bool)]) -> Satisfiability {
270 self.satisfiability_with_param_overrides(assumptions, &HashMap::new())
271 }
272
273 #[must_use]
284 pub fn is_satisfiable_in_region(&self, assumptions: &[(String, bool)], region: &str) -> bool {
285 let overrides = HashMap::from([(PSEUDO_REGION.to_string(), vec![region.to_string()])]);
286 self.satisfiability_with_param_overrides(assumptions, &overrides) != Satisfiability::Unsatisfiable
287 }
288
289 #[must_use]
294 fn satisfiability_with_param_overrides<'model>(
295 &'model self,
296 assumptions: &[(String, bool)],
297 param_overrides: &'model HashMap<String, Vec<String>>,
298 ) -> Satisfiability {
299 if self.satisfiability_budget_exhausted() {
302 return Satisfiability::Unknown;
303 }
304
305 let mut assumed: HashMap<&'model str, bool> = HashMap::with_capacity(assumptions.len());
306 for (name, expected) in assumptions {
307 let Some((condition_name, _)) = self.conditions.get_key_value(name.as_str()) else {
310 continue;
311 };
312 if let Some(&already_assumed) = assumed.get(condition_name.as_str())
313 && already_assumed != *expected
314 {
315 return Satisfiability::Unsatisfiable;
316 }
317 assumed.insert(condition_name.as_str(), *expected);
318 }
319 if assumed.is_empty() {
320 return Satisfiability::Satisfiable;
321 }
322 debug!("Checking satisfiability of {:?} against {} conditions", assumptions, self.conditions.len());
323
324 let mut query = SatisfiabilityQuery::prepare(self, &assumed, param_overrides);
325
326 if query.parameter_point_count() > MAX_PARAM_COMBINATIONS {
331 let steps = query.steps;
332 self.charge_satisfiability_work(steps);
333 let query_description = format_satisfiability_assumptions(assumptions);
334 self.record_curtailed_analysis(query_description);
335 self.budget_tracker.record(BudgetKind::ConditionParameterCombinations);
336 return Satisfiability::Unknown;
337 }
338
339 let satisfiable = query.some_point_satisfies(&assumed);
340 let steps = query.steps;
341 self.charge_satisfiability_work(steps);
342 if steps > MAX_SAT_ITERATIONS {
343 let query_description = format_satisfiability_assumptions(assumptions);
347 self.record_curtailed_analysis(query_description);
348 self.budget_tracker.record(BudgetKind::ConditionSatIterationsPerQuery);
349 return Satisfiability::Unknown;
350 }
351 if satisfiable { Satisfiability::Satisfiable } else { Satisfiability::Unsatisfiable }
352 }
353
354 fn charge_satisfiability_work(&self, steps: u64) {
358 let before = self.sat_iterations_used.fetch_add(steps, Ordering::Relaxed);
359 if before < MAX_TOTAL_SAT_ITERATIONS && before.saturating_add(steps) >= MAX_TOTAL_SAT_ITERATIONS {
360 self.record_curtailed_analysis(WHOLE_CONDITION_SET_DESCRIPTION.to_string());
361 self.budget_tracker.record(BudgetKind::ConditionSatIterationsTotal);
362 }
363 }
364
365 fn record_curtailed_analysis(&self, description: String) {
368 if let Ok(mut curtailed) = self.budget_exhausted_queries.lock()
369 && curtailed.len() < MAX_REPORTED_BUDGET_EXHAUSTED_QUERIES
370 {
371 curtailed.push(description);
372 }
373 }
374
375 #[must_use]
380 pub fn satisfiability_budget_exhausted(&self) -> bool {
381 self.sat_iterations_used.load(Ordering::Relaxed) >= MAX_TOTAL_SAT_ITERATIONS
382 }
383
384 #[must_use]
386 pub fn sat_iterations_used(&self) -> u64 {
387 self.sat_iterations_used.load(Ordering::Relaxed)
388 }
389
390 pub fn budget_exhausted_queries(&self) -> Vec<String> {
391 self.budget_exhausted_queries.lock().unwrap_or_else(|p| p.into_inner()).clone()
392 }
393
394 #[cfg(test)]
400 fn add_sat_iterations_for_test(&self, count: u64) {
401 self.charge_satisfiability_work(count);
402 }
403
404 fn collect_referenced_param_values(&self) -> HashMap<String, Vec<String>> {
429 let mut compared_literals: HashMap<String, Vec<String>> = HashMap::new();
430 for expr in self.conditions.values() {
431 collect_equals_pairs(expr, &mut compared_literals);
432 }
433 let mut param_values: HashMap<String, Vec<String>> = HashMap::new();
434 for (param_name, literals) in &compared_literals {
435 if let Some(fixed) = self.parameter_overrides.get(param_name)
436 && self
437 .parameters
438 .get(param_name)
439 .is_some_and(|parameter| !parameter.param_type.starts_with(SSM_PARAMETER_VALUE_TYPE_PREFIX))
440 {
441 param_values.insert(param_name.clone(), vec![fixed.clone()]);
442 continue;
443 }
444 if let Some(fixed) = self.pseudo_overrides.fixed_value(param_name) {
445 param_values.insert(param_name.clone(), vec![fixed]);
446 continue;
447 }
448 if let Some(allowed_values) = self.parameters.get(param_name).and_then(|p| p.allowed_values.clone()) {
449 param_values.insert(param_name.clone(), allowed_values);
450 continue;
451 }
452 let mut values = literals.clone();
453 values.push(PARAM_UNKNOWN_SENTINEL.to_string());
454 values.sort();
455 values.dedup();
456 param_values.insert(param_name.clone(), values);
457 }
458 param_values
459 }
460
461 fn referenced_param_values(&self) -> &HashMap<String, Vec<String>> {
464 self.referenced_param_values.get_or_init(|| self.collect_referenced_param_values())
465 }
466
467 #[must_use]
468 pub fn conditions_compatible(&self, cond_a: &str, cond_b: &str) -> bool {
469 self.is_satisfiable(&[(cond_a.into(), true), (cond_b.into(), true)])
470 }
471
472 #[must_use]
473 pub fn condition_implies(&self, cond_a: &str, cond_b: &str) -> bool {
474 if cond_a == cond_b {
477 return true;
478 }
479 !self.is_satisfiable(&[(cond_a.into(), true), (cond_b.into(), false)])
480 }
481
482 pub fn resources_compatible(&self, cond_a: Option<&str>, cond_b: Option<&str>) -> bool {
483 match (cond_a, cond_b) {
484 (None, _) | (_, None) => true,
485 (Some(a), Some(b)) => self.conditions_compatible(a, b),
486 }
487 }
488
489 pub fn get(&self, name: &str) -> Option<&ConditionExpr> {
490 self.conditions.get(name)
491 }
492
493 pub fn names(&self) -> impl Iterator<Item = &str> {
494 self.conditions.keys().map(|s| s.as_str())
495 }
496
497 pub fn tautological_equals(&self) -> Vec<(String, bool)> {
498 let mut result = Vec::new();
499 for (name, expr) in &self.conditions {
500 Self::find_tautological(expr, name, &mut result);
501 }
502 result
503 }
504
505 fn find_tautological(expr: &ConditionExpr, cond_name: &str, out: &mut Vec<(String, bool)>) {
506 match expr {
507 ConditionExpr::Equals(a, b) => {
508 let always_equal = match (a, b) {
509 (ValueExpr::Literal(la), ValueExpr::Literal(lb)) => Some(la == lb),
510 (ValueExpr::ParamRef(pa), ValueExpr::ParamRef(pb)) => Some(pa == pb),
511 (ValueExpr::PseudoParam(pa), ValueExpr::PseudoParam(pb)) => Some(pa == pb),
512 _ => None,
513 };
514 if let Some(equal) = always_equal {
515 out.push((cond_name.to_string(), equal));
516 }
517 }
518 ConditionExpr::And(children) | ConditionExpr::Or(children) => {
519 for c in children {
520 Self::find_tautological(c, cond_name, out);
521 }
522 }
523 ConditionExpr::Not(child) => Self::find_tautological(child, cond_name, out),
524 ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
525 }
526 }
527
528 pub fn referenced_params(&self) -> Vec<String> {
529 let mut params = Vec::new();
530 for expr in self.conditions.values() {
531 collect_param_refs_from_expr(expr, &mut params);
532 }
533 params.sort();
534 params.dedup();
535 params
536 }
537
538 pub fn register_inline(&mut self, name: String, expr: ConditionExpr) {
539 self.register_inline_batch(std::iter::once((name, expr)));
540 }
541
542 pub fn register_inline_batch(&mut self, items: impl IntoIterator<Item = (String, ConditionExpr)>) {
546 for (name, expr) in items {
547 self.conditions.insert(name, expr);
548 }
549 self.mutex_groups = extract_mutex_groups(&self.conditions);
550 self.implications = extract_implications(&self.conditions);
551 self.referenced_param_values = OnceLock::new();
558 }
559
560 pub fn register_rule_implications(&mut self, arena: &Arena, rules: &[(String, NodeRef, Vec<NodeRef>)]) {
561 let mut rule_implications: Vec<Implication> = Vec::new();
567 for (rule_name, condition_node, assertion_nodes) in rules {
568 let antecedent = if *condition_node != NULL_REF {
569 let expr = parse_condition_expr(arena, *condition_node, &self.parameters);
570 let synth_name = format!("__rule_cond_{}", rule_name);
571 self.conditions.insert(synth_name.clone(), expr);
572 Some(synth_name)
573 } else {
574 None
575 };
576
577 for (idx, assert_node) in assertion_nodes.iter().enumerate() {
578 if *assert_node == NULL_REF {
579 continue;
580 }
581 let assert_expr = parse_condition_expr(arena, *assert_node, &self.parameters);
582 let assert_name = format!("__rule_assert_{}_{}", rule_name, idx);
583 self.conditions.insert(assert_name.clone(), assert_expr);
584
585 if let Some(ref ante) = antecedent {
586 rule_implications.push(Implication { antecedent: ante.clone(), consequent: assert_name });
587 }
588 }
589 }
590
591 self.mutex_groups = extract_mutex_groups(&self.conditions);
592 self.implications = extract_implications(&self.conditions);
593 self.rule_implications = rule_implications;
594 self.referenced_param_values = OnceLock::new();
595 }
596
597 pub fn undefined_condition_refs(&self) -> Vec<(String, String)> {
598 let mut result = Vec::new();
599 for (owner, expr) in &self.conditions {
600 let mut refs = Vec::new();
601 collect_condition_refs(expr, &mut refs);
602 for r in refs {
603 if !self.conditions.contains_key(&r) {
604 result.push((owner.clone(), r));
605 }
606 }
607 }
608 result.sort();
611 result
612 }
613
614 pub fn invalid_condition_bodies(&self) -> Vec<String> {
619 let mut names: Vec<String> = self
620 .conditions
621 .iter()
622 .filter(|(name, expr)| matches!(expr, ConditionExpr::Invalid) && !name.starts_with("__"))
623 .map(|(name, _)| name.clone())
624 .collect();
625 names.sort();
626 names
627 }
628
629 pub fn detect_cycles(&self) -> Vec<Vec<String>> {
630 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
631 for (name, expr) in &self.conditions {
632 let mut refs = Vec::new();
633 collect_condition_refs(expr, &mut refs);
634 let mut targets: Vec<String> = refs.into_iter().filter(|r| self.conditions.contains_key(r)).collect();
635 targets.sort();
636 adj.insert(name.clone(), targets);
637 }
638
639 let mut sorted_names: Vec<&String> = self.conditions.keys().collect();
640 sorted_names.sort();
641
642 let mut cycles = Vec::new();
643 let mut visited: HashSet<String> = HashSet::new();
644 let mut on_stack: HashSet<String> = HashSet::new();
645 let mut path: Vec<String> = Vec::new();
646
647 for name in &sorted_names {
648 if !visited.contains(name.as_str()) {
649 Self::dfs_cycles(name, &adj, &mut visited, &mut on_stack, &mut path, &mut cycles);
650 }
651 }
652 cycles
653 }
654
655 fn dfs_cycles(
656 node: &str,
657 adj: &HashMap<String, Vec<String>>,
658 visited: &mut HashSet<String>,
659 on_stack: &mut HashSet<String>,
660 path: &mut Vec<String>,
661 cycles: &mut Vec<Vec<String>>,
662 ) {
663 visited.insert(node.to_string());
664 on_stack.insert(node.to_string());
665 path.push(node.to_string());
666
667 if let Some(neighbors) = adj.get(node) {
668 for neighbor in neighbors {
669 if !visited.contains(neighbor.as_str()) {
670 Self::dfs_cycles(neighbor, adj, visited, on_stack, path, cycles);
671 } else if on_stack.contains(neighbor.as_str()) {
672 let cycle_start = path.iter().position(|n| n == neighbor).unwrap();
673 let cycle: Vec<String> = path[cycle_start..].to_vec();
674 cycles.push(cycle);
675 }
676 }
677 }
678
679 path.pop();
680 on_stack.remove(node);
681 }
682
683 pub fn detect_equivalent_conditions(&self) -> Vec<(String, String)> {
684 let mut canonical_groups: HashMap<String, Vec<&str>> = HashMap::new();
685 for (name, expr) in &self.conditions {
686 if name.starts_with("__") {
687 continue;
688 }
689 if expr_has_opaque_value(expr) {
690 continue;
691 }
692 let canonical = canonical_form(expr);
693 canonical_groups.entry(canonical).or_default().push(name.as_str());
694 }
695
696 let mut pairs = Vec::new();
697 for (_, mut group) in canonical_groups {
698 if group.len() < 2 {
699 continue;
700 }
701 group.sort();
702 for other in &group[1..] {
703 pairs.push((group[0].to_string(), other.to_string()));
704 }
705 }
706 pairs
707 }
708}
709
710fn expr_has_opaque_value(expr: &ConditionExpr) -> bool {
711 match expr {
712 ConditionExpr::Equals(a, b) => value_is_opaque(a) || value_is_opaque(b),
713 ConditionExpr::And(items) | ConditionExpr::Or(items) => items.iter().any(expr_has_opaque_value),
714 ConditionExpr::Not(inner) => expr_has_opaque_value(inner),
715 ConditionExpr::Invalid => true,
719 ConditionExpr::ConditionRef(_) => false,
720 }
721}
722
723fn value_is_opaque(val: &ValueExpr) -> bool {
724 match val {
725 ValueExpr::Other => true,
726 ValueExpr::MappingLookup { key1, key2, .. } => value_is_opaque(key1) || value_is_opaque(key2),
731 _ => false,
732 }
733}
734
735fn canonical_form(expr: &ConditionExpr) -> String {
736 match expr {
737 ConditionExpr::Equals(a, b) => {
738 let ca = canonical_value(a);
739 let cb = canonical_value(b);
740 let (left, right) = if ca <= cb { (ca, cb) } else { (cb, ca) };
741 format!("EQ({},{})", left, right)
742 }
743 ConditionExpr::And(items) => {
744 let mut parts: Vec<String> = items.iter().map(canonical_form).collect();
745 parts.sort();
746 format!("AND({})", parts.join(","))
747 }
748 ConditionExpr::Or(items) => {
749 let mut parts: Vec<String> = items.iter().map(canonical_form).collect();
750 parts.sort();
751 format!("OR({})", parts.join(","))
752 }
753 ConditionExpr::Not(inner) => {
754 format!("NOT({})", canonical_form(inner))
755 }
756 ConditionExpr::ConditionRef(name) => {
757 format!("CREF({})", name)
758 }
759 ConditionExpr::Invalid => "INVALID".to_string(),
760 }
761}
762
763fn canonical_value(val: &ValueExpr) -> String {
764 match val {
765 ValueExpr::ParamRef(name) => format!("P({})", name),
766 ValueExpr::Literal(s) => format!("L({})", s),
767 ValueExpr::PseudoParam(name) => format!("PP({})", name),
768 ValueExpr::MappingLookup { map_name, key1, key2 } => {
769 format!("MAP({},{},{})", map_name, canonical_value(key1), canonical_value(key2))
770 }
771 ValueExpr::Other => "?".to_string(),
772 }
773}
774
775fn collect_param_refs_from_expr(expr: &ConditionExpr, out: &mut Vec<String>) {
776 match expr {
777 ConditionExpr::Equals(a, b) => {
778 collect_param_refs_from_value(a, out);
779 collect_param_refs_from_value(b, out);
780 }
781 ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
782 for e in exprs {
783 collect_param_refs_from_expr(e, out);
784 }
785 }
786 ConditionExpr::Not(e) => collect_param_refs_from_expr(e, out),
787 ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
788 }
789}
790
791fn collect_param_refs_from_value(expr: &ValueExpr, out: &mut Vec<String>) {
792 match expr {
793 ValueExpr::ParamRef(n) => out.push(n.clone()),
794 ValueExpr::MappingLookup { key1, key2, .. } => {
795 collect_param_refs_from_value(key1, out);
796 collect_param_refs_from_value(key2, out);
797 }
798 _ => {}
799 }
800}
801
802pub fn parse_condition_expr(
803 arena: &Arena,
804 node_ref: NodeRef,
805 parameters: &HashMap<String, ParameterInfo>,
806) -> ConditionExpr {
807 match arena.node(node_ref) {
808 Node::Intrinsic(IntrinsicFn::Equals(a, b)) => {
809 let va = parse_value_expr(arena, *a, parameters);
810 let vb = parse_value_expr(arena, *b, parameters);
811 ConditionExpr::Equals(va, vb)
812 }
813 Node::Intrinsic(IntrinsicFn::And(children)) => {
814 let exprs = children.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
815 ConditionExpr::And(exprs)
816 }
817 Node::Intrinsic(IntrinsicFn::Or(children)) => {
818 let exprs = children.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
819 ConditionExpr::Or(exprs)
820 }
821 Node::Intrinsic(IntrinsicFn::Not(child)) => {
822 let expr = parse_condition_expr(arena, *child, parameters);
823 ConditionExpr::Not(Box::new(expr))
824 }
825 Node::Intrinsic(IntrinsicFn::Ref(target)) => {
826 if let Some(name) = target.strip_prefix(CONDITION_REF_PREFIX) {
827 ConditionExpr::ConditionRef(name.to_string())
828 } else {
829 ConditionExpr::Invalid
833 }
834 }
835 _ => {
836 if let Some(entries) = arena.as_map(node_ref)
838 && entries.len() == 1
839 {
840 let (key, val) = &entries[0];
841 match key.as_str() {
842 FN_EQUALS => {
843 if let Some(arr) = arena.as_list(*val)
844 && arr.len() == 2
845 {
846 let va = parse_value_expr(arena, arr[0], parameters);
847 let vb = parse_value_expr(arena, arr[1], parameters);
848 return ConditionExpr::Equals(va, vb);
849 }
850 }
851 FN_AND => {
852 if let Some(arr) = arena.as_list(*val) {
853 let exprs = arr.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
854 return ConditionExpr::And(exprs);
855 }
856 }
857 FN_OR => {
858 if let Some(arr) = arena.as_list(*val) {
859 let exprs = arr.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
860 return ConditionExpr::Or(exprs);
861 }
862 }
863 FN_NOT => {
864 if let Some(arr) = arena.as_list(*val)
865 && !arr.is_empty()
866 {
867 let expr = parse_condition_expr(arena, arr[0], parameters);
868 return ConditionExpr::Not(Box::new(expr));
869 }
870 }
871 FN_CONDITION => {
872 if let Some(name) = arena.as_str(*val) {
873 return ConditionExpr::ConditionRef(name.to_string());
874 }
875 }
876 _ => {}
877 }
878 }
879 ConditionExpr::Invalid
880 }
881 }
882}
883
884fn parse_value_expr(arena: &Arena, node_ref: NodeRef, parameters: &HashMap<String, ParameterInfo>) -> ValueExpr {
885 match arena.node(node_ref) {
886 Node::String(s) => ValueExpr::Literal(s.clone()),
887 Node::Int(i) => ValueExpr::Literal(i.to_string()),
888 Node::Bool(b) => ValueExpr::Literal(b.to_string()),
889 Node::Intrinsic(IntrinsicFn::Ref(target)) => {
890 if target.starts_with(PSEUDO_PREFIX) {
891 ValueExpr::PseudoParam(target.clone())
892 } else if parameters.contains_key(target) {
893 ValueExpr::ParamRef(target.clone())
894 } else {
895 ValueExpr::Other
896 }
897 }
898 Node::Intrinsic(IntrinsicFn::FindInMap(m, k1, k2, _)) => {
906 let map_name = arena.as_str(*m).unwrap_or("?").to_string();
907 let key1 = parse_value_expr(arena, *k1, parameters);
908 let key2 = parse_value_expr(arena, *k2, parameters);
909 ValueExpr::MappingLookup { map_name, key1: Box::new(key1), key2: Box::new(key2) }
910 }
911 _ => ValueExpr::Other,
912 }
913}
914
915fn collect_equals_pairs(expr: &ConditionExpr, out: &mut HashMap<String, Vec<String>>) {
921 match expr {
922 ConditionExpr::Equals(a, b) => {
923 if let Some((symbol, literal)) = match_symbol_literal_pair(a, b) {
924 out.entry(symbol).or_default().push(literal);
925 } else {
926 collect_param_refs_from_value_into_pairs(a, out);
927 collect_param_refs_from_value_into_pairs(b, out);
928 }
929 }
930 ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
931 for e in exprs {
932 collect_equals_pairs(e, out);
933 }
934 }
935 ConditionExpr::Not(e) => collect_equals_pairs(e, out),
936 ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
937 }
938}
939
940fn match_symbol_literal_pair(a: &ValueExpr, b: &ValueExpr) -> Option<(String, String)> {
944 match (a, b) {
945 (ValueExpr::ParamRef(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::ParamRef(p)) => {
946 Some((p.clone(), v.clone()))
947 }
948 (ValueExpr::PseudoParam(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::PseudoParam(p)) => {
949 Some((p.clone(), v.clone()))
950 }
951 _ => None,
952 }
953}
954
955fn collect_param_refs_from_value_into_pairs(expr: &ValueExpr, out: &mut HashMap<String, Vec<String>>) {
956 match expr {
957 ValueExpr::ParamRef(p) | ValueExpr::PseudoParam(p) => {
958 out.entry(p.clone()).or_default();
959 }
960 ValueExpr::MappingLookup { key1, key2, .. } => {
961 collect_param_refs_from_value_into_pairs(key1, out);
962 collect_param_refs_from_value_into_pairs(key2, out);
963 }
964 _ => {}
965 }
966}
967
968fn collect_condition_refs(expr: &ConditionExpr, out: &mut Vec<String>) {
969 let mut borrowed = Vec::new();
970 condition_ref_names(expr, &mut borrowed);
971 out.extend(borrowed.into_iter().map(str::to_string));
972}
973
974pub fn collect_condition_deps(expr: &ConditionExpr, out: &mut Vec<String>) {
975 collect_condition_refs(expr, out);
976}
977
978fn extract_mutex_groups(conditions: &HashMap<String, ConditionExpr>) -> Vec<MutexGroup> {
979 let mut param_tests: HashMap<String, Vec<(String, String)>> = HashMap::new(); for (name, expr) in conditions {
984 if let Some((param, _lit, is_positive)) = extract_equals_test(expr) {
985 if is_positive {
989 param_tests.entry(param).or_default().push((name.clone(), _lit));
990 }
991 }
992 }
993
994 param_tests
995 .into_iter()
996 .filter(|(_, tests)| tests.len() > 1)
997 .map(|(param, tests)| {
998 let conditions = tests.iter().map(|(n, _)| n.clone()).collect();
999 let values = tests.iter().map(|(_, v)| v.clone()).collect();
1000 MutexGroup { conditions, parameter: param, values }
1001 })
1002 .collect()
1003}
1004
1005fn extract_equals_test(expr: &ConditionExpr) -> Option<(String, String, bool)> {
1006 match expr {
1007 ConditionExpr::Equals(a, b) => {
1008 if let (ValueExpr::ParamRef(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::ParamRef(p)) =
1009 (a, b)
1010 {
1011 return Some((p.clone(), v.clone(), true));
1012 }
1013 None
1014 }
1015 ConditionExpr::Not(inner) => {
1016 if let Some((param, lit, positive)) = extract_equals_test(inner) {
1017 Some((param, lit, !positive))
1018 } else {
1019 None
1020 }
1021 }
1022 _ => None,
1023 }
1024}
1025
1026struct SatisfiabilityQuery<'model> {
1051 model: &'model ConditionModel,
1052 dependencies: Vec<&'model str>,
1057 position_of_dependency: HashMap<&'model str, usize>,
1058 parameters: Vec<(&'model str, &'model [String])>,
1063 position_of_parameter: HashMap<&'model str, usize>,
1064 selected_value: Vec<usize>,
1066 bound_depth: usize,
1070 rule_constraints: Vec<(usize, usize)>,
1073 value_at_point: Vec<PointValue>,
1074 forced_value: Vec<Option<bool>>,
1081 forcings_made: u64,
1085 being_evaluated: Vec<bool>,
1086 provisional_reads: u64,
1091 steps: u64,
1094}
1095
1096#[derive(Clone, Copy, PartialEq, Eq)]
1098enum PointValue {
1099 NotEvaluated,
1100 Determined(bool),
1101 Undetermined,
1105}
1106
1107struct PointEnumeration<'query> {
1110 expectations: &'query [(usize, bool)],
1112 dependency_masks: &'query [u64],
1114 unbound_masks: &'query [u64],
1117}
1118
1119impl<'model> SatisfiabilityQuery<'model> {
1120 fn prepare(
1121 model: &'model ConditionModel,
1122 assumed: &HashMap<&'model str, bool>,
1123 param_overrides: &'model HashMap<String, Vec<String>>,
1124 ) -> Self {
1125 let mut assumed_names: Vec<&'model str> = assumed.keys().copied().collect();
1126 assumed_names.sort_unstable();
1127
1128 let mut query = SatisfiabilityQuery {
1129 model,
1130 dependencies: Vec::new(),
1131 position_of_dependency: HashMap::new(),
1132 parameters: Vec::new(),
1133 position_of_parameter: HashMap::new(),
1134 selected_value: Vec::new(),
1135 bound_depth: 0,
1136 rule_constraints: Vec::new(),
1137 value_at_point: Vec::new(),
1138 forced_value: Vec::new(),
1139 forcings_made: 0,
1140 being_evaluated: Vec::new(),
1141 provisional_reads: 0,
1142 steps: 0,
1143 };
1144 query.add_dependencies_reachable_from(&assumed_names);
1145 query.add_parameters_of_dependencies(param_overrides);
1146 query.add_applicable_rule_constraints(param_overrides);
1147 query.order_parameters_by_reader_count();
1148 query.selected_value = vec![0; query.parameters.len()];
1149 query.value_at_point = vec![PointValue::NotEvaluated; query.dependencies.len()];
1150 query.forced_value = vec![None; query.dependencies.len()];
1151 query.being_evaluated = vec![false; query.dependencies.len()];
1152 query
1153 }
1154
1155 fn order_parameters_by_reader_count(&mut self) {
1161 if self.parameters.len() < 2 {
1162 return;
1163 }
1164 let model = self.model;
1165 let mut reader_count: HashMap<&'model str, usize> = HashMap::new();
1166 let mut names: Vec<&'model str> = Vec::new();
1167 for dependency in &self.dependencies {
1168 let Some(expr) = model.conditions.get(*dependency) else {
1169 continue;
1170 };
1171 names.clear();
1172 parameter_names(expr, &mut names);
1173 names.sort_unstable();
1174 names.dedup();
1175 for name in &names {
1176 *reader_count.entry(name).or_default() += 1;
1177 }
1178 }
1179 self.steps += self.dependencies.len() as u64;
1180 self.parameters.sort_by(|(left, left_candidates), (right, right_candidates)| {
1184 (left_candidates.len() > 1)
1185 .cmp(&(right_candidates.len() > 1))
1186 .then_with(|| {
1187 let left_readers = reader_count.get(left).copied().unwrap_or_default();
1188 let right_readers = reader_count.get(right).copied().unwrap_or_default();
1189 right_readers.cmp(&left_readers)
1190 })
1191 .then_with(|| left.cmp(right))
1192 });
1193 self.position_of_parameter = self.parameters.iter().enumerate().map(|(at, (name, _))| (*name, at)).collect();
1194 }
1195
1196 fn add_dependencies_reachable_from(&mut self, seeds: &[&'model str]) {
1201 let conditions = &self.model.conditions;
1202 let mut pending: Vec<&'model str> = seeds.to_vec();
1203 while let Some(name) = pending.pop() {
1204 let Some((dependency, expr)) = conditions.get_key_value(name) else {
1205 continue;
1206 };
1207 let dependency = dependency.as_str();
1208 if self.position_of_dependency.contains_key(dependency) {
1209 continue;
1210 }
1211 self.position_of_dependency.insert(dependency, self.dependencies.len());
1212 self.dependencies.push(dependency);
1213 self.steps += 1;
1214 condition_ref_names(expr, &mut pending);
1215 }
1216 }
1217
1218 fn add_parameters_of_dependencies(&mut self, param_overrides: &'model HashMap<String, Vec<String>>) {
1221 let conditions = &self.model.conditions;
1222 let mut names: Vec<&'model str> = Vec::new();
1223 for dependency in &self.dependencies {
1224 if let Some(expr) = conditions.get(*dependency) {
1225 parameter_names(expr, &mut names);
1226 }
1227 }
1228 names.sort_unstable();
1229 names.dedup();
1230 for name in names {
1231 self.add_parameter(name, param_overrides);
1232 }
1233 }
1234
1235 fn add_parameter(&mut self, name: &'model str, param_overrides: &'model HashMap<String, Vec<String>>) {
1241 if self.position_of_parameter.contains_key(name) {
1242 return;
1243 }
1244 let candidates = param_overrides
1245 .get(name)
1246 .or_else(|| self.model.referenced_param_values().get(name))
1247 .map_or(&[][..], Vec::as_slice);
1248 if candidates.is_empty() {
1249 return;
1250 }
1251 self.position_of_parameter.insert(name, self.parameters.len());
1252 self.parameters.push((name, candidates));
1253 self.steps += 1;
1254 }
1255
1256 fn add_applicable_rule_constraints(&mut self, param_overrides: &'model HashMap<String, Vec<String>>) {
1267 let model = self.model;
1268 if model.rule_implications.is_empty() {
1269 return;
1270 }
1271 let mut applied = vec![false; model.rule_implications.len()];
1272 loop {
1273 let mut newly_applied = false;
1274 for (position, implication) in model.rule_implications.iter().enumerate() {
1275 if applied[position] {
1276 continue;
1277 }
1278 let (Some((antecedent, _)), Some((consequent, _))) = (
1279 model.conditions.get_key_value(implication.antecedent.as_str()),
1280 model.conditions.get_key_value(implication.consequent.as_str()),
1281 ) else {
1282 applied[position] = true;
1283 continue;
1284 };
1285 let endpoints = [antecedent.as_str(), consequent.as_str()];
1286 let mut steps = 0;
1287 let constrained_parameters = reachable_parameter_names(&model.conditions, &endpoints, &mut steps);
1288 self.steps += steps;
1289 if !constrained_parameters.iter().any(|name| self.position_of_parameter.contains_key(name)) {
1290 continue;
1291 }
1292 applied[position] = true;
1293 newly_applied = true;
1294 self.add_dependencies_reachable_from(&endpoints);
1295 self.add_parameters_of_dependencies(param_overrides);
1296 self.rule_constraints
1297 .push((self.position_of_dependency[endpoints[0]], self.position_of_dependency[endpoints[1]]));
1298 }
1299 if !newly_applied {
1300 return;
1301 }
1302 }
1303 }
1304
1305 fn parameter_point_count(&self) -> u64 {
1307 self.parameters.iter().fold(1u64, |points, (_, candidates)| points.saturating_mul(candidates.len() as u64))
1308 }
1309
1310 fn some_point_satisfies(&mut self, assumed: &HashMap<&'model str, bool>) -> bool {
1320 let mut expectations: Vec<(usize, bool)> = assumed
1321 .iter()
1322 .filter_map(|(name, expected)| self.position_of_dependency.get(name).map(|&at| (at, *expected)))
1323 .collect();
1324 expectations.sort_unstable();
1325 let parameter_masks = self.varying_parameter_masks();
1326 let dependency_masks = self.dependency_parameter_masks(¶meter_masks);
1327 let mut unbound_masks = vec![0u64; self.parameters.len() + 1];
1328 for at in (0..self.parameters.len()).rev() {
1329 unbound_masks[at] = unbound_masks[at + 1] | parameter_masks[at];
1330 }
1331 self.bound_depth = self.parameters.iter().take_while(|(_, candidates)| candidates.len() < 2).count();
1335 let enumeration = PointEnumeration {
1336 expectations: &expectations,
1337 dependency_masks: &dependency_masks,
1338 unbound_masks: &unbound_masks,
1339 };
1340 self.extend_point(&enumeration)
1341 }
1342
1343 fn extend_point(&mut self, enumeration: &PointEnumeration<'_>) -> bool {
1346 self.steps += 1;
1347 if self.steps > MAX_SAT_ITERATIONS {
1348 return true;
1354 }
1355 if !self.assumptions_can_still_hold(enumeration.expectations) {
1356 return false;
1357 }
1358 let at = self.bound_depth;
1359 if at == self.parameters.len() {
1360 return true;
1361 }
1362 for candidate in 0..self.parameters[at].1.len() {
1363 self.selected_value[at] = candidate;
1364 self.discard_values_affected_by(enumeration.unbound_masks[at], enumeration.dependency_masks);
1367 self.bound_depth = at + 1;
1368 let satisfied = self.extend_point(enumeration);
1369 self.bound_depth = at;
1370 if satisfied {
1371 return true;
1372 }
1373 }
1374 false
1375 }
1376
1377 fn assumptions_can_still_hold(&mut self, expectations: &[(usize, bool)]) -> bool {
1392 self.forced_value.fill(None);
1393 loop {
1394 let forcings_before = self.forcings_made;
1395 for &(dependency, expected) in expectations {
1396 if !self.require_dependency(dependency, expected) {
1397 return false;
1398 }
1399 }
1400 if !self.rule_constraints_hold() {
1401 return false;
1402 }
1403 if self.forcings_made == forcings_before {
1404 return true;
1405 }
1406 }
1407 }
1408
1409 fn require_dependency(&mut self, dependency: usize, expected: bool) -> bool {
1413 self.steps += 1;
1414 if let Some(determined) = self.dependency_value(dependency) {
1415 return determined == expected;
1416 }
1417 match self.forced_value[dependency] {
1418 Some(already_forced) => return already_forced == expected,
1419 None => {
1420 self.forced_value[dependency] = Some(expected);
1421 self.forcings_made += 1;
1422 }
1423 }
1424 let Some(expr) = self.model.conditions.get(self.dependencies[dependency]) else {
1425 return true;
1426 };
1427 self.require(expr, expected)
1428 }
1429
1430 fn require(&mut self, expr: &'model ConditionExpr, expected: bool) -> bool {
1436 self.steps += 1;
1437 match expr {
1438 ConditionExpr::ConditionRef(name) => match self.position_of_dependency.get(name.as_str()).copied() {
1439 Some(dependency) => self.require_dependency(dependency, expected),
1440 None => true,
1442 },
1443 ConditionExpr::Not(operand) => self.require(operand, !expected),
1444 ConditionExpr::And(operands) if expected => operands.iter().all(|operand| self.require(operand, true)),
1445 ConditionExpr::Or(operands) if !expected => operands.iter().all(|operand| self.require(operand, false)),
1446 ConditionExpr::And(operands) => self.require_any(operands, false),
1447 ConditionExpr::Or(operands) => self.require_any(operands, true),
1448 ConditionExpr::Equals(_, _) | ConditionExpr::Invalid => true,
1451 }
1452 }
1453
1454 fn require_any(&mut self, operands: &'model [ConditionExpr], satisfying: bool) -> bool {
1459 let mut undecided: Option<&'model ConditionExpr> = None;
1460 let mut undecided_operands = 0usize;
1461 for operand in operands {
1462 match self.evaluate(operand) {
1463 Some(value) if value == satisfying => return true,
1464 Some(_) => {}
1465 None => {
1466 undecided_operands += 1;
1467 undecided = Some(operand);
1468 }
1469 }
1470 }
1471 match (undecided_operands, undecided) {
1472 (0, _) => false,
1473 (1, Some(last_candidate)) => self.require(last_candidate, satisfying),
1474 _ => true,
1475 }
1476 }
1477
1478 fn rule_constraints_hold(&mut self) -> bool {
1482 for at in 0..self.rule_constraints.len() {
1483 let (antecedent, consequent) = self.rule_constraints[at];
1484 if self.dependency_belief(antecedent) == Some(true) && !self.require_dependency(consequent, true) {
1485 return false;
1486 }
1487 if self.dependency_belief(consequent) == Some(false) && !self.require_dependency(antecedent, false) {
1488 return false;
1489 }
1490 }
1491 true
1492 }
1493
1494 fn varying_parameter_masks(&self) -> Vec<u64> {
1499 let mut masks = Vec::with_capacity(self.parameters.len());
1500 let mut next_bit = 0u32;
1501 for (_, candidates) in &self.parameters {
1502 if candidates.len() < 2 {
1503 masks.push(0);
1504 continue;
1505 }
1506 masks.push(1u64 << next_bit);
1507 next_bit = (next_bit + 1).min(u64::BITS - 1);
1508 }
1509 masks
1510 }
1511
1512 fn dependency_parameter_masks(&mut self, parameter_masks: &[u64]) -> Vec<u64> {
1518 let model = self.model;
1519 let mut masks = vec![0u64; self.dependencies.len()];
1520 let mut references: Vec<Vec<usize>> = vec![Vec::new(); self.dependencies.len()];
1521 let mut names: Vec<&'model str> = Vec::new();
1522 let mut referenced: Vec<&'model str> = Vec::new();
1523 for (at, dependency) in self.dependencies.iter().enumerate() {
1524 let Some(expr) = model.conditions.get(*dependency) else {
1525 continue;
1526 };
1527 names.clear();
1528 parameter_names(expr, &mut names);
1529 for name in &names {
1530 if let Some(&position) = self.position_of_parameter.get(*name) {
1531 masks[at] |= parameter_masks[position];
1532 }
1533 }
1534 referenced.clear();
1535 condition_ref_names(expr, &mut referenced);
1536 references[at] =
1537 referenced.iter().filter_map(|name| self.position_of_dependency.get(*name).copied()).collect();
1538 }
1539 self.steps += self.dependencies.len() as u64;
1540
1541 loop {
1544 let mut grew = false;
1545 for at in 0..masks.len() {
1546 let propagated = references[at].iter().fold(masks[at], |mask, &reference| mask | masks[reference]);
1547 if propagated != masks[at] {
1548 masks[at] = propagated;
1549 grew = true;
1550 }
1551 }
1552 self.steps += masks.len() as u64;
1553 if !grew {
1554 return masks;
1555 }
1556 }
1557 }
1558
1559 fn discard_values_affected_by(&mut self, changed_parameters: u64, dependency_masks: &[u64]) {
1562 for (at, value) in self.value_at_point.iter_mut().enumerate() {
1563 if dependency_masks[at] & changed_parameters != 0 {
1564 *value = PointValue::NotEvaluated;
1565 }
1566 }
1567 }
1568
1569 fn dependency_value(&mut self, dependency: usize) -> Option<bool> {
1574 match self.value_at_point[dependency] {
1575 PointValue::Determined(value) => return Some(value),
1576 PointValue::Undetermined => return None,
1577 PointValue::NotEvaluated => {}
1578 }
1579 if self.being_evaluated[dependency] {
1580 self.provisional_reads += 1;
1585 return None;
1586 }
1587 let Some(expr) = self.model.conditions.get(self.dependencies[dependency]) else {
1588 self.value_at_point[dependency] = PointValue::Undetermined;
1589 return None;
1590 };
1591 self.being_evaluated[dependency] = true;
1592 let provisional_reads_before = self.provisional_reads;
1593 let value = self.evaluate(expr);
1594 self.being_evaluated[dependency] = false;
1595 if self.provisional_reads == provisional_reads_before {
1596 self.value_at_point[dependency] = value.map_or(PointValue::Undetermined, PointValue::Determined);
1597 }
1598 value
1599 }
1600
1601 fn dependency_belief(&mut self, dependency: usize) -> Option<bool> {
1604 if let Some(determined) = self.dependency_value(dependency) {
1605 return Some(determined);
1606 }
1607 let forced = self.forced_value[dependency];
1608 if forced.is_some() {
1609 self.provisional_reads += 1;
1610 }
1611 forced
1612 }
1613
1614 fn evaluate(&mut self, expr: &'model ConditionExpr) -> Option<bool> {
1620 self.steps += 1;
1626 match expr {
1627 ConditionExpr::Equals(left, right) => {
1628 let left = self.value_at_current_point(left)?;
1629 let right = self.value_at_current_point(right)?;
1630 Some(left == right)
1631 }
1632 ConditionExpr::And(operands) => {
1633 let mut all_determined = true;
1634 for operand in operands {
1635 match self.evaluate(operand) {
1636 Some(false) => return Some(false),
1637 Some(true) => {}
1638 None => all_determined = false,
1639 }
1640 }
1641 all_determined.then_some(true)
1642 }
1643 ConditionExpr::Or(operands) => {
1644 let mut all_determined = true;
1645 for operand in operands {
1646 match self.evaluate(operand) {
1647 Some(true) => return Some(true),
1648 Some(false) => {}
1649 None => all_determined = false,
1650 }
1651 }
1652 all_determined.then_some(false)
1653 }
1654 ConditionExpr::Not(operand) => self.evaluate(operand).map(|value| !value),
1655 ConditionExpr::ConditionRef(name) => {
1656 let dependency = self.position_of_dependency.get(name.as_str()).copied()?;
1659 self.dependency_belief(dependency)
1660 }
1661 ConditionExpr::Invalid => None,
1664 }
1665 }
1666
1667 fn value_at_current_point(&mut self, expr: &'model ValueExpr) -> Option<String> {
1670 match expr {
1671 ValueExpr::Literal(literal) => Some(literal.clone()),
1672 ValueExpr::ParamRef(name) => self.selected_parameter_value(name).map(str::to_string),
1673 ValueExpr::PseudoParam(name) => self
1674 .selected_parameter_value(name)
1675 .map(str::to_string)
1676 .or_else(|| self.model.pseudo_overrides.fixed_value(name)),
1677 ValueExpr::MappingLookup { map_name, key1, key2 } => {
1678 let top_level_key = self.value_at_current_point(key1)?;
1679 let second_level_key = self.value_at_current_point(key2)?;
1680 let mapped = self.model.mappings.get(map_name)?.get(&top_level_key)?.get(&second_level_key)?;
1681 match mapped {
1682 serde_json::Value::String(text) => Some(text.clone()),
1683 other => Some(other.to_string()),
1684 }
1685 }
1686 ValueExpr::Other => None,
1687 }
1688 }
1689
1690 fn selected_parameter_value(&self, name: &str) -> Option<&'model str> {
1693 let position = *self.position_of_parameter.get(name)?;
1694 if position >= self.bound_depth {
1695 return None;
1696 }
1697 self.parameters[position].1.get(self.selected_value[position]).map(String::as_str)
1698 }
1699}
1700
1701fn condition_ref_names<'expr>(expr: &'expr ConditionExpr, out: &mut Vec<&'expr str>) {
1704 match expr {
1705 ConditionExpr::ConditionRef(name) => out.push(name.as_str()),
1706 ConditionExpr::And(operands) | ConditionExpr::Or(operands) => {
1707 for operand in operands {
1708 condition_ref_names(operand, out);
1709 }
1710 }
1711 ConditionExpr::Not(operand) => condition_ref_names(operand, out),
1712 ConditionExpr::Equals(_, _) | ConditionExpr::Invalid => {}
1713 }
1714}
1715
1716fn parameter_names<'expr>(expr: &'expr ConditionExpr, out: &mut Vec<&'expr str>) {
1719 match expr {
1720 ConditionExpr::Equals(left, right) => {
1721 parameter_names_of_value(left, out);
1722 parameter_names_of_value(right, out);
1723 }
1724 ConditionExpr::And(operands) | ConditionExpr::Or(operands) => {
1725 for operand in operands {
1726 parameter_names(operand, out);
1727 }
1728 }
1729 ConditionExpr::Not(operand) => parameter_names(operand, out),
1730 ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
1731 }
1732}
1733
1734fn parameter_names_of_value<'expr>(expr: &'expr ValueExpr, out: &mut Vec<&'expr str>) {
1735 match expr {
1736 ValueExpr::ParamRef(name) | ValueExpr::PseudoParam(name) => out.push(name.as_str()),
1737 ValueExpr::MappingLookup { key1, key2, .. } => {
1738 parameter_names_of_value(key1, out);
1739 parameter_names_of_value(key2, out);
1740 }
1741 ValueExpr::Literal(_) | ValueExpr::Other => {}
1742 }
1743}
1744
1745fn reachable_parameter_names<'conditions>(
1748 conditions: &'conditions HashMap<String, ConditionExpr>,
1749 seeds: &[&'conditions str],
1750 steps: &mut u64,
1751) -> Vec<&'conditions str> {
1752 let mut visited: HashSet<&'conditions str> = HashSet::new();
1753 let mut pending: Vec<&'conditions str> = seeds.to_vec();
1754 let mut names = Vec::new();
1755 while let Some(name) = pending.pop() {
1756 if !visited.insert(name) {
1757 continue;
1758 }
1759 *steps += 1;
1760 if let Some(expr) = conditions.get(name) {
1761 parameter_names(expr, &mut names);
1762 condition_ref_names(expr, &mut pending);
1763 }
1764 }
1765 names.sort_unstable();
1766 names.dedup();
1767 names
1768}
1769
1770fn extract_implications(conditions: &HashMap<String, ConditionExpr>) -> Vec<Implication> {
1781 let mut implications = Vec::new();
1782
1783 for (name, expr) in conditions {
1784 match expr {
1785 ConditionExpr::And(children) => {
1786 let mut refs = Vec::new();
1787 collect_same_operator_refs(children, true, &mut refs);
1788 for ref_name in refs {
1789 implications.push(Implication { antecedent: name.clone(), consequent: ref_name });
1790 }
1791 }
1792 ConditionExpr::Or(children) => {
1793 let mut refs = Vec::new();
1794 collect_same_operator_refs(children, false, &mut refs);
1795 for ref_name in refs {
1796 implications.push(Implication { antecedent: ref_name, consequent: name.clone() });
1797 }
1798 }
1799 _ => {}
1800 }
1801 }
1802
1803 implications
1804}
1805
1806fn collect_same_operator_refs(exprs: &[ConditionExpr], in_and: bool, out: &mut Vec<String>) {
1810 for child in exprs {
1811 match child {
1812 ConditionExpr::ConditionRef(name) => out.push(name.clone()),
1813 ConditionExpr::And(children) if in_and => collect_same_operator_refs(children, in_and, out),
1814 ConditionExpr::Or(children) if !in_and => collect_same_operator_refs(children, in_and, out),
1815 _ => {}
1816 }
1817 }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822 use super::*;
1823 use crate::model::PseudoParameterOverrides;
1824 use crate::parser;
1825 use crate::resolver::{extract_mappings, extract_parameters};
1826 use std::fmt::Write;
1827
1828 fn build_condition_model(input: &str) -> ConditionModel {
1829 let ir = parser::parse(input.as_bytes()).unwrap();
1830 let (params, _) = extract_parameters(&ir);
1831 let (mappings, _) = extract_mappings(&ir);
1832 let pseudo = PseudoParameterOverrides::default();
1833 ConditionModel::from_ir(&ir, ¶ms, &pseudo, &mappings)
1834 }
1835
1836 #[test]
1837 fn parse_simple_conditions() {
1838 let input = r#"
1839Parameters:
1840 Env:
1841 Type: String
1842 AllowedValues:
1843 - Prod
1844 - Dev
1845Conditions:
1846 isProduction:
1847 Fn::Equals:
1848 - !Ref Env
1849 - Prod
1850 isDevelopment:
1851 Fn::Equals:
1852 - !Ref Env
1853 - Dev
1854Resources:
1855 R:
1856 Type: T
1857"#;
1858 let model = build_condition_model(input);
1859 assert_eq!(model.conditions.len(), 2);
1860 assert!(model.conditions.contains_key("isProduction"));
1861 assert!(model.conditions.contains_key("isDevelopment"));
1862 }
1863
1864 #[test]
1865 fn mutex_group_extraction() {
1866 let input = r#"
1867Parameters:
1868 Env:
1869 Type: String
1870 AllowedValues: [Prod, Dev]
1871Conditions:
1872 isProduction:
1873 Fn::Equals: [!Ref Env, Prod]
1874 isDevelopment:
1875 Fn::Equals: [!Ref Env, Dev]
1876Resources:
1877 R:
1878 Type: T
1879"#;
1880 let model = build_condition_model(input);
1881 assert!(!model.mutex_groups.is_empty());
1882 assert_eq!(model.mutex_groups[0].conditions.len(), 2);
1883 }
1884
1885 #[test]
1886 fn satisfiable_single_true() {
1887 let input = r#"
1888Parameters:
1889 Env:
1890 Type: String
1891 AllowedValues: [Prod, Dev]
1892Conditions:
1893 isProduction:
1894 Fn::Equals: [!Ref Env, Prod]
1895 isDevelopment:
1896 Fn::Equals: [!Ref Env, Dev]
1897Resources:
1898 R:
1899 Type: T
1900"#;
1901 let model = build_condition_model(input);
1902 assert!(model.is_satisfiable(&[("isProduction".into(), true)]));
1903 }
1904
1905 #[test]
1906 fn satisfiable_mutex_violation() {
1907 let input = r#"
1908Parameters:
1909 Env:
1910 Type: String
1911 AllowedValues: [Prod, Dev]
1912Conditions:
1913 isProduction:
1914 Fn::Equals: [!Ref Env, Prod]
1915 isDevelopment:
1916 Fn::Equals: [!Ref Env, Dev]
1917Resources:
1918 R:
1919 Type: T
1920"#;
1921 let model = build_condition_model(input);
1922 assert!(!model.conditions_compatible("isProduction", "isDevelopment"));
1923 }
1924
1925 #[test]
1926 fn resources_compatible_both_unconditional() {
1927 let input = r#"
1928Resources:
1929 R:
1930 Type: T
1931"#;
1932 let model = build_condition_model(input);
1933 assert!(model.resources_compatible(None, None));
1934 }
1935
1936 #[test]
1937 fn no_conditions_template() {
1938 let input = r#"{"Resources":{"R":{"Type":"T"}}}"#;
1939 let model = build_condition_model(input);
1940 assert!(model.conditions.is_empty());
1941 assert!(model.is_satisfiable(&[]));
1942 }
1943
1944 #[test]
1945 fn condition_implies_same_condition() {
1946 let input = r#"
1947Parameters:
1948 Env:
1949 Type: String
1950 AllowedValues: [Prod, Dev]
1951Conditions:
1952 isProduction:
1953 Fn::Equals: [!Ref Env, Prod]
1954Resources:
1955 R:
1956 Type: T
1957"#;
1958 let model = build_condition_model(input);
1959 assert!(model.condition_implies("isProduction", "isProduction"));
1961 }
1962
1963 #[test]
1964 fn is_satisfiable_contradictory_assumptions() {
1965 let input = r#"
1966Parameters:
1967 Env:
1968 Type: String
1969 AllowedValues: [Prod, Dev]
1970Conditions:
1971 isProduction:
1972 Fn::Equals: [!Ref Env, Prod]
1973Resources:
1974 R:
1975 Type: T
1976"#;
1977 let model = build_condition_model(input);
1978 assert!(!model.is_satisfiable(&[("isProduction".into(), true), ("isProduction".into(), false),]));
1980 }
1981
1982 #[test]
1983 fn condition_implies_mutex_conditions() {
1984 let input = r#"
1985Parameters:
1986 Env:
1987 Type: String
1988 AllowedValues: [Prod, Dev]
1989Conditions:
1990 isProduction:
1991 Fn::Equals: [!Ref Env, Prod]
1992 isDevelopment:
1993 Fn::Equals: [!Ref Env, Dev]
1994Resources:
1995 R:
1996 Type: T
1997"#;
1998 let model = build_condition_model(input);
1999 assert!(!model.condition_implies("isProduction", "isDevelopment"));
2001 assert!(!model.condition_implies("isDevelopment", "isProduction"));
2002 }
2003
2004 #[test]
2005 fn compatible_or_condition_without_allowed_values() {
2006 let input = r#"
2007Parameters:
2008 Env:
2009 Type: String
2010Conditions:
2011 IsProd:
2012 Fn::Equals: [!Ref Env, Prod]
2013 IsProdOrStage:
2014 Fn::Or:
2015 - Condition: IsProd
2016 - Fn::Equals: [!Ref Env, Stage]
2017Resources:
2018 R:
2019 Type: T
2020"#;
2021 let model = build_condition_model(input);
2022 assert!(model.conditions_compatible("IsProd", "IsProdOrStage"));
2023 assert!(model.condition_implies("IsProd", "IsProdOrStage"));
2024 assert!(!model.condition_implies("IsProdOrStage", "IsProd"));
2025 }
2026
2027 #[test]
2028 fn tautological_equals_literal_match() {
2029 let input = r#"
2030Conditions:
2031 AlwaysTrue:
2032 Fn::Equals: ["same", "same"]
2033 AlwaysFalse:
2034 Fn::Equals: ["a", "b"]
2035Resources:
2036 R:
2037 Type: T
2038"#;
2039 let model = build_condition_model(input);
2040 let taut = model.tautological_equals();
2041 assert!(taut.iter().any(|(n, v)| n == "AlwaysTrue" && *v));
2042 assert!(taut.iter().any(|(n, v)| n == "AlwaysFalse" && !*v));
2043 }
2044
2045 #[test]
2046 fn tautological_equals_nested_in_and() {
2047 let input = r#"
2048Parameters:
2049 Env:
2050 Type: String
2051Conditions:
2052 Nested:
2053 Fn::And:
2054 - Fn::Equals: ["x", "x"]
2055 - Fn::Equals: [!Ref Env, Prod]
2056Resources:
2057 R:
2058 Type: T
2059"#;
2060 let model = build_condition_model(input);
2061 let taut = model.tautological_equals();
2062 assert!(
2063 taut.iter().any(|(n, v)| n == "Nested" && *v),
2064 "should detect tautological Equals inside And: {:?}",
2065 taut
2066 );
2067 }
2068
2069 #[test]
2070 fn referenced_params_collected() {
2071 let input = r#"
2072Parameters:
2073 Env:
2074 Type: String
2075 Region:
2076 Type: String
2077Conditions:
2078 IsProd:
2079 Fn::Equals: [!Ref Env, Prod]
2080 IsUsEast:
2081 Fn::Equals: [!Ref Region, us-east-1]
2082Resources:
2083 R:
2084 Type: T
2085"#;
2086 let model = build_condition_model(input);
2087 let refs = model.referenced_params();
2088 assert!(refs.contains(&"Env".to_string()));
2089 assert!(refs.contains(&"Region".to_string()));
2090 assert_eq!(refs.len(), 2);
2091 }
2092
2093 #[test]
2094 fn and_implication_extracted() {
2095 let input = r#"
2096Parameters:
2097 Env:
2098 Type: String
2099 DB:
2100 Type: String
2101 AllowedValues: [yes, no]
2102Conditions:
2103 IsProd:
2104 Fn::Equals: [!Ref Env, Prod]
2105 CreateDB:
2106 Fn::Equals: [!Ref DB, yes]
2107 ProdAndDB:
2108 Fn::And:
2109 - Condition: IsProd
2110 - Condition: CreateDB
2111Resources:
2112 R:
2113 Type: T
2114"#;
2115 let model = build_condition_model(input);
2116 assert!(model.implications.iter().any(|i| i.antecedent == "ProdAndDB" && i.consequent == "IsProd"));
2118 assert!(model.implications.iter().any(|i| i.antecedent == "ProdAndDB" && i.consequent == "CreateDB"));
2119 }
2120
2121 #[test]
2122 fn or_implication_extracted() {
2123 let input = r#"
2124Parameters:
2125 Env:
2126 Type: String
2127 AllowedValues: [Prod, Dev, Stage]
2128Conditions:
2129 IsProd:
2130 Fn::Equals: [!Ref Env, Prod]
2131 IsDev:
2132 Fn::Equals: [!Ref Env, Dev]
2133 ProdOrDev:
2134 Fn::Or:
2135 - Condition: IsProd
2136 - Condition: IsDev
2137Resources:
2138 R:
2139 Type: T
2140"#;
2141 let model = build_condition_model(input);
2142 assert!(model.implications.iter().any(|i| i.antecedent == "IsProd" && i.consequent == "ProdOrDev"));
2144 assert!(model.implications.iter().any(|i| i.antecedent == "IsDev" && i.consequent == "ProdOrDev"));
2145 }
2146
2147 #[test]
2148 fn three_way_mutex_group() {
2149 let input = r#"
2150Parameters:
2151 Env:
2152 Type: String
2153 AllowedValues: [Prod, Dev, Stage]
2154Conditions:
2155 IsProd:
2156 Fn::Equals: [!Ref Env, Prod]
2157 IsDev:
2158 Fn::Equals: [!Ref Env, Dev]
2159 IsStage:
2160 Fn::Equals: [!Ref Env, Stage]
2161Resources:
2162 R:
2163 Type: T
2164"#;
2165 let model = build_condition_model(input);
2166 assert_eq!(model.mutex_groups.len(), 1);
2167 assert_eq!(model.mutex_groups[0].conditions.len(), 3);
2168 assert!(!model.conditions_compatible("IsProd", "IsDev"));
2169 assert!(!model.conditions_compatible("IsProd", "IsStage"));
2170 assert!(!model.conditions_compatible("IsDev", "IsStage"));
2171 }
2172
2173 #[test]
2174 fn resources_compatible_one_unconditional() {
2175 let input = r#"
2176Parameters:
2177 Env:
2178 Type: String
2179 AllowedValues: [Prod, Dev]
2180Conditions:
2181 IsProd:
2182 Fn::Equals: [!Ref Env, Prod]
2183Resources:
2184 R:
2185 Type: T
2186"#;
2187 let model = build_condition_model(input);
2188 assert!(model.resources_compatible(Some("IsProd"), None));
2189 assert!(model.resources_compatible(None, Some("IsProd")));
2190 }
2191
2192 #[test]
2193 fn condition_names_iterator() {
2194 let input = r#"
2195Conditions:
2196 A:
2197 Fn::Equals: ["x", "x"]
2198 B:
2199 Fn::Equals: ["y", "y"]
2200Resources:
2201 R:
2202 Type: T
2203"#;
2204 let model = build_condition_model(input);
2205 let mut names: Vec<&str> = model.names().collect();
2206 names.sort();
2207 assert_eq!(names, vec!["A", "B"]);
2208 }
2209
2210 #[test]
2211 fn get_returns_none_for_unknown_condition() {
2212 let input = r#"{"Resources":{"R":{"Type":"T"}}}"#;
2213 let model = build_condition_model(input);
2214 assert!(model.get("NonExistent").is_none(), "unknown condition should return None");
2215 }
2216
2217 #[test]
2218 fn not_condition_parsed() {
2219 let input = r#"
2220Parameters:
2221 Env:
2222 Type: String
2223 AllowedValues: [Prod, Dev]
2224Conditions:
2225 IsProd:
2226 Fn::Equals: [!Ref Env, Prod]
2227 NotProd:
2228 Fn::Not:
2229 - Condition: IsProd
2230Resources:
2231 R:
2232 Type: T
2233"#;
2234 let model = build_condition_model(input);
2235 assert!(!model.conditions_compatible("IsProd", "NotProd"));
2236 assert!(!model.condition_implies("IsProd", "NotProd"));
2237 }
2238
2239 #[test]
2240 fn pseudo_param_without_override_is_a_free_variable_in_sat_solver() {
2241 let input = r#"
2242Parameters:
2243 Env:
2244 Type: String
2245 AllowedValues: [Prod, Dev]
2246Conditions:
2247 IsUsEast1:
2248 Fn::Equals: [!Ref "AWS::Region", us-east-1]
2249 IsProd:
2250 Fn::Equals: [!Ref Env, Prod]
2251Resources:
2252 R:
2253 Type: T
2254"#;
2255 let model = build_condition_model(input);
2256 assert!(
2257 model.is_satisfiable(&[("IsUsEast1".into(), true)]),
2258 "IsUsEast1=true must be reachable: AWS::Region can equal 'us-east-1'"
2259 );
2260 assert!(
2261 model.is_satisfiable(&[("IsUsEast1".into(), false)]),
2262 "IsUsEast1=false must be reachable: AWS::Region can equal anything other than 'us-east-1' \
2263 (the auto-derived default region must not pin the pseudo-parameter to a constant - that \
2264 produced false-positive W1028 unreachable-branch diagnostics on partition/region branches)"
2265 );
2266 assert!(
2267 model.is_satisfiable(&[("IsProd".into(), true)]),
2268 "IsProd=true must be reachable: Env can equal 'Prod'"
2269 );
2270 assert!(
2271 model.is_satisfiable(&[("IsProd".into(), false)]),
2272 "IsProd=false must be reachable: Env can equal 'Dev'"
2273 );
2274 }
2275
2276 #[test]
2277 fn is_satisfiable_in_region_pins_aws_region() {
2278 let input = r#"
2279Conditions:
2280 IsUsEast1:
2281 Fn::Equals: [!Ref "AWS::Region", us-east-1]
2282Resources:
2283 R:
2284 Type: T
2285"#;
2286 let model = build_condition_model(input);
2287 assert!(
2292 model.is_satisfiable_in_region(&[("IsUsEast1".into(), true)], "us-east-1"),
2293 "IsUsEast1=true must hold when the target region IS us-east-1"
2294 );
2295 assert!(
2296 !model.is_satisfiable_in_region(&[("IsUsEast1".into(), true)], "us-west-2"),
2297 "IsUsEast1=true must be unsatisfiable when the target region is us-west-2"
2298 );
2299 assert!(
2301 model.is_satisfiable_in_region(&[("IsUsEast1".into(), false)], "us-west-2"),
2302 "IsUsEast1=false must hold at us-west-2"
2303 );
2304 }
2305
2306 #[test]
2307 fn pseudo_param_explicit_override_pins_value_in_sat_solver() {
2308 let ir = parser::parse(
2309 r#"
2310Conditions:
2311 IsAwsPartition:
2312 Fn::Equals: [!Ref "AWS::Partition", aws]
2313Resources:
2314 R:
2315 Type: T
2316"#
2317 .as_bytes(),
2318 )
2319 .unwrap();
2320 let (params, _) = extract_parameters(&ir);
2321 let (mappings, _) = extract_mappings(&ir);
2322 let pseudo = PseudoParameterOverrides { partition: Some("aws-cn".to_string()), ..Default::default() };
2323
2324 let model = ConditionModel::from_ir(&ir, ¶ms, &pseudo, &mappings);
2325
2326 assert!(
2327 !model.is_satisfiable(&[("IsAwsPartition".into(), true)]),
2328 "with partition pinned to aws-cn, IsAwsPartition=true must be unsatisfiable"
2329 );
2330 assert!(
2331 model.is_satisfiable(&[("IsAwsPartition".into(), false)]),
2332 "with partition pinned to aws-cn, IsAwsPartition=false must hold"
2333 );
2334 }
2335
2336 #[test]
2337 fn pseudo_param_custom_region_changes_satisfiability() {
2338 let ir = parser::parse(
2339 r#"
2340Conditions:
2341 IsUsEast1:
2342 Fn::Equals: [!Ref "AWS::Region", us-east-1]
2343Resources:
2344 R:
2345 Type: T
2346"#
2347 .as_bytes(),
2348 )
2349 .unwrap();
2350 let (params, _) = extract_parameters(&ir);
2351 let (mappings, _) = extract_mappings(&ir);
2352 let pseudo = PseudoParameterOverrides { region: Some("eu-west-1".to_string()), ..Default::default() };
2353 let model = ConditionModel::from_ir(&ir, ¶ms, &pseudo, &mappings);
2354 assert!(!model.is_satisfiable(&[("IsUsEast1".into(), true)]));
2356 assert!(model.is_satisfiable(&[("IsUsEast1".into(), false)]));
2357 }
2358
2359 #[test]
2360 fn pseudo_param_partition_false_branch_is_reachable_without_override() {
2361 let input = r#"
2362Conditions:
2363 HasEcrPublic:
2364 Fn::Equals: [!Ref "AWS::Partition", aws]
2365Resources:
2366 R:
2367 Type: T
2368"#;
2369 let model = build_condition_model(input);
2370
2371 assert!(
2372 model.is_satisfiable(&[("HasEcrPublic".into(), true)]),
2373 "HasEcrPublic=true must be reachable: AWS::Partition can equal 'aws'"
2374 );
2375 assert!(
2376 model.is_satisfiable(&[("HasEcrPublic".into(), false)]),
2377 "HasEcrPublic=false must be reachable: AWS::Partition can equal 'aws-cn' or 'aws-us-gov'"
2378 );
2379 }
2380
2381 #[test]
2382 fn mapping_lookup_resolves_in_condition() {
2383 let input = r#"
2384Parameters:
2385 Env:
2386 Type: String
2387 AllowedValues: [Prod, Dev]
2388Mappings:
2389 EnvMap:
2390 Prod:
2391 Label: production
2392 Dev:
2393 Label: development
2394Conditions:
2395 IsProdLabel:
2396 Fn::Equals:
2397 - !FindInMap [EnvMap, !Ref Env, Label]
2398 - production
2399Resources:
2400 R:
2401 Type: T
2402"#;
2403 let model = build_condition_model(input);
2404 assert!(model.is_satisfiable(&[("IsProdLabel".into(), true)]));
2407 assert!(model.is_satisfiable(&[("IsProdLabel".into(), false)]));
2408 }
2409
2410 #[test]
2411 fn mapping_lookup_format_value_expr_readable() {
2412 let expr = ValueExpr::MappingLookup {
2413 map_name: "MyMap".to_string(),
2414 key1: Box::new(ValueExpr::ParamRef("Env".to_string())),
2415 key2: Box::new(ValueExpr::Literal("Label".to_string())),
2416 };
2417 let formatted = format_value_expr(&expr);
2418 assert!(formatted.contains("FindInMap"));
2419 assert!(formatted.contains("MyMap"));
2420 }
2421
2422 #[test]
2423 fn mapping_lookup_param_refs_collected() {
2424 let input = r#"
2425Parameters:
2426 Env:
2427 Type: String
2428 AllowedValues: [Prod, Dev]
2429Mappings:
2430 EnvMap:
2431 Prod:
2432 Label: production
2433 Dev:
2434 Label: development
2435Conditions:
2436 IsProdLabel:
2437 Fn::Equals:
2438 - !FindInMap [EnvMap, !Ref Env, Label]
2439 - production
2440Resources:
2441 R:
2442 Type: T
2443"#;
2444 let model = build_condition_model(input);
2445 let refs = model.referenced_params();
2446 assert!(refs.contains(&"Env".to_string()));
2447 }
2448
2449 #[test]
2450 fn extract_equals_test_unwraps_not() {
2451 let expr = ConditionExpr::Not(Box::new(ConditionExpr::Equals(
2452 ValueExpr::ParamRef("Env".into()),
2453 ValueExpr::Literal("Prod".into()),
2454 )));
2455 let result = extract_equals_test(&expr);
2456 let (param, lit, positive) = result.expect("extract_equals_test should return Some for Not(Equals)");
2457 assert_eq!(param, "Env");
2458 assert_eq!(lit, "Prod");
2459 assert!(!positive);
2460 }
2461
2462 #[test]
2463 fn nested_and_implication_extracts_deep_condition_refs() {
2464 let input = r#"
2465Parameters:
2466 A:
2467 Type: String
2468 AllowedValues: [yes, no]
2469 B:
2470 Type: String
2471 AllowedValues: [yes, no]
2472 C:
2473 Type: String
2474 AllowedValues: [yes, no]
2475Conditions:
2476 CondA:
2477 Fn::Equals: [!Ref A, yes]
2478 CondB:
2479 Fn::Equals: [!Ref B, yes]
2480 CondC:
2481 Fn::Equals: [!Ref C, yes]
2482 NestedAnd:
2483 Fn::And:
2484 - Fn::And:
2485 - Condition: CondA
2486 - Condition: CondB
2487 - Condition: CondC
2488Resources:
2489 R:
2490 Type: T
2491"#;
2492 let model = build_condition_model(input);
2493 assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondA"));
2495 assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondB"));
2496 assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondC"));
2497 }
2498
2499 #[test]
2500 fn nested_or_implication_extracts_deep_condition_refs() {
2501 let input = r#"
2502Parameters:
2503 A:
2504 Type: String
2505 AllowedValues: [yes, no]
2506 B:
2507 Type: String
2508 AllowedValues: [yes, no]
2509Conditions:
2510 CondA:
2511 Fn::Equals: [!Ref A, yes]
2512 CondB:
2513 Fn::Equals: [!Ref B, yes]
2514 NestedOr:
2515 Fn::Or:
2516 - Fn::Or:
2517 - Condition: CondA
2518 - Condition: CondB
2519Resources:
2520 R:
2521 Type: T
2522"#;
2523 let model = build_condition_model(input);
2524 assert!(model.implications.iter().any(|i| i.antecedent == "CondA" && i.consequent == "NestedOr"));
2526 assert!(model.implications.iter().any(|i| i.antecedent == "CondB" && i.consequent == "NestedOr"));
2527 }
2528
2529 #[test]
2530 fn register_inline_adds_condition_and_rebuilds_mutex() {
2531 let input = r#"
2532Parameters:
2533 Env:
2534 Type: String
2535 AllowedValues: [Prod, Dev]
2536Conditions:
2537 IsProd:
2538 Fn::Equals: [!Ref Env, Prod]
2539Resources:
2540 R:
2541 Type: T
2542"#;
2543 let mut model = build_condition_model(input);
2544 assert_eq!(model.conditions.len(), 1);
2545 model.register_inline(
2547 "__inline_1".to_string(),
2548 ConditionExpr::Equals(ValueExpr::ParamRef("Env".to_string()), ValueExpr::Literal("Dev".to_string())),
2549 );
2550 assert_eq!(model.conditions.len(), 2);
2551 assert!(!model.mutex_groups.is_empty());
2553 assert!(!model.conditions_compatible("IsProd", "__inline_1"));
2554 }
2555
2556 #[test]
2557 fn ifexpr_literal_equals_eagerly_evaluated() {
2558 let input = r#"{
2560 "Conditions": {"C": {"Fn::Equals": ["x", "x"]}},
2561 "Resources": {
2562 "R": {
2563 "Type": "T",
2564 "Properties": {
2565 "V": {"Fn::If": [{"Fn::Equals": ["same", "same"]}, "yes", "no"]}
2566 }
2567 }
2568 }
2569}"#;
2570 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2571 match model.resolve("R", "Properties.V") {
2573 Some(crate::resolver::ResolvedValue::Concrete { value: v }) => {
2574 assert_eq!(v.as_str().unwrap(), "yes");
2575 }
2576 other => panic!("Expected Concrete(\"yes\") from eager eval, got {:?}", other),
2577 }
2578 }
2579
2580 #[test]
2581 fn ifexpr_literal_not_equals_eagerly_evaluated_to_false_branch() {
2582 let input = r#"{
2583 "Conditions": {"C": {"Fn::Equals": ["x", "x"]}},
2584 "Resources": {
2585 "R": {
2586 "Type": "T",
2587 "Properties": {
2588 "V": {"Fn::If": [{"Fn::Equals": ["a", "b"]}, "yes", "no"]}
2589 }
2590 }
2591 }
2592}"#;
2593 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2594 match model.resolve("R", "Properties.V") {
2595 Some(crate::resolver::ResolvedValue::Concrete { value: v }) => {
2596 assert_eq!(v.as_str().unwrap(), "no");
2597 }
2598 other => panic!("Expected Concrete(\"no\") from eager eval, got {:?}", other),
2599 }
2600 }
2601
2602 #[test]
2603 fn ifexpr_non_literal_registers_inline_condition() {
2604 let input = r#"
2606Parameters:
2607 Env:
2608 Type: String
2609 AllowedValues: [Prod, Dev]
2610Resources:
2611 R:
2612 Type: T
2613 Properties:
2614 V:
2615 Fn::If:
2616 - Fn::Equals: [!Ref Env, Prod]
2617 - big
2618 - small
2619"#;
2620 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2621 let inline_count = model.conditions.conditions.keys().filter(|k| k.starts_with("__inline_cond_")).count();
2623 assert!(inline_count > 0, "Expected at least one inline condition registered");
2624 }
2625
2626 fn chain_with_contradiction(chain_len: usize) -> String {
2638 let mut s = String::from(
2639 "Parameters:\n P0:\n Type: String\n AllowedValues: [yes, no]\n \
2640 P1:\n Type: String\n AllowedValues: [yes, no]\n\
2641 Conditions:\n C000:\n Fn::Equals: [!Ref P0, yes]\n \
2642 C001:\n Fn::Equals: [!Ref P1, yes]\n",
2643 );
2644 for i in 2..chain_len {
2645 let outer_inner = if i % 2 == 0 { "Fn::Or" } else { "Fn::And" };
2647 let _ = write!(
2648 s,
2649 " C{:03}:\n Fn::Not:\n - {}:\n - Fn::Not:\n - Condition: C{:03}\n - Fn::Not:\n - Condition: C{:03}\n",
2650 i,
2651 outer_inner,
2652 i - 1,
2653 i - 2
2654 );
2655 }
2656 let top = format!("C{:03}", chain_len - 1);
2657 let _ = write!(
2658 s,
2659 " Contra:\n Fn::And:\n - Condition: {top}\n \
2660 - Fn::Not:\n - Condition: {top}\n\
2661 Resources:\n R:\n Type: T\n",
2662 );
2663 s
2664 }
2665
2666 #[test]
2667 fn contradiction_over_a_long_condition_chain_is_decided_exactly_and_cheaply() {
2668 const SHORT_CHAIN: usize = 8;
2679 const LONG_CHAIN: usize = 32;
2680 const DOUBLED_CHAIN: usize = 64;
2681
2682 for chain_length in [SHORT_CHAIN, LONG_CHAIN, DOUBLED_CHAIN] {
2683 let model = build_condition_model(&chain_with_contradiction(chain_length));
2684 assert!(
2685 !model.is_satisfiable(&[("Contra".to_string(), true)]),
2686 "a contradiction over a {chain_length}-condition chain must be proven \
2687 unsatisfiable, not conceded as satisfiable because the search was too expensive"
2688 );
2689 }
2690
2691 const MAX_GROWTH_FROM_DOUBLING: u64 = 4;
2696 let long_model = build_condition_model(&chain_with_contradiction(LONG_CHAIN));
2697 let _ = long_model.is_satisfiable(&[("Contra".to_string(), true)]);
2698 let doubled_model = build_condition_model(&chain_with_contradiction(DOUBLED_CHAIN));
2699 let _ = doubled_model.is_satisfiable(&[("Contra".to_string(), true)]);
2700 let long_chain_work = long_model.sat_iterations_used();
2701 let doubled_chain_work = doubled_model.sat_iterations_used();
2702 assert!(
2703 doubled_chain_work <= long_chain_work * MAX_GROWTH_FROM_DOUBLING,
2704 "doubling the chain length must not multiply the work by more than \
2705 {MAX_GROWTH_FROM_DOUBLING}x; {LONG_CHAIN} conditions cost {long_chain_work} steps and \
2706 {DOUBLED_CHAIN} cost {doubled_chain_work}"
2707 );
2708 assert!(
2709 doubled_chain_work < MAX_SAT_ITERATIONS,
2710 "deciding a chain of {DOUBLED_CHAIN} conditions over two binary parameters must stay \
2711 far inside the per-query budget; cost {doubled_chain_work} of {MAX_SAT_ITERATIONS}"
2712 );
2713 }
2714
2715 fn conditions_layered_over_shared_pseudo_parameters(layered: usize) -> String {
2720 const PARTITIONS: [&str; 4] = ["aws", "aws-us-gov", "aws-cn", "aws-iso"];
2721 const REGIONS: [&str; 4] = ["us-east-1", "us-west-2", "us-gov-west-1", "cn-north-1"];
2722 let mut template = String::from("Parameters:\n Stage:\n Type: String\nConditions:\n");
2723 let mut base_names = Vec::new();
2724 for (index, partition) in PARTITIONS.iter().enumerate() {
2725 let _ = write!(template, " IsPartition{index}:\n Fn::Equals: [!Ref 'AWS::Partition', {partition}]\n");
2726 base_names.push(format!("IsPartition{index}"));
2727 }
2728 for (index, region) in REGIONS.iter().enumerate() {
2729 let _ = write!(template, " IsRegion{index}:\n Fn::Equals: [!Ref 'AWS::Region', {region}]\n");
2730 base_names.push(format!("IsRegion{index}"));
2731 }
2732 let _ = write!(template, " IsProd:\n Fn::Equals: [!Ref Stage, prod]\n");
2733 base_names.push("IsProd".to_string());
2734
2735 let mut names = base_names.clone();
2736 for index in 0..layered {
2737 let operator = if index % 2 == 0 { "Fn::And" } else { "Fn::Or" };
2738 let operands: Vec<&String> = (0..3).map(|offset| &names[(index * 7 + offset * 3) % names.len()]).collect();
2739 let _ = write!(template, " Layered{index:03}:\n {operator}:\n");
2740 for operand in operands {
2741 let _ = writeln!(template, " - Condition: {operand}");
2742 }
2743 names.push(format!("Layered{index:03}"));
2744 }
2745 let _ = write!(
2746 template,
2747 "Resources:\n R:\n Type: AWS::SNS::Topic\n Condition: {}\n",
2748 names[names.len() - 1]
2749 );
2750 template
2751 }
2752
2753 #[test]
2754 fn conditions_layered_over_shared_inputs_are_analyzed_in_full() {
2755 const LAYERED_CONDITIONS: usize = 91;
2764 let model = build_condition_model(&conditions_layered_over_shared_pseudo_parameters(LAYERED_CONDITIONS));
2765
2766 let mut names: Vec<&str> = model.names().collect();
2767 names.sort_unstable();
2768 assert_eq!(names.len(), LAYERED_CONDITIONS + 9, "the fixture must reach the scale under test");
2769
2770 let mut decided_pairs = 0u64;
2771 for (offset, first) in names.iter().enumerate() {
2772 for second in &names[offset + 1..] {
2773 let _ = model.conditions_compatible(first, second);
2774 decided_pairs += 1;
2775 }
2776 }
2777
2778 assert!(
2779 !model.satisfiability_budget_exhausted(),
2780 "the full pairwise pass over {} conditions must fit in the cumulative budget; it spent \
2781 {} of {MAX_TOTAL_SAT_ITERATIONS} deciding {decided_pairs} pairs",
2782 names.len(),
2783 model.sat_iterations_used()
2784 );
2785 assert!(
2786 model.budget_exhausted_queries().is_empty(),
2787 "no query over conditions layered on shared inputs may exceed its budget: {:?}",
2788 model.budget_exhausted_queries()
2789 );
2790 assert!(
2794 !model.conditions_compatible("IsPartition0", "IsPartition1"),
2795 "conditions comparing the same pseudo-parameter with different values must be proven \
2796 incompatible"
2797 );
2798 }
2799
2800 #[test]
2801 fn cumulative_budget_exhaustion_is_reported_rather_than_silently_narrowing_analysis() {
2802 let model = build_condition_model(&chain_with_contradiction(8));
2807 assert!(model.budget_exhausted_queries().is_empty(), "nothing is curtailed before any work is charged");
2808
2809 model.add_sat_iterations_for_test(MAX_TOTAL_SAT_ITERATIONS);
2810
2811 assert!(model.satisfiability_budget_exhausted(), "the budget must register as spent");
2812 assert!(
2813 !model.budget_exhausted_queries().is_empty(),
2814 "spending the cumulative budget must be reported so a curtailed analysis is never silent"
2815 );
2816 }
2817
2818 #[test]
2819 fn self_referential_conditions_are_undetermined_rather_than_looping() {
2820 let model = build_condition_model(
2825 "Parameters:\n Env:\n Type: String\n AllowedValues: [prod, dev]\n\
2826 Conditions:\n Looping:\n Fn::And:\n - Condition: AlsoLooping\n \
2827 - Fn::Equals: [!Ref Env, prod]\n \
2828 AlsoLooping:\n Fn::Or:\n - Condition: Looping\n \
2829 - Fn::Equals: [!Ref Env, dev]\n\
2830 Resources:\n R:\n Type: T\n",
2831 );
2832 assert!(
2833 model.is_satisfiable(&[("Looping".to_string(), true)]),
2834 "a condition on a reference cycle cannot be decided, so it must be assumed satisfiable"
2835 );
2836 assert!(model.is_satisfiable(&[("Looping".to_string(), false)]), "the same holds for the negated assumption");
2837 assert!(
2838 model.sat_iterations_used() < MAX_SAT_ITERATIONS,
2839 "a cycle must be cut immediately, not explored until the budget runs out; cost {}",
2840 model.sat_iterations_used()
2841 );
2842 }
2843
2844 #[test]
2845 fn condition_over_an_unresolvable_value_does_not_constrain_a_query() {
2846 let model = build_condition_model(
2851 "Parameters:\n Subnets:\n Type: CommaDelimitedList\n \
2852 Env:\n Type: String\n AllowedValues: [prod, dev]\n\
2853 Conditions:\n \
2854 FirstSubnetIsReserved:\n Fn::Equals: [!Select [0, !Ref Subnets], reserved]\n \
2855 IsProd:\n Fn::Equals: [!Ref Env, prod]\n \
2856 IsDev:\n Fn::Equals: [!Ref Env, dev]\n \
2857 ReservedInProd:\n Fn::And:\n - Condition: FirstSubnetIsReserved\n \
2858 - Condition: IsProd\n\
2859 Resources:\n R:\n Type: T\n",
2860 );
2861 assert!(
2862 model.is_satisfiable(&[("FirstSubnetIsReserved".to_string(), true)]),
2863 "an unresolvable comparison must be assumed able to hold"
2864 );
2865 assert!(
2866 model.conditions_compatible("ReservedInProd", "IsProd"),
2867 "an unresolvable operand must not make a combined condition look impossible"
2868 );
2869 assert!(
2870 !model.conditions_compatible("ReservedInProd", "IsDev"),
2871 "the resolvable operand must still constrain the combination: the And requires prod, \
2872 which excludes dev"
2873 );
2874 }
2875
2876 #[test]
2877 fn conditions_sharing_a_parameter_through_derived_conditions_stay_exact() {
2878 let model = build_condition_model(
2883 "Parameters:\n Env:\n Type: String\n AllowedValues: [prod, gamma, dev]\n \
2884 Feature:\n Type: String\n AllowedValues: ['on', 'off']\n\
2885 Conditions:\n \
2886 IsProd:\n Fn::Equals: [!Ref Env, prod]\n \
2887 IsDev:\n Fn::Equals: [!Ref Env, dev]\n \
2888 FeatureOn:\n Fn::Equals: [!Ref Feature, 'on']\n \
2889 ProdWithFeature:\n Fn::And:\n - Condition: IsProd\n - Condition: FeatureOn\n \
2890 DevWithFeature:\n Fn::And:\n - Condition: IsDev\n - Condition: FeatureOn\n \
2891 EitherWithFeature:\n Fn::Or:\n - Condition: ProdWithFeature\n \
2892 - Condition: DevWithFeature\n\
2893 Resources:\n R:\n Type: T\n",
2894 );
2895 assert!(
2896 !model.conditions_compatible("ProdWithFeature", "DevWithFeature"),
2897 "conditions requiring different values of the same parameter cannot both hold, however \
2898 many references separate them from that parameter"
2899 );
2900 assert!(
2901 model.conditions_compatible("ProdWithFeature", "EitherWithFeature"),
2902 "a condition and a disjunction it satisfies must remain compatible"
2903 );
2904 assert!(
2905 model.condition_implies("ProdWithFeature", "FeatureOn"),
2906 "a conjunction must be proven to imply its operand"
2907 );
2908 assert!(
2909 !model.condition_implies("EitherWithFeature", "IsProd"),
2910 "a disjunction must not be proven to imply only one of its branches"
2911 );
2912 }
2913
2914 fn wide_parameter_closure(param_count: usize) -> String {
2919 let mut s = String::from("Parameters:\n");
2920 for i in 0..param_count {
2921 let _ = write!(s, " P{i:02}:\n Type: String\n AllowedValues: [yes, no]\n");
2922 }
2923 s.push_str("Conditions:\n");
2924 for i in 0..param_count {
2925 let _ = write!(s, " Base{i:02}:\n Fn::Equals: [!Ref P{i:02}, yes]\n");
2926 }
2927 s.push_str(" Wide:\n Fn::And:\n");
2928 for i in 0..param_count {
2929 let _ = writeln!(s, " - Condition: Base{i:02}");
2930 }
2931 s.push_str("Resources:\n R:\n Type: T\n");
2932 s
2933 }
2934
2935 #[test]
2936 fn satisfiability_with_wide_parameter_closure_is_capped_conservatively() {
2937 const WIDE_PARAMS: usize = 24;
2941 let mut model = build_condition_model(&wide_parameter_closure(WIDE_PARAMS));
2942 let wide_expression = model.get("Wide").cloned().expect("the generated model must contain Wide");
2943 model.register_inline("__inline_cond_test".to_string(), wide_expression);
2944
2945 assert!(
2950 model.is_satisfiable(&[("Base00".to_string(), true), ("__inline_cond_test".to_string(), true)]),
2951 "a condition whose closure references more parameters than the cap must be \
2952 assumed satisfiable, not enumerated"
2953 );
2954 assert_eq!(
2955 model.budget_exhausted_queries(),
2956 vec!["<inline condition>=true, Base00=true".to_string()],
2957 "budget advisories must canonicalize assumption order and describe synthetic conditions generically"
2958 );
2959
2960 assert!(
2964 model.sat_iterations_used() < MAX_PARAM_COMBINATIONS,
2965 "the parameter cap must short-circuit before enumerating the cartesian product; \
2966 charged {} iterations, expected well under {MAX_PARAM_COMBINATIONS}",
2967 model.sat_iterations_used()
2968 );
2969 }
2970
2971 #[test]
2972 fn satisfiability_with_narrow_parameter_closure_stays_exact() {
2973 let model = build_condition_model(
2977 "Parameters:\n Env:\n Type: String\n AllowedValues: [prod, dev]\n\
2978 Conditions:\n IsProd:\n Fn::Equals: [!Ref Env, prod]\n \
2979 IsDev:\n Fn::Equals: [!Ref Env, dev]\n\
2980 Resources:\n R:\n Type: T\n",
2981 );
2982 assert!(
2983 !model.conditions_compatible("IsProd", "IsDev"),
2984 "mutually exclusive conditions over a small parameter space must remain provably \
2985 incompatible"
2986 );
2987 }
2988
2989 #[test]
2990 fn cumulative_satisfiability_budget_accumulates_across_queries_then_halts_search() {
2991 const CHAIN: usize = 8;
2996 let model = build_condition_model(&chain_with_contradiction(CHAIN));
2997
2998 assert_eq!(model.sat_iterations_used(), 0, "a freshly built model has spent none of its cumulative budget");
2999 assert!(!model.satisfiability_budget_exhausted(), "a freshly built model's cumulative budget is not exhausted");
3000
3001 let mut previous = 0u64;
3005 for _ in 0..3 {
3006 let _ = model.is_satisfiable(&[("Contra".to_string(), true)]);
3007 let used = model.sat_iterations_used();
3008 assert!(
3009 used > previous,
3010 "each query while under budget must add to the shared cumulative counter; a \
3011 per-query reset would be a silent denial-of-service regression. was {previous}, \
3012 now {used}"
3013 );
3014 previous = used;
3015 }
3016 assert!(
3017 !model.satisfiability_budget_exhausted(),
3018 "a handful of queries must not exhaust the (large) cumulative budget"
3019 );
3020
3021 let to_threshold = MAX_TOTAL_SAT_ITERATIONS - model.sat_iterations_used() - 1;
3027 model.add_sat_iterations_for_test(to_threshold);
3028 assert!(
3029 !model.satisfiability_budget_exhausted(),
3030 "one iteration short of the cap must not be exhausted; counter is {}",
3031 model.sat_iterations_used()
3032 );
3033 model.add_sat_iterations_for_test(1);
3034 assert!(
3035 model.satisfiability_budget_exhausted(),
3036 "reaching MAX_TOTAL_SAT_ITERATIONS must trip the exhausted flag; counter is {}",
3037 model.sat_iterations_used()
3038 );
3039
3040 let before_short_circuit = model.sat_iterations_used();
3043 let conservative = model.is_satisfiable(&[("Contra".to_string(), true)]);
3044 assert!(
3045 conservative,
3046 "a query issued after the cumulative budget is exhausted must return the conservative \
3047 satisfiable answer"
3048 );
3049 assert_eq!(
3050 model.sat_iterations_used(),
3051 before_short_circuit,
3052 "an exhausted-budget query must short-circuit without performing or charging further \
3053 search work"
3054 );
3055 }
3056
3057 #[test]
3058 fn rules_section_implication_eliminates_impossible_scenario() {
3059 let yaml = b"
3063Parameters:
3064 Env:
3065 Type: String
3066Conditions:
3067 IsProd: !Equals [!Ref Env, prod]
3068Rules:
3069 RejectProd:
3070 RuleCondition: !Equals [!Ref Env, prod]
3071 Assertions:
3072 - Assert: !Equals [!Ref Env, dev]
3073Resources:
3074 B:
3075 Type: AWS::S3::Bucket
3076 Condition: IsProd
3077";
3078 let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
3079 assert!(
3080 !model.conditions.is_satisfiable(&[("IsProd".to_string(), true)]),
3081 "IsProd=true contradicts the Rules assertion and must be unsatisfiable"
3082 );
3083 assert!(model.conditions.is_satisfiable(&[("IsProd".to_string(), false)]), "IsProd=false remains satisfiable");
3084 }
3085
3086 #[test]
3087 fn rules_without_rule_condition_do_not_constrain_unrelated_queries() {
3088 let yaml = b"
3091Parameters:
3092 Env:
3093 Type: String
3094 Other:
3095 Type: String
3096Conditions:
3097 UsesOther: !Equals [!Ref Other, x]
3098Rules:
3099 AlwaysDev:
3100 Assertions:
3101 - Assert: !Equals [!Ref Env, dev]
3102Resources:
3103 B:
3104 Type: AWS::S3::Bucket
3105 Condition: UsesOther
3106";
3107 let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
3108 assert!(model.conditions.is_satisfiable(&[("UsesOther".to_string(), true)]));
3109 assert!(model.conditions.is_satisfiable(&[("UsesOther".to_string(), false)]));
3110 }
3111
3112 #[test]
3113 fn mixed_nesting_produces_no_unsound_implications() {
3114 let yaml = b"
3120Parameters:
3121 Env:
3122 Type: String
3123 AllowedValues: [dev, prod]
3124 Flag:
3125 Type: String
3126 AllowedValues: ['true', 'false']
3127Conditions:
3128 A: !Equals [!Ref Env, prod]
3129 B: !Equals [!Ref Env, dev]
3130 C: !Equals [!Ref Flag, 'true']
3131 Complex: !And [!Or [!Condition A, !Condition B], !Not [!Condition C]]
3132 Inline: !And [!Condition B, !Condition C]
3133 NotA: !Not [!Condition A]
3134Resources:
3135 R:
3136 Type: AWS::S3::Bucket
3137 Condition: NotA
3138";
3139 let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
3140 assert!(
3141 !model.conditions.implications.iter().any(|i| i.antecedent == "Complex" || i.consequent == "Complex"),
3142 "no implication may involve the mixed-nesting condition: {:?}",
3143 model.conditions.implications
3144 );
3145 assert!(
3147 model.conditions.is_satisfiable(&[("NotA".to_string(), true), ("Inline".to_string(), false)]),
3148 "sound implications must not prune this satisfiable scenario"
3149 );
3150 }
3151}