Skip to main content

template_model/
conditions.rs

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
14/// How many budget-exhausted satisfiability queries are retained for the
15/// advisory diagnostic. The advisory tells an author that analysis of their
16/// condition set was cut short; a handful of examples conveys that, while
17/// retaining every query on a pathological template would itself grow unbounded.
18const MAX_REPORTED_BUDGET_EXHAUSTED_QUERIES: usize = 5;
19
20/// How the advisory describes analysis cut short by the cumulative budget rather
21/// than by one expensive query: from that point on, every remaining question about
22/// the condition set gets the conservative answer.
23const 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/// Result of a satisfiability query when callers need to distinguish an exact
29/// answer from the conservative answer returned after a search budget is spent.
30#[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    /// A condition body that does not produce a boolean (e.g. a bare `Fn::Ref`,
45    /// a scalar, or a value-producing function). CloudFormation rejects it; it is
46    /// reported as a not-a-boolean error and is deliberately opaque to the SAT
47    /// model and to the undefined-reference check (it is not a condition
48    /// reference).
49    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    /// Constraints a `Rules` section imposes on the parameters, as
81    /// `rule condition => assertion` pairs over synthetic conditions. Kept apart
82    /// from `implications` - which the `And`/`Or` structure of the conditions
83    /// entails and satisfiability therefore rederives on its own - because these
84    /// are external restrictions on what a deployment may supply and are the only
85    /// constraints the satisfiability search has to apply itself. Separate
86    /// storage also means recomputing the derived `implications` cannot discard
87    /// them.
88    rule_implications: Vec<Implication>,
89    parameter_overrides: HashMap<String, String>,
90    pseudo_overrides: PseudoParameterOverrides,
91    mappings: MappingData,
92    /// Cumulative satisfiability search steps consumed across every query for
93    /// this model. Shared (the model is reached through one `Arc`) so the
94    /// quadratic compatibility pass, scenario resolution, and rule-evaluation
95    /// builtins all draw down a single per-validation budget.
96    sat_iterations_used: AtomicU64,
97    /// Referenced parameters and their candidate values, derived from the
98    /// condition set and parameter definitions. Every satisfiability query reads
99    /// the candidate values of the parameters it depends on, so caching this
100    /// avoids rederiving the same map across queries. Computed once on first use
101    /// and invalidated by `register_inline` - the only path that mutates the
102    /// condition set after construction.
103    referenced_param_values: OnceLock<HashMap<String, Vec<String>>>,
104    budget_exhausted_queries: std::sync::Mutex<Vec<String>>,
105    /// Budget exhaustions are shared with the owning semantic model, or kept in
106    /// a standalone tracker when this condition model is constructed directly.
107    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    /// Constructs a `ConditionModel` with a pre-created shared budget tracker.
172    /// Used by `SemanticModel::parse` to share a single tracker across model
173    /// construction and downstream consumers.
174    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    /// Decides whether the given condition assumptions can hold simultaneously.
220    ///
221    /// A condition is a pure function of the template's parameters,
222    /// pseudo-parameters, and mappings, so the assumptions hold exactly when some
223    /// assignment of concrete values to the parameters they read makes every
224    /// assumed condition take its assumed value. The search is over those
225    /// parameter assignments, which is why its cost tracks how many independent
226    /// inputs a template really has rather than how many conditions are layered
227    /// over them.
228    ///
229    /// On inputs that exhaust a search budget this degrades to a conservative
230    /// `true` (assume satisfiable). Whether that conservative answer *adds* or
231    /// *suppresses* a diagnostic depends on how each caller uses the result, so
232    /// "conservative" is not safe in one fixed direction. The callers below are
233    /// examples, not an exhaustive list:
234    ///
235    /// - Used directly (`conditions_compatible`, `resources_compatible`, and the
236    ///   scenario-reachability filters in `resolve_scenarios_json` and the schema
237    ///   validator) a conservative `true` treats two conditions as able to
238    ///   coexist / a scenario as reachable, so it keeps a pair or scenario a full
239    ///   search might have eliminated - at worst surfacing an extra
240    ///   (false-positive) diagnostic.
241    /// - Used negated to prove *unreachability* (`find_unreachable_branches`,
242    ///   which emits when `!is_satisfiable(branch_assumptions)`) a conservative
243    ///   `true` makes the branch look reachable and therefore *suppresses* an
244    ///   unreachable-branch diagnostic - a false negative, the opposite
245    ///   direction.
246    /// - Used negated to prove *implication* (`condition_implies`, i.e.
247    ///   `!is_satisfiable(a = true, b = false)`; the conditional-reference guard
248    ///   uses the same shape) a conservative `true` flips the result to "does not
249    ///   imply", which can surface an extra (false-positive) diagnostic.
250    ///
251    /// All of these occur only on adversarial inputs that exhaust the budget;
252    /// valid templates resolve well within budget and are unaffected. Reaching a
253    /// budget is recorded (see [`Self::budget_exhausted_queries`]) so a curtailed
254    /// analysis is reported rather than silently narrowing what validation proves.
255    ///
256    /// A condition the parameters cannot decide - one comparing a value the model
257    /// cannot resolve, or sitting on a reference cycle - is treated as compatible
258    /// with any assumption about it, the same conservative direction.
259    #[must_use]
260    pub fn is_satisfiable(&self, assumptions: &[(String, bool)]) -> bool {
261        self.satisfiability(assumptions) != Satisfiability::Unsatisfiable
262    }
263
264    /// Decides satisfiability without conflating a budget-curtailed search with
265    /// an exact satisfiable result. Proof-producing callers must require
266    /// [`Satisfiability::Satisfiable`]; callers that use satisfiability only as a
267    /// conservative reachability guard can continue using [`Self::is_satisfiable`].
268    #[must_use]
269    pub fn satisfiability(&self, assumptions: &[(String, bool)]) -> Satisfiability {
270        self.satisfiability_with_param_overrides(assumptions, &HashMap::new())
271    }
272
273    /// Like [`Self::is_satisfiable`], but with the target region pinned as the
274    /// only candidate value for the `AWS::Region` pseudo-parameter - a per-region
275    /// satisfiability query that asks whether a condition can hold *in that
276    /// region*, not whether the region could be anything. Used by the
277    /// region-availability check so a resource guarded by a
278    /// condition that cannot hold in the target region (e.g.
279    /// `!Equals [AWS::Region, other-region]`) is correctly treated as never
280    /// created there - even when no explicit `--region` override pins the
281    /// pseudo-parameter globally (where it stays a free variable to avoid
282    /// false unreachable-branch diagnostics).
283    #[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    /// Satisfiability with a set of parameter/pseudo-parameter candidate-value
290    /// overrides applied on top of the model's derived candidate values. An
291    /// override restricts a parameter to exactly the given values for this query
292    /// only; a parameter not referenced by any relevant condition is unaffected.
293    #[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        // Once the cumulative search budget for this model is spent, no further
300        // query can provide an exact answer.
301        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            // A name that names no condition of this template fixes no
308            // expression's value, so it constrains nothing.
309            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 even the restricted parameter space this query depends on is too
327        // large to enumerate, the answer is unknown rather than proven
328        // satisfiable. The boolean API maps this back to its historical
329        // conservative `true` result.
330        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            // Synthetic condition names (inline Fn::If expressions and
344            // Rules-section conditions) are internal; describe them generically
345            // rather than leaking `__`-prefixed identifiers into the advisory.
346            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    /// Charges satisfiability work to this model's cumulative budget, reporting the
355    /// crossing so an analysis that silently stops deciding conditions cannot go
356    /// unnoticed by the author of the template that caused it.
357    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    /// Records what a spent budget stopped short of deciding, for the advisory
366    /// diagnostic the validation reports.
367    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    /// Whether this model has spent its cumulative satisfiability search budget.
376    /// Callers that drive many queries (the pairwise compatibility pass) check
377    /// this to stop early once further queries would only return the
378    /// conservative answer.
379    #[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    /// Cumulative satisfiability search steps consumed by this model so far.
385    #[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    /// Test-only: advance the cumulative satisfiability counter directly, so the
395    /// budget threshold and short-circuit behavior can be exercised without
396    /// burning `MAX_TOTAL_SAT_ITERATIONS` of real search work (which would be
397    /// pointless seconds of computation). Charged through the same path as real
398    /// work so the reporting that accompanies exhaustion is exercised too.
399    #[cfg(test)]
400    fn add_sat_iterations_for_test(&self, count: u64) {
401        self.charge_satisfiability_work(count);
402    }
403
404    /// Builds, per parameter the conditions reference, the candidate values to
405    /// enumerate during satisfiability search. The set used for each parameter
406    /// is:
407    ///
408    /// - **User parameter with `AllowedValues`**: those values exactly.
409    /// - **User parameter without `AllowedValues`**: every literal it is
410    ///   compared against in any condition, plus a sentinel
411    ///   (`PARAM_UNKNOWN_SENTINEL`) standing for "any other value".
412    /// - **Pseudo-parameter pinned by an explicit override** (e.g. user passed
413    ///   `--region us-east-1`): the override value as the only candidate, so
414    ///   the solver treats the pseudo-parameter as a constant.
415    /// - **Pseudo-parameter without an explicit override**: literals plus the
416    ///   `__unknown__` sentinel, treating it as a free variable. Without this,
417    ///   `Fn::Equals[AWS::Partition, "aws"]` would always evaluate true (the
418    ///   default partition is "aws"), incorrectly marking the false branch
419    ///   unreachable on templates that deploy to non-commercial partitions.
420    ///
421    /// Comparing a parameter against only the literals it is actually compared
422    /// against (plus the sentinel) is exact: two values a condition set never
423    /// distinguishes produce identical truth assignments, so one representative
424    /// of the "any other value" class suffices.
425    ///
426    /// Derived purely from the immutable condition set, parameter definitions,
427    /// and pseudo-parameter overrides, so it is computed once and cached.
428    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    /// The candidate values of every parameter any condition references,
462    /// computed once and cached (see [`Self::collect_referenced_param_values`]).
463    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        // A implies B iff (A=true, B=false) is unsatisfiable
475        // Fast path: same condition always implies itself
476        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    /// Registers many inline conditions at once, recomputing the derived mutex
543    /// groups and implications a single time. Registering a large batch through
544    /// `register_inline` would recompute them per insertion (quadratic).
545    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        // Inserting a condition can introduce parameter references the cached
552        // map omits, so invalidate it; it is rebuilt lazily on the next
553        // satisfiability query. The cumulative iteration counter is deliberately
554        // left untouched - it is a per-validation work budget, not state derived
555        // from the condition set, and resetting it would hand adversarial input a
556        // way to refill the budget.
557        self.referenced_param_values = OnceLock::new();
558    }
559
560    pub fn register_rule_implications(&mut self, arena: &Arena, rules: &[(String, NodeRef, Vec<NodeRef>)]) {
561        // A Rules-section `RuleCondition => Assertions` relationship becomes a set
562        // of constraints `__rule_cond_<rule> => __rule_assert_<rule>_<i>`. They are
563        // kept in their own list because they restrict what a deployment may
564        // supply rather than following from the conditions' structure, so
565        // satisfiability has to apply them explicitly (see `rule_implications`).
566        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        // The conditions map iterates in arbitrary order; sort so diagnostic
609        // emission order is deterministic across runs.
610        result.sort();
611        result
612    }
613
614    /// Names of top-level conditions whose body does not produce a boolean
615    /// (e.g. a bare `Fn::Ref`), for the not-a-boolean condition diagnostic.
616    /// Synthetic conditions (`__`-prefixed) are excluded - they are never
617    /// user-visible. Returned sorted for deterministic diagnostic ordering.
618    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        // An invalid (non-boolean) body is reported by its own diagnostic and
716        // never participates in equivalence detection, so treat it as opaque to
717        // exclude it.
718        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        // A mapping lookup whose keys contain an opaque sub-expression cannot be
727        // compared for equivalence: two lookups that differ only in an opaque key
728        // (e.g. `Fn::Select` over different indices) would otherwise canonicalize
729        // to the same "?" and be wrongly reported as equivalent.
730        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                // A bare `Fn::Ref` (to a parameter, resource, or pseudo-parameter)
830                // is not a boolean and is not a condition reference, so it is an
831                // invalid condition body.
832                ConditionExpr::Invalid
833            }
834        }
835        _ => {
836            // Try to parse as a map with intrinsic keys
837            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        // FindInMap can resolve to a concrete value, so it gets a dedicated
899        // variant the SAT solver understands. Every other intrinsic produces
900        // a value that cannot be known statically - it must be treated as an
901        // opaque unknown (`Other`), never a comparable literal. Treating, say,
902        // `Fn::Sub(...)` as the literal string "Sub(...)" would make
903        // `Fn::Equals[!Sub ..., "x"]` look like a literal-vs-literal compare and
904        // spuriously fire the always-true/false tautology check.
905        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
915/// Walks a condition expression and collects, per parameter or pseudo-parameter
916/// it references, every literal it is compared against in an `Fn::Equals`.
917/// Pseudo-parameters (`AWS::Partition`, `AWS::Region`, …) are collected the same
918/// way as user parameters; the SAT solver treats both as named symbols whose
919/// candidate values are enumerated during satisfiability search.
920fn 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
940/// Returns the `(symbol_name, compared_literal)` when `a` and `b` are an
941/// `Fn::Equals` pair of a parameter (or pseudo-parameter) and a literal in
942/// either order. Returns `None` otherwise.
943fn 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    // Find conditions that test the same parameter with different literal values.
980    // Handles both Equals(Param, Lit) and Not(Equals(Param, Lit)).
981    let mut param_tests: HashMap<String, Vec<(String, String)>> = HashMap::new(); // param → [(cond_name, literal)]
982
983    for (name, expr) in conditions {
984        if let Some((param, _lit, is_positive)) = extract_equals_test(expr) {
985            // Only positive tests (Equals) form mutex groups - two conditions
986            // testing Equals(Param, "X") and Equals(Param, "Y") are mutex.
987            // Not(Equals(Param, "X")) is compatible with Equals(Param, "Y").
988            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
1026/// One satisfiability query, prepared for enumeration.
1027///
1028/// A CloudFormation condition is a pure function of the template's parameters,
1029/// pseudo-parameters, and mappings - a condition has no truth value of its own
1030/// to choose. So "can these conditions hold simultaneously?" is decided over the
1031/// *parameter* space: the assumptions hold exactly when some assignment of
1032/// concrete values to the parameters they depend on makes every assumed
1033/// condition take its assumed value.
1034///
1035/// Searching parameter assignments rather than condition assignments is what
1036/// keeps the cost proportional to a template's real degrees of freedom. The
1037/// number of parameter points is the product of a few candidate values per
1038/// referenced parameter, while the space of condition truth assignments is
1039/// exponential in the number of conditions - so a template that layers many
1040/// conditions over a few shared inputs (a partition or region check reused
1041/// across dozens of conditions) stays cheap no matter how many conditions are
1042/// built on top of those inputs.
1043///
1044/// Enumerating points also removes the need to enforce derived constraints
1045/// separately: a parameter takes exactly one value at a point, so mutually
1046/// exclusive conditions cannot both hold there, and evaluating a condition at a
1047/// point yields exactly the value its `And`/`Or`/`Not` structure entails. Only
1048/// Rules-section constraints, which no condition expression entails, are applied
1049/// as a filter on admissible points.
1050struct SatisfiabilityQuery<'model> {
1051    model: &'model ConditionModel,
1052    /// Conditions whose value this query depends on: the assumed conditions, the
1053    /// conditions reachable from them through condition references, and the
1054    /// endpoints of every applicable Rules-section constraint. Positions in this
1055    /// list index `value_at_point` and `being_evaluated`.
1056    dependencies: Vec<&'model str>,
1057    position_of_dependency: HashMap<&'model str, usize>,
1058    /// Parameters the dependencies reference, each with the candidate values to
1059    /// enumerate. Held in a deterministic order so the work charged to the
1060    /// budget - and therefore which query first trips an exhausted budget - does
1061    /// not depend on hash iteration order.
1062    parameters: Vec<(&'model str, &'model [String])>,
1063    position_of_parameter: HashMap<&'model str, usize>,
1064    /// Which candidate value each parameter takes at the current point.
1065    selected_value: Vec<usize>,
1066    /// How many parameters are bound while the descent explores points: those at
1067    /// positions below it have a value, those at or above it are still free and
1068    /// read as undetermined.
1069    bound_depth: usize,
1070    /// Rules-section constraints to honor, as `(antecedent, consequent)` pairs of
1071    /// dependency positions.
1072    rule_constraints: Vec<(usize, usize)>,
1073    value_at_point: Vec<PointValue>,
1074    /// The value a condition is forced to take by the assumptions, for conditions
1075    /// the point itself leaves undetermined. Such a condition acts as a free
1076    /// choice - its value turns on data the model cannot resolve - and the
1077    /// assumptions constrain that choice: assuming `And(a, b)` holds forces both
1078    /// `a` and `b` to hold however little is known about them. Cleared for every
1079    /// point.
1080    forced_value: Vec<Option<bool>>,
1081    /// How many values the assumptions have forced. Propagation repeats while this
1082    /// keeps growing, so a value forced late still constrains what was examined
1083    /// earlier.
1084    forcings_made: u64,
1085    being_evaluated: Vec<bool>,
1086    /// How many reads returned something other than a property of the point alone:
1087    /// a reference cut because it closes a cycle, or a value the assumptions forced.
1088    /// Comparing this count before and after an evaluation is how that evaluation
1089    /// detects its result must not be cached as the point's own value.
1090    provisional_reads: u64,
1091    /// Satisfiability work performed, charged to the model's per-query and
1092    /// cumulative budgets by the caller.
1093    steps: u64,
1094}
1095
1096/// Cached value of one dependency at the current parameter point.
1097#[derive(Clone, Copy, PartialEq, Eq)]
1098enum PointValue {
1099    NotEvaluated,
1100    Determined(bool),
1101    /// The point does not determine the condition: it compares a value the model
1102    /// cannot resolve, references a condition the template does not define, or
1103    /// sits on a condition-reference cycle.
1104    Undetermined,
1105}
1106
1107/// The precomputed inputs of one point enumeration, bundled so the recursive
1108/// descent carries a single reference rather than three slices.
1109struct PointEnumeration<'query> {
1110    /// The value each assumed condition must take, by dependency position.
1111    expectations: &'query [(usize, bool)],
1112    /// The parameters each dependency's value depends on.
1113    dependency_masks: &'query [u64],
1114    /// The parameters still free at each depth, so the condition values that read
1115    /// them can be discarded before a new value is tried.
1116    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    /// Orders the parameters for the descent: those pinned to a single candidate
1156    /// value first, since binding them can never branch, and the rest so that the
1157    /// one the fewest conditions read is bound last. The descent re-evaluates only
1158    /// conditions that read a parameter it just rebound, so varying the least-read
1159    /// parameter innermost keeps the largest number of condition values valid.
1160    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        // Ties break on the parameter name so the enumeration order - and with it
1181        // the work charged before a budget trips - never depends on hash
1182        // iteration order.
1183        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    /// Adds `seeds` and every condition reachable from them through condition
1197    /// references. Reachability is what makes the parameter search exact: a
1198    /// condition no assumed condition can reach cannot influence the answer, and
1199    /// a parameter only that condition reads is therefore irrelevant too.
1200    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    /// Registers the parameters the current dependencies read, with the candidate
1219    /// values to enumerate for each.
1220    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    /// Registers one parameter and the values the query will try for it. A
1236    /// per-query override (e.g. a pinned target region) replaces the model's
1237    /// derived candidates. A parameter with no candidate values is left out: it
1238    /// is then undetermined at every point, which keeps the query conservative
1239    /// rather than silently making every condition that reads it unsatisfiable.
1240    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    /// Adds the Rules-section constraints this query must honor. A Rules
1257    /// assertion restricts which parameter values a deployment can supply, and no
1258    /// condition expression entails it, so a point that violates one must be
1259    /// excluded rather than accepted.
1260    ///
1261    /// A constraint applies once it reads a parameter this query already varies;
1262    /// pulling it in can introduce further parameters, which can bring in further
1263    /// constraints, so this runs to a fixpoint. A constraint over parameters the
1264    /// query does not vary is left unenforced - its antecedent is undetermined at
1265    /// every point, so enforcing it could only reject points the template allows.
1266    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    /// How many distinct parameter points this query would enumerate.
1306    fn parameter_point_count(&self) -> u64 {
1307        self.parameters.iter().fold(1u64, |points, (_, candidates)| points.saturating_mul(candidates.len() as u64))
1308    }
1309
1310    /// Whether some parameter point makes every assumed condition take its
1311    /// assumed value.
1312    ///
1313    /// Parameters are bound one at a time and the descent continues only while the
1314    /// assumptions can still hold: a condition the parameters bound so far already
1315    /// decide against its assumption cannot be rescued by the parameters still
1316    /// unbound, so every point below that branch is skipped. That is what keeps a
1317    /// wide parameter space affordable - the branches that could satisfy the query
1318    /// are explored, the rest are cut at the level that decided them.
1319    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(&parameter_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        // Parameters pinned to a single candidate value are ordered first and are
1332        // bound for the whole enumeration: their value cannot vary, so descending
1333        // through them would only deepen the recursion without branching.
1334        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    /// Explores the points that agree with the parameters bound so far, returning
1344    /// whether any of them satisfies the query.
1345    fn extend_point(&mut self, enumeration: &PointEnumeration<'_>) -> bool {
1346        self.steps += 1;
1347        if self.steps > MAX_SAT_ITERATIONS {
1348            // The number of points is the product of the candidate values of every
1349            // parameter the query reads, so it grows exponentially in that
1350            // parameter count. Once the per-query budget is spent, assume
1351            // satisfiable rather than explore further - the conservative-`true`
1352            // contract documented on `is_satisfiable`.
1353            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            // Everything from this parameter inward is about to take a new value,
1365            // so the condition values that read any of them no longer hold.
1366            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    /// Whether the assumptions and the applicable Rules-section constraints can
1378    /// still hold given the parameters bound so far. A condition that is already
1379    /// determined against its assumption stays that way however the unbound
1380    /// parameters are chosen, which is what makes cutting the branch sound.
1381    ///
1382    /// A condition the parameters leave undetermined is not simply accepted:
1383    /// assuming it holds also constrains the conditions it is built from, so the
1384    /// assumptions are propagated into those and any contradiction they force is a
1385    /// contradiction of the assumptions. Propagation repeats while it keeps forcing
1386    /// new values, so a value forced late still contradicts an assumption examined
1387    /// earlier. Only contradictions that must hold are derived - where two operands
1388    /// could each be the one satisfying a disjunction, nothing is forced - so the
1389    /// answer degrades to the conservative "can still hold" rather than to a wrong
1390    /// rejection.
1391    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    /// Requires a dependency to take a value, propagating into the conditions it is
1410    /// built from when the point leaves it undetermined. Returns `false` when that
1411    /// contradicts what the point determines or what the assumptions already forced.
1412    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    /// Requires an expression to take a value, deriving the values its operands
1431    /// must take. A conjunction that must hold requires every operand to hold, a
1432    /// disjunction that must not hold requires every operand not to hold, and a
1433    /// negation inverts the requirement; the remaining shapes need at least one
1434    /// operand to comply, which forces nothing until only one candidate is left.
1435    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                // A reference the template does not define constrains nothing.
1441                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            // A comparison is a leaf: when the point cannot decide it, the values it
1449            // reads are simply unknown, and any requirement on it can be met.
1450            ConditionExpr::Equals(_, _) | ConditionExpr::Invalid => true,
1451        }
1452    }
1453
1454    /// Requires at least one operand to take `satisfying`. Nothing is forced while
1455    /// more than one operand could be the one that does - only the two certainties
1456    /// are derived: a contradiction when every operand already rules it out, and the
1457    /// value of the last operand that could still comply.
1458    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    /// Whether the applicable Rules-section constraints hold, forcing what they
1479    /// entail: a constraint whose antecedent holds requires its consequent to hold,
1480    /// and one whose consequent cannot hold requires its antecedent not to.
1481    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    /// One bit per parameter whose value varies across points. A parameter with a
1495    /// single candidate value gets an empty mask because its value never changes.
1496    /// Parameters past the width of the mask share its last bit, which
1497    /// over-invalidates rather than under-invalidates.
1498    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    /// For each dependency, the parameters its value can depend on: those it reads
1513    /// itself and those read by everything it references. Enumeration consults
1514    /// this to keep the values a parameter change cannot have affected, which is
1515    /// what makes the cost of a point proportional to the conditions that
1516    /// actually moved rather than to the whole dependency closure.
1517    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        // Propagate to a fixpoint: masks only grow, so this terminates even when
1542        // the references form a cycle.
1543        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    /// Drops the cached values of every dependency that reads one of the
1560    /// parameters that just changed.
1561    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    /// The value of one dependency at the current point, memoized for the rest of
1570    /// this point's evaluation. Only what the point itself determines is cached;
1571    /// values that came from a cut cycle or from what the assumptions forced are
1572    /// left out, because they hold for one reference rather than for the point.
1573    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            // A condition that transitively references itself has no value to
1581            // read here; leaving this reference undetermined keeps evaluation
1582            // total and terminating. The cycle itself is reported as a template
1583            // defect by the reference-graph analysis.
1584            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    /// What is currently believed about a dependency: the value the point
1602    /// determines, or failing that the value the assumptions have forced on it.
1603    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    /// Evaluates a condition expression at the current point. `None` means the
1615    /// point does not determine the expression; it propagates except where an
1616    /// operand already decides the result - a false operand of `And`, a true
1617    /// operand of `Or` - so an unresolvable comparison in one branch does not
1618    /// discard what the rest of the expression proves.
1619    fn evaluate(&mut self, expr: &'model ConditionExpr) -> Option<bool> {
1620        // Each evaluation step is one unit of satisfiability work. Counting here,
1621        // in the recursive core, keeps the budget proportional to real effort: a
1622        // deep condition closure draws the budget down faster than a shallow one,
1623        // so the budget bounds actual work uniformly regardless of how the
1624        // conditions are shaped.
1625        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                // A reference to a condition the template does not define has no
1657                // value; it is reported as an undefined reference elsewhere.
1658                let dependency = self.position_of_dependency.get(name.as_str()).copied()?;
1659                self.dependency_belief(dependency)
1660            }
1661            // A condition body that is not a boolean has no truth value, so it
1662            // never decides a query.
1663            ConditionExpr::Invalid => None,
1664        }
1665    }
1666
1667    /// The concrete value of a value expression at the current point, or `None`
1668    /// when the point does not determine it.
1669    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    /// The value the parameters bound so far assign to a parameter, or `None` when
1691    /// the query does not vary it or has not bound it yet.
1692    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
1701/// Names of the conditions `expr` references, borrowed from `expr` so the
1702/// satisfiability hot path collects them without allocating.
1703fn 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
1716/// Names of the parameters and pseudo-parameters `expr` reads directly - not
1717/// those read by the conditions it references.
1718fn 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
1745/// Names of the parameters and pseudo-parameters reachable from `seeds` through
1746/// condition references, with the traversal cost added to `steps`.
1747fn 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
1770/// Derives `antecedent => consequent` pairs from `And`/`Or` condition
1771/// structure. These are *enforced* by the satisfiability search, so only
1772/// logically sound pairs may be produced:
1773///
1774/// * `X = And(...)` implies each condition reference reachable through nested
1775///   `And`s (`And(And(A, B), C)` implies `A`, `B`, and `C`) - but nothing
1776///   under an `Or` or `Not` child, whose references are not individually
1777///   entailed.
1778/// * Symmetrically, each condition reference reachable through nested `Or`s
1779///   implies `X = Or(...)`, and nothing under an `And` or `Not` child does.
1780fn 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
1806/// Collects condition references reachable through nested operators of the
1807/// *same* kind only (`in_and` selects which). Crossing into the other operator
1808/// or a `Not` breaks entailment, so those subtrees are skipped.
1809fn 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, &params, &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        // A condition always implies itself
1960        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        // Same condition assumed both true and false is unsatisfiable
1979        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        // Mutex conditions do not imply each other
2000        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        // ProdAndDB = And(IsProd, CreateDB) → ProdAndDB implies IsProd and CreateDB
2117        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        // Or(IsProd, IsDev) → IsProd implies ProdOrDev, IsDev implies ProdOrDev
2143        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        // With AWS::Region left free, the region-equals condition can hold either
2288        // way; pinning the region resolves it. This is what the region-availability
2289        // check relies on to skip a resource whose condition cannot hold in the
2290        // target region - even when no explicit --region override is set.
2291        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        // A condition that does not reference AWS::Region is unaffected by the pin.
2300        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, &params, &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, &params, &pseudo, &mappings);
2354        // With eu-west-1, IsUsEast1=true should be unsatisfiable
2355        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        // When Env=Prod, FindInMap returns "production" → IsProdLabel=true
2405        // When Env=Dev, FindInMap returns "development" → IsProdLabel=false
2406        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        // NestedAnd = And(And(CondA, CondB), CondC) should imply all three
2494        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        // CondA implies NestedOr, CondB implies NestedOr (through nested Or)
2525        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        // Register an inline condition that tests the same param
2546        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        // Mutex groups should be rebuilt to include the new condition
2552        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        // When IfExpr has Equals("a", "a"), it should resolve to the true branch directly
2559        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        // Literal Equals("same","same") is true → should resolve to "yes" directly
2572        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        // When IfExpr has a non-trivial condition, it should register as inline
2605        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        // Should have registered an inline condition
2622        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    /// Builds a template whose conditions form a chain `chain_len` long, topped
2627    /// by an unsatisfiable `Contra = And(Top, Not(Top))`. `Top` transitively
2628    /// depends on the whole chain, so deciding `Contra` forces the solver to
2629    /// reason over every condition in the chain.
2630    ///
2631    /// Each link is written in De Morgan form (`Not(Or(Not(a), Not(b)))` for
2632    /// `And`, `Not(And(Not(a), Not(b)))` for `Or`) so the implication extractor
2633    /// (which only reads top-level `And`/`Or` structure) derives nothing from
2634    /// the chain. That keeps implication pruning out of the search, which is
2635    /// the point: this test exercises the *raw iteration budget*, not the
2636    /// implication constraints (covered by their own tests).
2637    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            // De Morgan: even links are And(a, b), odd links are Or(a, b).
2646            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        // `Contra = And(Top, Not(Top))` is unsatisfiable, and Top depends on the
2669        // whole chain, so deciding it requires reasoning over every condition in
2670        // the chain. The chain is built over two binary parameters no matter how
2671        // long it gets, so the answer is decided by four parameter assignments -
2672        // the work must therefore grow with the chain's length, not with the
2673        // number of truth assignments its conditions could take.
2674        //
2675        // This is the shape that made a real build hang: conditions layered over a
2676        // handful of shared inputs, where searching condition assignments costs
2677        // exponentially more than searching the inputs themselves.
2678        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        // Doubling the chain may at most double the work a few times over -
2692        // anything exponential in the condition count would exceed this by orders
2693        // of magnitude. Iteration counts are deterministic and machine-independent,
2694        // so this is a stable bound rather than a timing assertion.
2695        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    /// Builds a template shaped like the deployment templates that made CDK's
2716    /// default validation hang: `layered` conditions, each an `And`/`Or` over
2717    /// conditions that all test the same few pseudo-parameters, so every condition
2718    /// is reachable from every other through the inputs they share.
2719    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        // The regression guard for the incident: a hundred conditions built over
2756        // nine base conditions that test three shared inputs. Analyzing this by
2757        // searching condition assignments cost so much that the cumulative budget
2758        // ran out partway through the pairwise pass, silently leaving most
2759        // condition pairs undecided. Searching parameter assignments instead must
2760        // decide every pair well inside the budget. The same shape at the
2761        // two-hundred-condition CloudFormation maximum is covered end to end by the
2762        // security fixtures.
2763        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        // Two conditions testing the same pseudo-parameter against different values
2791        // can never hold together. Proving that is what the pass exists for, and
2792        // what a budget-exhausted analysis silently stopped doing.
2793        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        // Exhausting the cumulative budget makes every later question about the
2803        // condition set take the conservative answer, so the template's author has
2804        // to be told the analysis was curtailed - the failure that made a
2805        // multi-hour hang produce no output at all.
2806        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        // Two conditions that reference each other have no value to read. Deciding
2821        // a query over them must terminate - treating the cycle as undetermined,
2822        // the conservative direction - rather than recurse forever. The cycle
2823        // itself is reported by the reference-graph analysis.
2824        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        // A condition comparing something the model cannot resolve has no value at
2847        // any parameter assignment. It must not falsify a query - that would
2848        // suppress diagnostics for resources it guards - and must not make the
2849        // conditions it is combined with undecidable either.
2850        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        // Two derived conditions that reach the same parameter through different
2879        // chains of references. Their compatibility follows only from the values
2880        // that parameter can take, so it must be decided exactly however deeply the
2881        // conditions are layered.
2882        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    /// Builds a model with `param_count` binary parameters, one base condition
2915    /// per parameter, and a `Wide` condition that is the conjunction of every
2916    /// base - so a query over `Wide` has a dependency closure spanning all of
2917    /// the parameters.
2918    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        // 24 binary parameters means 2^24 value combinations - far above
2938        // MAX_PARAM_COMBINATIONS. Enumerating them at every search leaf is the
2939        // denial-of-service shape the parameter cap defends against.
2940        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        // The query must return the conservative "satisfiable" answer rather
2946        // than enumerate the cartesian product. Supply the assumptions in reverse
2947        // display order so the recorded advisory also proves its rendering is
2948        // deterministic and does not expose synthetic condition identifiers.
2949        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        // And it must do so cheaply: the cap short-circuits before the search,
2961        // so the work charged stays far below the combination count that a full
2962        // enumeration would have cost.
2963        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        // A contradiction over a single parameter is well under the cap, so the
2974        // solver must still prove it unsatisfiable rather than fall back to the
2975        // conservative answer - proving the cap does not over-trigger.
2976        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        // A small contradiction chain whose query completes exactly and cheaply
2992        // while still charging a non-zero, deterministic amount to the model's
2993        // shared cumulative counter - enough to prove queries accumulate without
2994        // spending the full per-query budget on every call.
2995        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        // (1) Real queries accumulate across queries - a per-query reset would
3002        // be a silent denial-of-service regression. Issuing the same saturating
3003        // query repeatedly must strictly increase the shared counter.
3004        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        // (2) The exhausted flag trips exactly at the cumulative threshold.
3022        // Fast-forward to one iteration short of the cap rather than burning
3023        // ~MAX_TOTAL_SAT_ITERATIONS of real search (pointless seconds of work);
3024        // the accumulation checked in (1) already proves real queries feed this
3025        // same counter.
3026        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        // (3) Once exhausted, further queries must short-circuit in O(1): they
3041        // return the conservative satisfiable answer and charge no further work.
3042        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        // Rules: when Env == prod, an assertion requires Env == dev - so a
3060        // condition equivalent to Env == prod can never hold. The implication
3061        // must actually constrain the SAT search, not merely be recorded.
3062        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        // An unconditional assertion has no antecedent implication; unrelated
3089        // conditions stay satisfiable both ways.
3090        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        // `Complex = And(Or(A, B), Not(C))` entails none of A, B, C
3115        // individually, and none of them entails Complex. An unsound
3116        // implication here would be enforced by the search and wrongly prune
3117        // satisfiable scenarios (a real false-positive source for the
3118        // unreachable-branch rule).
3119        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        // Env=dev, Flag=false satisfies NotA=true with Inline=false.
3146        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}