use crate::budget::{BudgetKind, BudgetTracker};
use crate::consts::{
CONDITION_REF_PREFIX, FN_AND, FN_CONDITION, FN_EQUALS, FN_NOT, FN_OR, MAX_PARAM_COMBINATIONS, MAX_SAT_ITERATIONS,
MAX_TOTAL_SAT_ITERATIONS, PARAM_UNKNOWN_SENTINEL, PSEUDO_PREFIX, PSEUDO_REGION,
};
use crate::ir::*;
use crate::model::PseudoParameterOverrides;
use crate::resolver::{MappingData, ParameterInfo};
use log::{debug, info};
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
const MAX_REPORTED_BUDGET_EXHAUSTED_QUERIES: usize = 5;
const WHOLE_CONDITION_SET_DESCRIPTION: &str =
"this template's condition set as a whole (remaining conditions were assumed compatible)";
const INLINE_CONDITION_DESCRIPTION: &str = "<inline condition>";
const SYNTHETIC_CONDITION_PREFIX: &str = "__";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Satisfiability {
Satisfiable,
Unsatisfiable,
Unknown,
}
#[derive(Debug, Clone)]
pub enum ConditionExpr {
Equals(ValueExpr, ValueExpr),
And(Vec<ConditionExpr>),
Or(Vec<ConditionExpr>),
Not(Box<ConditionExpr>),
ConditionRef(String),
Invalid,
}
#[derive(Debug, Clone)]
pub enum ValueExpr {
ParamRef(String),
Literal(String),
PseudoParam(String),
MappingLookup { map_name: String, key1: Box<ValueExpr>, key2: Box<ValueExpr> },
Other,
}
#[derive(Debug, Clone)]
pub struct MutexGroup {
pub conditions: Vec<String>,
pub parameter: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Implication {
pub antecedent: String,
pub consequent: String,
}
#[derive(Debug)]
pub struct ConditionModel {
pub conditions: HashMap<String, ConditionExpr>,
pub parameters: HashMap<String, ParameterInfo>,
pub mutex_groups: Vec<MutexGroup>,
pub implications: Vec<Implication>,
rule_implications: Vec<Implication>,
pseudo_overrides: PseudoParameterOverrides,
mappings: MappingData,
sat_iterations_used: AtomicU64,
referenced_param_values: OnceLock<HashMap<String, Vec<String>>>,
budget_exhausted_queries: std::sync::Mutex<Vec<String>>,
budget_tracker: Arc<BudgetTracker>,
}
pub fn format_condition_expr(expr: &ConditionExpr) -> String {
match expr {
ConditionExpr::Equals(a, b) => {
format!("Equals({}, {})", format_value_expr(a), format_value_expr(b))
}
ConditionExpr::And(exprs) => {
let items: Vec<String> = exprs.iter().map(format_condition_expr).collect();
format!("And({})", items.join(", "))
}
ConditionExpr::Or(exprs) => {
let items: Vec<String> = exprs.iter().map(format_condition_expr).collect();
format!("Or({})", items.join(", "))
}
ConditionExpr::Not(e) => format!("Not({})", format_condition_expr(e)),
ConditionExpr::ConditionRef(name) => format!("Condition({})", name),
ConditionExpr::Invalid => "Invalid".to_string(),
}
}
fn format_value_expr(expr: &ValueExpr) -> String {
match expr {
ValueExpr::ParamRef(name) => format!("Param({})", name),
ValueExpr::Literal(s) => format!("\"{}\"", s),
ValueExpr::PseudoParam(name) => name.clone(),
ValueExpr::MappingLookup { map_name, key1, key2 } => {
format!("FindInMap({}, {}, {})", map_name, format_value_expr(key1), format_value_expr(key2))
}
ValueExpr::Other => "?".into(),
}
}
fn format_satisfiability_assumptions(assumptions: &[(String, bool)]) -> String {
let mut descriptions = assumptions
.iter()
.map(|(name, value)| {
let condition_description =
if name.starts_with(SYNTHETIC_CONDITION_PREFIX) { INLINE_CONDITION_DESCRIPTION } else { name };
format!("{condition_description}={value}")
})
.collect::<Vec<_>>();
descriptions.sort_unstable();
descriptions.join(", ")
}
impl ConditionModel {
pub fn from_ir(
ir: &TemplateIR,
parameters: &HashMap<String, ParameterInfo>,
pseudo_overrides: &PseudoParameterOverrides,
mappings: &MappingData,
) -> Self {
Self::from_ir_with_tracker(ir, parameters, pseudo_overrides, mappings, Arc::new(BudgetTracker::new()))
}
pub(crate) fn from_ir_with_tracker(
ir: &TemplateIR,
parameters: &HashMap<String, ParameterInfo>,
pseudo_overrides: &PseudoParameterOverrides,
mappings: &MappingData,
tracker: Arc<BudgetTracker>,
) -> Self {
let mut conditions = HashMap::new();
if ir.conditions != NULL_REF
&& let Some(entries) = ir.arena.as_map(ir.conditions)
{
for (name, node_ref) in entries {
let expr = parse_condition_expr(&ir.arena, *node_ref, parameters);
conditions.insert(name.clone(), expr);
}
}
let mutex_groups = extract_mutex_groups(&conditions);
let implications = extract_implications(&conditions);
info!(
"Condition model: {} conditions, {} mutex groups (params: {:?}), {} implications",
conditions.len(),
mutex_groups.len(),
mutex_groups.iter().map(|g| g.parameter.as_str()).collect::<Vec<_>>(),
implications.len()
);
ConditionModel {
conditions,
parameters: parameters.clone(),
mutex_groups,
implications,
rule_implications: Vec::new(),
pseudo_overrides: pseudo_overrides.clone(),
mappings: mappings.clone(),
sat_iterations_used: AtomicU64::new(0),
referenced_param_values: OnceLock::new(),
budget_exhausted_queries: std::sync::Mutex::new(Vec::new()),
budget_tracker: tracker,
}
}
#[must_use]
pub fn is_satisfiable(&self, assumptions: &[(String, bool)]) -> bool {
self.satisfiability(assumptions) != Satisfiability::Unsatisfiable
}
#[must_use]
pub fn satisfiability(&self, assumptions: &[(String, bool)]) -> Satisfiability {
self.satisfiability_with_param_overrides(assumptions, &HashMap::new())
}
#[must_use]
pub fn is_satisfiable_in_region(&self, assumptions: &[(String, bool)], region: &str) -> bool {
let overrides = HashMap::from([(PSEUDO_REGION.to_string(), vec![region.to_string()])]);
self.satisfiability_with_param_overrides(assumptions, &overrides) != Satisfiability::Unsatisfiable
}
#[must_use]
fn satisfiability_with_param_overrides<'model>(
&'model self,
assumptions: &[(String, bool)],
param_overrides: &'model HashMap<String, Vec<String>>,
) -> Satisfiability {
if self.satisfiability_budget_exhausted() {
return Satisfiability::Unknown;
}
let mut assumed: HashMap<&'model str, bool> = HashMap::with_capacity(assumptions.len());
for (name, expected) in assumptions {
let Some((condition_name, _)) = self.conditions.get_key_value(name.as_str()) else {
continue;
};
if let Some(&already_assumed) = assumed.get(condition_name.as_str())
&& already_assumed != *expected
{
return Satisfiability::Unsatisfiable;
}
assumed.insert(condition_name.as_str(), *expected);
}
if assumed.is_empty() {
return Satisfiability::Satisfiable;
}
debug!("Checking satisfiability of {:?} against {} conditions", assumptions, self.conditions.len());
let mut query = SatisfiabilityQuery::prepare(self, &assumed, param_overrides);
if query.parameter_point_count() > MAX_PARAM_COMBINATIONS {
let steps = query.steps;
self.charge_satisfiability_work(steps);
let query_description = format_satisfiability_assumptions(assumptions);
self.record_curtailed_analysis(query_description);
self.budget_tracker.record(BudgetKind::ConditionParameterCombinations);
return Satisfiability::Unknown;
}
let satisfiable = query.some_point_satisfies(&assumed);
let steps = query.steps;
self.charge_satisfiability_work(steps);
if steps > MAX_SAT_ITERATIONS {
let query_description = format_satisfiability_assumptions(assumptions);
self.record_curtailed_analysis(query_description);
self.budget_tracker.record(BudgetKind::ConditionSatIterationsPerQuery);
return Satisfiability::Unknown;
}
if satisfiable { Satisfiability::Satisfiable } else { Satisfiability::Unsatisfiable }
}
fn charge_satisfiability_work(&self, steps: u64) {
let before = self.sat_iterations_used.fetch_add(steps, Ordering::Relaxed);
if before < MAX_TOTAL_SAT_ITERATIONS && before.saturating_add(steps) >= MAX_TOTAL_SAT_ITERATIONS {
self.record_curtailed_analysis(WHOLE_CONDITION_SET_DESCRIPTION.to_string());
self.budget_tracker.record(BudgetKind::ConditionSatIterationsTotal);
}
}
fn record_curtailed_analysis(&self, description: String) {
if let Ok(mut curtailed) = self.budget_exhausted_queries.lock()
&& curtailed.len() < MAX_REPORTED_BUDGET_EXHAUSTED_QUERIES
{
curtailed.push(description);
}
}
#[must_use]
pub fn satisfiability_budget_exhausted(&self) -> bool {
self.sat_iterations_used.load(Ordering::Relaxed) >= MAX_TOTAL_SAT_ITERATIONS
}
#[must_use]
pub fn sat_iterations_used(&self) -> u64 {
self.sat_iterations_used.load(Ordering::Relaxed)
}
pub fn budget_exhausted_queries(&self) -> Vec<String> {
self.budget_exhausted_queries.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
#[cfg(test)]
fn add_sat_iterations_for_test(&self, count: u64) {
self.charge_satisfiability_work(count);
}
fn collect_referenced_param_values(&self) -> HashMap<String, Vec<String>> {
let mut compared_literals: HashMap<String, Vec<String>> = HashMap::new();
for expr in self.conditions.values() {
collect_equals_pairs(expr, &mut compared_literals);
}
let mut param_values: HashMap<String, Vec<String>> = HashMap::new();
for (param_name, literals) in &compared_literals {
if let Some(fixed) = self.pseudo_overrides.fixed_value(param_name) {
param_values.insert(param_name.clone(), vec![fixed]);
continue;
}
if let Some(allowed_values) = self.parameters.get(param_name).and_then(|p| p.allowed_values.clone()) {
param_values.insert(param_name.clone(), allowed_values);
continue;
}
let mut values = literals.clone();
values.push(PARAM_UNKNOWN_SENTINEL.to_string());
values.sort();
values.dedup();
param_values.insert(param_name.clone(), values);
}
param_values
}
fn referenced_param_values(&self) -> &HashMap<String, Vec<String>> {
self.referenced_param_values.get_or_init(|| self.collect_referenced_param_values())
}
#[must_use]
pub fn conditions_compatible(&self, cond_a: &str, cond_b: &str) -> bool {
self.is_satisfiable(&[(cond_a.into(), true), (cond_b.into(), true)])
}
#[must_use]
pub fn condition_implies(&self, cond_a: &str, cond_b: &str) -> bool {
if cond_a == cond_b {
return true;
}
!self.is_satisfiable(&[(cond_a.into(), true), (cond_b.into(), false)])
}
pub fn resources_compatible(&self, cond_a: Option<&str>, cond_b: Option<&str>) -> bool {
match (cond_a, cond_b) {
(None, _) | (_, None) => true,
(Some(a), Some(b)) => self.conditions_compatible(a, b),
}
}
pub fn get(&self, name: &str) -> Option<&ConditionExpr> {
self.conditions.get(name)
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.conditions.keys().map(|s| s.as_str())
}
pub fn tautological_equals(&self) -> Vec<(String, bool)> {
let mut result = Vec::new();
for (name, expr) in &self.conditions {
Self::find_tautological(expr, name, &mut result);
}
result
}
fn find_tautological(expr: &ConditionExpr, cond_name: &str, out: &mut Vec<(String, bool)>) {
match expr {
ConditionExpr::Equals(a, b) => {
let always_equal = match (a, b) {
(ValueExpr::Literal(la), ValueExpr::Literal(lb)) => Some(la == lb),
(ValueExpr::ParamRef(pa), ValueExpr::ParamRef(pb)) => Some(pa == pb),
(ValueExpr::PseudoParam(pa), ValueExpr::PseudoParam(pb)) => Some(pa == pb),
_ => None,
};
if let Some(equal) = always_equal {
out.push((cond_name.to_string(), equal));
}
}
ConditionExpr::And(children) | ConditionExpr::Or(children) => {
for c in children {
Self::find_tautological(c, cond_name, out);
}
}
ConditionExpr::Not(child) => Self::find_tautological(child, cond_name, out),
ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
}
}
pub fn referenced_params(&self) -> Vec<String> {
let mut params = Vec::new();
for expr in self.conditions.values() {
collect_param_refs_from_expr(expr, &mut params);
}
params.sort();
params.dedup();
params
}
pub fn register_inline(&mut self, name: String, expr: ConditionExpr) {
self.register_inline_batch(std::iter::once((name, expr)));
}
pub fn register_inline_batch(&mut self, items: impl IntoIterator<Item = (String, ConditionExpr)>) {
for (name, expr) in items {
self.conditions.insert(name, expr);
}
self.mutex_groups = extract_mutex_groups(&self.conditions);
self.implications = extract_implications(&self.conditions);
self.referenced_param_values = OnceLock::new();
}
pub fn register_rule_implications(&mut self, arena: &Arena, rules: &[(String, NodeRef, Vec<NodeRef>)]) {
let mut rule_implications: Vec<Implication> = Vec::new();
for (rule_name, condition_node, assertion_nodes) in rules {
let antecedent = if *condition_node != NULL_REF {
let expr = parse_condition_expr(arena, *condition_node, &self.parameters);
let synth_name = format!("__rule_cond_{}", rule_name);
self.conditions.insert(synth_name.clone(), expr);
Some(synth_name)
} else {
None
};
for (idx, assert_node) in assertion_nodes.iter().enumerate() {
if *assert_node == NULL_REF {
continue;
}
let assert_expr = parse_condition_expr(arena, *assert_node, &self.parameters);
let assert_name = format!("__rule_assert_{}_{}", rule_name, idx);
self.conditions.insert(assert_name.clone(), assert_expr);
if let Some(ref ante) = antecedent {
rule_implications.push(Implication { antecedent: ante.clone(), consequent: assert_name });
}
}
}
self.mutex_groups = extract_mutex_groups(&self.conditions);
self.implications = extract_implications(&self.conditions);
self.rule_implications = rule_implications;
self.referenced_param_values = OnceLock::new();
}
pub fn undefined_condition_refs(&self) -> Vec<(String, String)> {
let mut result = Vec::new();
for (owner, expr) in &self.conditions {
let mut refs = Vec::new();
collect_condition_refs(expr, &mut refs);
for r in refs {
if !self.conditions.contains_key(&r) {
result.push((owner.clone(), r));
}
}
}
result.sort();
result
}
pub fn invalid_condition_bodies(&self) -> Vec<String> {
let mut names: Vec<String> = self
.conditions
.iter()
.filter(|(name, expr)| matches!(expr, ConditionExpr::Invalid) && !name.starts_with("__"))
.map(|(name, _)| name.clone())
.collect();
names.sort();
names
}
pub fn detect_cycles(&self) -> Vec<Vec<String>> {
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
for (name, expr) in &self.conditions {
let mut refs = Vec::new();
collect_condition_refs(expr, &mut refs);
let mut targets: Vec<String> = refs.into_iter().filter(|r| self.conditions.contains_key(r)).collect();
targets.sort();
adj.insert(name.clone(), targets);
}
let mut sorted_names: Vec<&String> = self.conditions.keys().collect();
sorted_names.sort();
let mut cycles = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
let mut on_stack: HashSet<String> = HashSet::new();
let mut path: Vec<String> = Vec::new();
for name in &sorted_names {
if !visited.contains(name.as_str()) {
Self::dfs_cycles(name, &adj, &mut visited, &mut on_stack, &mut path, &mut cycles);
}
}
cycles
}
fn dfs_cycles(
node: &str,
adj: &HashMap<String, Vec<String>>,
visited: &mut HashSet<String>,
on_stack: &mut HashSet<String>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
visited.insert(node.to_string());
on_stack.insert(node.to_string());
path.push(node.to_string());
if let Some(neighbors) = adj.get(node) {
for neighbor in neighbors {
if !visited.contains(neighbor.as_str()) {
Self::dfs_cycles(neighbor, adj, visited, on_stack, path, cycles);
} else if on_stack.contains(neighbor.as_str()) {
let cycle_start = path.iter().position(|n| n == neighbor).unwrap();
let cycle: Vec<String> = path[cycle_start..].to_vec();
cycles.push(cycle);
}
}
}
path.pop();
on_stack.remove(node);
}
pub fn detect_equivalent_conditions(&self) -> Vec<(String, String)> {
let mut canonical_groups: HashMap<String, Vec<&str>> = HashMap::new();
for (name, expr) in &self.conditions {
if name.starts_with("__") {
continue;
}
if expr_has_opaque_value(expr) {
continue;
}
let canonical = canonical_form(expr);
canonical_groups.entry(canonical).or_default().push(name.as_str());
}
let mut pairs = Vec::new();
for (_, mut group) in canonical_groups {
if group.len() < 2 {
continue;
}
group.sort();
for other in &group[1..] {
pairs.push((group[0].to_string(), other.to_string()));
}
}
pairs
}
}
fn expr_has_opaque_value(expr: &ConditionExpr) -> bool {
match expr {
ConditionExpr::Equals(a, b) => value_is_opaque(a) || value_is_opaque(b),
ConditionExpr::And(items) | ConditionExpr::Or(items) => items.iter().any(expr_has_opaque_value),
ConditionExpr::Not(inner) => expr_has_opaque_value(inner),
ConditionExpr::Invalid => true,
ConditionExpr::ConditionRef(_) => false,
}
}
fn value_is_opaque(val: &ValueExpr) -> bool {
match val {
ValueExpr::Other => true,
ValueExpr::MappingLookup { key1, key2, .. } => value_is_opaque(key1) || value_is_opaque(key2),
_ => false,
}
}
fn canonical_form(expr: &ConditionExpr) -> String {
match expr {
ConditionExpr::Equals(a, b) => {
let ca = canonical_value(a);
let cb = canonical_value(b);
let (left, right) = if ca <= cb { (ca, cb) } else { (cb, ca) };
format!("EQ({},{})", left, right)
}
ConditionExpr::And(items) => {
let mut parts: Vec<String> = items.iter().map(canonical_form).collect();
parts.sort();
format!("AND({})", parts.join(","))
}
ConditionExpr::Or(items) => {
let mut parts: Vec<String> = items.iter().map(canonical_form).collect();
parts.sort();
format!("OR({})", parts.join(","))
}
ConditionExpr::Not(inner) => {
format!("NOT({})", canonical_form(inner))
}
ConditionExpr::ConditionRef(name) => {
format!("CREF({})", name)
}
ConditionExpr::Invalid => "INVALID".to_string(),
}
}
fn canonical_value(val: &ValueExpr) -> String {
match val {
ValueExpr::ParamRef(name) => format!("P({})", name),
ValueExpr::Literal(s) => format!("L({})", s),
ValueExpr::PseudoParam(name) => format!("PP({})", name),
ValueExpr::MappingLookup { map_name, key1, key2 } => {
format!("MAP({},{},{})", map_name, canonical_value(key1), canonical_value(key2))
}
ValueExpr::Other => "?".to_string(),
}
}
fn collect_param_refs_from_expr(expr: &ConditionExpr, out: &mut Vec<String>) {
match expr {
ConditionExpr::Equals(a, b) => {
collect_param_refs_from_value(a, out);
collect_param_refs_from_value(b, out);
}
ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
for e in exprs {
collect_param_refs_from_expr(e, out);
}
}
ConditionExpr::Not(e) => collect_param_refs_from_expr(e, out),
ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
}
}
fn collect_param_refs_from_value(expr: &ValueExpr, out: &mut Vec<String>) {
match expr {
ValueExpr::ParamRef(n) => out.push(n.clone()),
ValueExpr::MappingLookup { key1, key2, .. } => {
collect_param_refs_from_value(key1, out);
collect_param_refs_from_value(key2, out);
}
_ => {}
}
}
pub fn parse_condition_expr(
arena: &Arena,
node_ref: NodeRef,
parameters: &HashMap<String, ParameterInfo>,
) -> ConditionExpr {
match arena.node(node_ref) {
Node::Intrinsic(IntrinsicFn::Equals(a, b)) => {
let va = parse_value_expr(arena, *a, parameters);
let vb = parse_value_expr(arena, *b, parameters);
ConditionExpr::Equals(va, vb)
}
Node::Intrinsic(IntrinsicFn::And(children)) => {
let exprs = children.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
ConditionExpr::And(exprs)
}
Node::Intrinsic(IntrinsicFn::Or(children)) => {
let exprs = children.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
ConditionExpr::Or(exprs)
}
Node::Intrinsic(IntrinsicFn::Not(child)) => {
let expr = parse_condition_expr(arena, *child, parameters);
ConditionExpr::Not(Box::new(expr))
}
Node::Intrinsic(IntrinsicFn::Ref(target)) => {
if let Some(name) = target.strip_prefix(CONDITION_REF_PREFIX) {
ConditionExpr::ConditionRef(name.to_string())
} else {
ConditionExpr::Invalid
}
}
_ => {
if let Some(entries) = arena.as_map(node_ref)
&& entries.len() == 1
{
let (key, val) = &entries[0];
match key.as_str() {
FN_EQUALS => {
if let Some(arr) = arena.as_list(*val)
&& arr.len() == 2
{
let va = parse_value_expr(arena, arr[0], parameters);
let vb = parse_value_expr(arena, arr[1], parameters);
return ConditionExpr::Equals(va, vb);
}
}
FN_AND => {
if let Some(arr) = arena.as_list(*val) {
let exprs = arr.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
return ConditionExpr::And(exprs);
}
}
FN_OR => {
if let Some(arr) = arena.as_list(*val) {
let exprs = arr.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
return ConditionExpr::Or(exprs);
}
}
FN_NOT => {
if let Some(arr) = arena.as_list(*val)
&& !arr.is_empty()
{
let expr = parse_condition_expr(arena, arr[0], parameters);
return ConditionExpr::Not(Box::new(expr));
}
}
FN_CONDITION => {
if let Some(name) = arena.as_str(*val) {
return ConditionExpr::ConditionRef(name.to_string());
}
}
_ => {}
}
}
ConditionExpr::Invalid
}
}
}
fn parse_value_expr(arena: &Arena, node_ref: NodeRef, parameters: &HashMap<String, ParameterInfo>) -> ValueExpr {
match arena.node(node_ref) {
Node::String(s) => ValueExpr::Literal(s.clone()),
Node::Int(i) => ValueExpr::Literal(i.to_string()),
Node::Bool(b) => ValueExpr::Literal(b.to_string()),
Node::Intrinsic(IntrinsicFn::Ref(target)) => {
if target.starts_with(PSEUDO_PREFIX) {
ValueExpr::PseudoParam(target.clone())
} else if parameters.contains_key(target) {
ValueExpr::ParamRef(target.clone())
} else {
ValueExpr::Other
}
}
Node::Intrinsic(IntrinsicFn::FindInMap(m, k1, k2, _)) => {
let map_name = arena.as_str(*m).unwrap_or("?").to_string();
let key1 = parse_value_expr(arena, *k1, parameters);
let key2 = parse_value_expr(arena, *k2, parameters);
ValueExpr::MappingLookup { map_name, key1: Box::new(key1), key2: Box::new(key2) }
}
_ => ValueExpr::Other,
}
}
fn collect_equals_pairs(expr: &ConditionExpr, out: &mut HashMap<String, Vec<String>>) {
match expr {
ConditionExpr::Equals(a, b) => {
if let Some((symbol, literal)) = match_symbol_literal_pair(a, b) {
out.entry(symbol).or_default().push(literal);
} else {
collect_param_refs_from_value_into_pairs(a, out);
collect_param_refs_from_value_into_pairs(b, out);
}
}
ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
for e in exprs {
collect_equals_pairs(e, out);
}
}
ConditionExpr::Not(e) => collect_equals_pairs(e, out),
ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
}
}
fn match_symbol_literal_pair(a: &ValueExpr, b: &ValueExpr) -> Option<(String, String)> {
match (a, b) {
(ValueExpr::ParamRef(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::ParamRef(p)) => {
Some((p.clone(), v.clone()))
}
(ValueExpr::PseudoParam(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::PseudoParam(p)) => {
Some((p.clone(), v.clone()))
}
_ => None,
}
}
fn collect_param_refs_from_value_into_pairs(expr: &ValueExpr, out: &mut HashMap<String, Vec<String>>) {
match expr {
ValueExpr::ParamRef(p) | ValueExpr::PseudoParam(p) => {
out.entry(p.clone()).or_default();
}
ValueExpr::MappingLookup { key1, key2, .. } => {
collect_param_refs_from_value_into_pairs(key1, out);
collect_param_refs_from_value_into_pairs(key2, out);
}
_ => {}
}
}
fn collect_condition_refs(expr: &ConditionExpr, out: &mut Vec<String>) {
let mut borrowed = Vec::new();
condition_ref_names(expr, &mut borrowed);
out.extend(borrowed.into_iter().map(str::to_string));
}
pub fn collect_condition_deps(expr: &ConditionExpr, out: &mut Vec<String>) {
collect_condition_refs(expr, out);
}
fn extract_mutex_groups(conditions: &HashMap<String, ConditionExpr>) -> Vec<MutexGroup> {
let mut param_tests: HashMap<String, Vec<(String, String)>> = HashMap::new();
for (name, expr) in conditions {
if let Some((param, _lit, is_positive)) = extract_equals_test(expr) {
if is_positive {
param_tests.entry(param).or_default().push((name.clone(), _lit));
}
}
}
param_tests
.into_iter()
.filter(|(_, tests)| tests.len() > 1)
.map(|(param, tests)| {
let conditions = tests.iter().map(|(n, _)| n.clone()).collect();
let values = tests.iter().map(|(_, v)| v.clone()).collect();
MutexGroup { conditions, parameter: param, values }
})
.collect()
}
fn extract_equals_test(expr: &ConditionExpr) -> Option<(String, String, bool)> {
match expr {
ConditionExpr::Equals(a, b) => {
if let (ValueExpr::ParamRef(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::ParamRef(p)) =
(a, b)
{
return Some((p.clone(), v.clone(), true));
}
None
}
ConditionExpr::Not(inner) => {
if let Some((param, lit, positive)) = extract_equals_test(inner) {
Some((param, lit, !positive))
} else {
None
}
}
_ => None,
}
}
struct SatisfiabilityQuery<'model> {
model: &'model ConditionModel,
dependencies: Vec<&'model str>,
position_of_dependency: HashMap<&'model str, usize>,
parameters: Vec<(&'model str, &'model [String])>,
position_of_parameter: HashMap<&'model str, usize>,
selected_value: Vec<usize>,
bound_depth: usize,
rule_constraints: Vec<(usize, usize)>,
value_at_point: Vec<PointValue>,
forced_value: Vec<Option<bool>>,
forcings_made: u64,
being_evaluated: Vec<bool>,
provisional_reads: u64,
steps: u64,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PointValue {
NotEvaluated,
Determined(bool),
Undetermined,
}
struct PointEnumeration<'query> {
expectations: &'query [(usize, bool)],
dependency_masks: &'query [u64],
unbound_masks: &'query [u64],
}
impl<'model> SatisfiabilityQuery<'model> {
fn prepare(
model: &'model ConditionModel,
assumed: &HashMap<&'model str, bool>,
param_overrides: &'model HashMap<String, Vec<String>>,
) -> Self {
let mut assumed_names: Vec<&'model str> = assumed.keys().copied().collect();
assumed_names.sort_unstable();
let mut query = SatisfiabilityQuery {
model,
dependencies: Vec::new(),
position_of_dependency: HashMap::new(),
parameters: Vec::new(),
position_of_parameter: HashMap::new(),
selected_value: Vec::new(),
bound_depth: 0,
rule_constraints: Vec::new(),
value_at_point: Vec::new(),
forced_value: Vec::new(),
forcings_made: 0,
being_evaluated: Vec::new(),
provisional_reads: 0,
steps: 0,
};
query.add_dependencies_reachable_from(&assumed_names);
query.add_parameters_of_dependencies(param_overrides);
query.add_applicable_rule_constraints(param_overrides);
query.order_parameters_by_reader_count();
query.selected_value = vec![0; query.parameters.len()];
query.value_at_point = vec![PointValue::NotEvaluated; query.dependencies.len()];
query.forced_value = vec![None; query.dependencies.len()];
query.being_evaluated = vec![false; query.dependencies.len()];
query
}
fn order_parameters_by_reader_count(&mut self) {
if self.parameters.len() < 2 {
return;
}
let model = self.model;
let mut reader_count: HashMap<&'model str, usize> = HashMap::new();
let mut names: Vec<&'model str> = Vec::new();
for dependency in &self.dependencies {
let Some(expr) = model.conditions.get(*dependency) else {
continue;
};
names.clear();
parameter_names(expr, &mut names);
names.sort_unstable();
names.dedup();
for name in &names {
*reader_count.entry(name).or_default() += 1;
}
}
self.steps += self.dependencies.len() as u64;
self.parameters.sort_by(|(left, left_candidates), (right, right_candidates)| {
(left_candidates.len() > 1)
.cmp(&(right_candidates.len() > 1))
.then_with(|| {
let left_readers = reader_count.get(left).copied().unwrap_or_default();
let right_readers = reader_count.get(right).copied().unwrap_or_default();
right_readers.cmp(&left_readers)
})
.then_with(|| left.cmp(right))
});
self.position_of_parameter = self.parameters.iter().enumerate().map(|(at, (name, _))| (*name, at)).collect();
}
fn add_dependencies_reachable_from(&mut self, seeds: &[&'model str]) {
let conditions = &self.model.conditions;
let mut pending: Vec<&'model str> = seeds.to_vec();
while let Some(name) = pending.pop() {
let Some((dependency, expr)) = conditions.get_key_value(name) else {
continue;
};
let dependency = dependency.as_str();
if self.position_of_dependency.contains_key(dependency) {
continue;
}
self.position_of_dependency.insert(dependency, self.dependencies.len());
self.dependencies.push(dependency);
self.steps += 1;
condition_ref_names(expr, &mut pending);
}
}
fn add_parameters_of_dependencies(&mut self, param_overrides: &'model HashMap<String, Vec<String>>) {
let conditions = &self.model.conditions;
let mut names: Vec<&'model str> = Vec::new();
for dependency in &self.dependencies {
if let Some(expr) = conditions.get(*dependency) {
parameter_names(expr, &mut names);
}
}
names.sort_unstable();
names.dedup();
for name in names {
self.add_parameter(name, param_overrides);
}
}
fn add_parameter(&mut self, name: &'model str, param_overrides: &'model HashMap<String, Vec<String>>) {
if self.position_of_parameter.contains_key(name) {
return;
}
let candidates = param_overrides
.get(name)
.or_else(|| self.model.referenced_param_values().get(name))
.map_or(&[][..], Vec::as_slice);
if candidates.is_empty() {
return;
}
self.position_of_parameter.insert(name, self.parameters.len());
self.parameters.push((name, candidates));
self.steps += 1;
}
fn add_applicable_rule_constraints(&mut self, param_overrides: &'model HashMap<String, Vec<String>>) {
let model = self.model;
if model.rule_implications.is_empty() {
return;
}
let mut applied = vec![false; model.rule_implications.len()];
loop {
let mut newly_applied = false;
for (position, implication) in model.rule_implications.iter().enumerate() {
if applied[position] {
continue;
}
let (Some((antecedent, _)), Some((consequent, _))) = (
model.conditions.get_key_value(implication.antecedent.as_str()),
model.conditions.get_key_value(implication.consequent.as_str()),
) else {
applied[position] = true;
continue;
};
let endpoints = [antecedent.as_str(), consequent.as_str()];
let mut steps = 0;
let constrained_parameters = reachable_parameter_names(&model.conditions, &endpoints, &mut steps);
self.steps += steps;
if !constrained_parameters.iter().any(|name| self.position_of_parameter.contains_key(name)) {
continue;
}
applied[position] = true;
newly_applied = true;
self.add_dependencies_reachable_from(&endpoints);
self.add_parameters_of_dependencies(param_overrides);
self.rule_constraints
.push((self.position_of_dependency[endpoints[0]], self.position_of_dependency[endpoints[1]]));
}
if !newly_applied {
return;
}
}
}
fn parameter_point_count(&self) -> u64 {
self.parameters.iter().fold(1u64, |points, (_, candidates)| points.saturating_mul(candidates.len() as u64))
}
fn some_point_satisfies(&mut self, assumed: &HashMap<&'model str, bool>) -> bool {
let mut expectations: Vec<(usize, bool)> = assumed
.iter()
.filter_map(|(name, expected)| self.position_of_dependency.get(name).map(|&at| (at, *expected)))
.collect();
expectations.sort_unstable();
let parameter_masks = self.varying_parameter_masks();
let dependency_masks = self.dependency_parameter_masks(¶meter_masks);
let mut unbound_masks = vec![0u64; self.parameters.len() + 1];
for at in (0..self.parameters.len()).rev() {
unbound_masks[at] = unbound_masks[at + 1] | parameter_masks[at];
}
self.bound_depth = self.parameters.iter().take_while(|(_, candidates)| candidates.len() < 2).count();
let enumeration = PointEnumeration {
expectations: &expectations,
dependency_masks: &dependency_masks,
unbound_masks: &unbound_masks,
};
self.extend_point(&enumeration)
}
fn extend_point(&mut self, enumeration: &PointEnumeration<'_>) -> bool {
self.steps += 1;
if self.steps > MAX_SAT_ITERATIONS {
return true;
}
if !self.assumptions_can_still_hold(enumeration.expectations) {
return false;
}
let at = self.bound_depth;
if at == self.parameters.len() {
return true;
}
for candidate in 0..self.parameters[at].1.len() {
self.selected_value[at] = candidate;
self.discard_values_affected_by(enumeration.unbound_masks[at], enumeration.dependency_masks);
self.bound_depth = at + 1;
let satisfied = self.extend_point(enumeration);
self.bound_depth = at;
if satisfied {
return true;
}
}
false
}
fn assumptions_can_still_hold(&mut self, expectations: &[(usize, bool)]) -> bool {
self.forced_value.fill(None);
loop {
let forcings_before = self.forcings_made;
for &(dependency, expected) in expectations {
if !self.require_dependency(dependency, expected) {
return false;
}
}
if !self.rule_constraints_hold() {
return false;
}
if self.forcings_made == forcings_before {
return true;
}
}
}
fn require_dependency(&mut self, dependency: usize, expected: bool) -> bool {
self.steps += 1;
if let Some(determined) = self.dependency_value(dependency) {
return determined == expected;
}
match self.forced_value[dependency] {
Some(already_forced) => return already_forced == expected,
None => {
self.forced_value[dependency] = Some(expected);
self.forcings_made += 1;
}
}
let Some(expr) = self.model.conditions.get(self.dependencies[dependency]) else {
return true;
};
self.require(expr, expected)
}
fn require(&mut self, expr: &'model ConditionExpr, expected: bool) -> bool {
self.steps += 1;
match expr {
ConditionExpr::ConditionRef(name) => match self.position_of_dependency.get(name.as_str()).copied() {
Some(dependency) => self.require_dependency(dependency, expected),
None => true,
},
ConditionExpr::Not(operand) => self.require(operand, !expected),
ConditionExpr::And(operands) if expected => operands.iter().all(|operand| self.require(operand, true)),
ConditionExpr::Or(operands) if !expected => operands.iter().all(|operand| self.require(operand, false)),
ConditionExpr::And(operands) => self.require_any(operands, false),
ConditionExpr::Or(operands) => self.require_any(operands, true),
ConditionExpr::Equals(_, _) | ConditionExpr::Invalid => true,
}
}
fn require_any(&mut self, operands: &'model [ConditionExpr], satisfying: bool) -> bool {
let mut undecided: Option<&'model ConditionExpr> = None;
let mut undecided_operands = 0usize;
for operand in operands {
match self.evaluate(operand) {
Some(value) if value == satisfying => return true,
Some(_) => {}
None => {
undecided_operands += 1;
undecided = Some(operand);
}
}
}
match (undecided_operands, undecided) {
(0, _) => false,
(1, Some(last_candidate)) => self.require(last_candidate, satisfying),
_ => true,
}
}
fn rule_constraints_hold(&mut self) -> bool {
for at in 0..self.rule_constraints.len() {
let (antecedent, consequent) = self.rule_constraints[at];
if self.dependency_belief(antecedent) == Some(true) && !self.require_dependency(consequent, true) {
return false;
}
if self.dependency_belief(consequent) == Some(false) && !self.require_dependency(antecedent, false) {
return false;
}
}
true
}
fn varying_parameter_masks(&self) -> Vec<u64> {
let mut masks = Vec::with_capacity(self.parameters.len());
let mut next_bit = 0u32;
for (_, candidates) in &self.parameters {
if candidates.len() < 2 {
masks.push(0);
continue;
}
masks.push(1u64 << next_bit);
next_bit = (next_bit + 1).min(u64::BITS - 1);
}
masks
}
fn dependency_parameter_masks(&mut self, parameter_masks: &[u64]) -> Vec<u64> {
let model = self.model;
let mut masks = vec![0u64; self.dependencies.len()];
let mut references: Vec<Vec<usize>> = vec![Vec::new(); self.dependencies.len()];
let mut names: Vec<&'model str> = Vec::new();
let mut referenced: Vec<&'model str> = Vec::new();
for (at, dependency) in self.dependencies.iter().enumerate() {
let Some(expr) = model.conditions.get(*dependency) else {
continue;
};
names.clear();
parameter_names(expr, &mut names);
for name in &names {
if let Some(&position) = self.position_of_parameter.get(*name) {
masks[at] |= parameter_masks[position];
}
}
referenced.clear();
condition_ref_names(expr, &mut referenced);
references[at] =
referenced.iter().filter_map(|name| self.position_of_dependency.get(*name).copied()).collect();
}
self.steps += self.dependencies.len() as u64;
loop {
let mut grew = false;
for at in 0..masks.len() {
let propagated = references[at].iter().fold(masks[at], |mask, &reference| mask | masks[reference]);
if propagated != masks[at] {
masks[at] = propagated;
grew = true;
}
}
self.steps += masks.len() as u64;
if !grew {
return masks;
}
}
}
fn discard_values_affected_by(&mut self, changed_parameters: u64, dependency_masks: &[u64]) {
for (at, value) in self.value_at_point.iter_mut().enumerate() {
if dependency_masks[at] & changed_parameters != 0 {
*value = PointValue::NotEvaluated;
}
}
}
fn dependency_value(&mut self, dependency: usize) -> Option<bool> {
match self.value_at_point[dependency] {
PointValue::Determined(value) => return Some(value),
PointValue::Undetermined => return None,
PointValue::NotEvaluated => {}
}
if self.being_evaluated[dependency] {
self.provisional_reads += 1;
return None;
}
let Some(expr) = self.model.conditions.get(self.dependencies[dependency]) else {
self.value_at_point[dependency] = PointValue::Undetermined;
return None;
};
self.being_evaluated[dependency] = true;
let provisional_reads_before = self.provisional_reads;
let value = self.evaluate(expr);
self.being_evaluated[dependency] = false;
if self.provisional_reads == provisional_reads_before {
self.value_at_point[dependency] = value.map_or(PointValue::Undetermined, PointValue::Determined);
}
value
}
fn dependency_belief(&mut self, dependency: usize) -> Option<bool> {
if let Some(determined) = self.dependency_value(dependency) {
return Some(determined);
}
let forced = self.forced_value[dependency];
if forced.is_some() {
self.provisional_reads += 1;
}
forced
}
fn evaluate(&mut self, expr: &'model ConditionExpr) -> Option<bool> {
self.steps += 1;
match expr {
ConditionExpr::Equals(left, right) => {
let left = self.value_at_current_point(left)?;
let right = self.value_at_current_point(right)?;
Some(left == right)
}
ConditionExpr::And(operands) => {
let mut all_determined = true;
for operand in operands {
match self.evaluate(operand) {
Some(false) => return Some(false),
Some(true) => {}
None => all_determined = false,
}
}
all_determined.then_some(true)
}
ConditionExpr::Or(operands) => {
let mut all_determined = true;
for operand in operands {
match self.evaluate(operand) {
Some(true) => return Some(true),
Some(false) => {}
None => all_determined = false,
}
}
all_determined.then_some(false)
}
ConditionExpr::Not(operand) => self.evaluate(operand).map(|value| !value),
ConditionExpr::ConditionRef(name) => {
let dependency = self.position_of_dependency.get(name.as_str()).copied()?;
self.dependency_belief(dependency)
}
ConditionExpr::Invalid => None,
}
}
fn value_at_current_point(&mut self, expr: &'model ValueExpr) -> Option<String> {
match expr {
ValueExpr::Literal(literal) => Some(literal.clone()),
ValueExpr::ParamRef(name) => self.selected_parameter_value(name).map(str::to_string),
ValueExpr::PseudoParam(name) => self
.selected_parameter_value(name)
.map(str::to_string)
.or_else(|| self.model.pseudo_overrides.fixed_value(name)),
ValueExpr::MappingLookup { map_name, key1, key2 } => {
let top_level_key = self.value_at_current_point(key1)?;
let second_level_key = self.value_at_current_point(key2)?;
let mapped = self.model.mappings.get(map_name)?.get(&top_level_key)?.get(&second_level_key)?;
match mapped {
serde_json::Value::String(text) => Some(text.clone()),
other => Some(other.to_string()),
}
}
ValueExpr::Other => None,
}
}
fn selected_parameter_value(&self, name: &str) -> Option<&'model str> {
let position = *self.position_of_parameter.get(name)?;
if position >= self.bound_depth {
return None;
}
self.parameters[position].1.get(self.selected_value[position]).map(String::as_str)
}
}
fn condition_ref_names<'expr>(expr: &'expr ConditionExpr, out: &mut Vec<&'expr str>) {
match expr {
ConditionExpr::ConditionRef(name) => out.push(name.as_str()),
ConditionExpr::And(operands) | ConditionExpr::Or(operands) => {
for operand in operands {
condition_ref_names(operand, out);
}
}
ConditionExpr::Not(operand) => condition_ref_names(operand, out),
ConditionExpr::Equals(_, _) | ConditionExpr::Invalid => {}
}
}
fn parameter_names<'expr>(expr: &'expr ConditionExpr, out: &mut Vec<&'expr str>) {
match expr {
ConditionExpr::Equals(left, right) => {
parameter_names_of_value(left, out);
parameter_names_of_value(right, out);
}
ConditionExpr::And(operands) | ConditionExpr::Or(operands) => {
for operand in operands {
parameter_names(operand, out);
}
}
ConditionExpr::Not(operand) => parameter_names(operand, out),
ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
}
}
fn parameter_names_of_value<'expr>(expr: &'expr ValueExpr, out: &mut Vec<&'expr str>) {
match expr {
ValueExpr::ParamRef(name) | ValueExpr::PseudoParam(name) => out.push(name.as_str()),
ValueExpr::MappingLookup { key1, key2, .. } => {
parameter_names_of_value(key1, out);
parameter_names_of_value(key2, out);
}
ValueExpr::Literal(_) | ValueExpr::Other => {}
}
}
fn reachable_parameter_names<'conditions>(
conditions: &'conditions HashMap<String, ConditionExpr>,
seeds: &[&'conditions str],
steps: &mut u64,
) -> Vec<&'conditions str> {
let mut visited: HashSet<&'conditions str> = HashSet::new();
let mut pending: Vec<&'conditions str> = seeds.to_vec();
let mut names = Vec::new();
while let Some(name) = pending.pop() {
if !visited.insert(name) {
continue;
}
*steps += 1;
if let Some(expr) = conditions.get(name) {
parameter_names(expr, &mut names);
condition_ref_names(expr, &mut pending);
}
}
names.sort_unstable();
names.dedup();
names
}
fn extract_implications(conditions: &HashMap<String, ConditionExpr>) -> Vec<Implication> {
let mut implications = Vec::new();
for (name, expr) in conditions {
match expr {
ConditionExpr::And(children) => {
let mut refs = Vec::new();
collect_same_operator_refs(children, true, &mut refs);
for ref_name in refs {
implications.push(Implication { antecedent: name.clone(), consequent: ref_name });
}
}
ConditionExpr::Or(children) => {
let mut refs = Vec::new();
collect_same_operator_refs(children, false, &mut refs);
for ref_name in refs {
implications.push(Implication { antecedent: ref_name, consequent: name.clone() });
}
}
_ => {}
}
}
implications
}
fn collect_same_operator_refs(exprs: &[ConditionExpr], in_and: bool, out: &mut Vec<String>) {
for child in exprs {
match child {
ConditionExpr::ConditionRef(name) => out.push(name.clone()),
ConditionExpr::And(children) if in_and => collect_same_operator_refs(children, in_and, out),
ConditionExpr::Or(children) if !in_and => collect_same_operator_refs(children, in_and, out),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::PseudoParameterOverrides;
use crate::parser;
use crate::resolver::{extract_mappings, extract_parameters};
use std::fmt::Write;
fn build_condition_model(input: &str) -> ConditionModel {
let ir = parser::parse(input.as_bytes()).unwrap();
let (params, _) = extract_parameters(&ir);
let (mappings, _) = extract_mappings(&ir);
let pseudo = PseudoParameterOverrides::default();
ConditionModel::from_ir(&ir, ¶ms, &pseudo, &mappings)
}
#[test]
fn parse_simple_conditions() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues:
- Prod
- Dev
Conditions:
isProduction:
Fn::Equals:
- !Ref Env
- Prod
isDevelopment:
Fn::Equals:
- !Ref Env
- Dev
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert_eq!(model.conditions.len(), 2);
assert!(model.conditions.contains_key("isProduction"));
assert!(model.conditions.contains_key("isDevelopment"));
}
#[test]
fn mutex_group_extraction() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
isProduction:
Fn::Equals: [!Ref Env, Prod]
isDevelopment:
Fn::Equals: [!Ref Env, Dev]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(!model.mutex_groups.is_empty());
assert_eq!(model.mutex_groups[0].conditions.len(), 2);
}
#[test]
fn satisfiable_single_true() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
isProduction:
Fn::Equals: [!Ref Env, Prod]
isDevelopment:
Fn::Equals: [!Ref Env, Dev]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.is_satisfiable(&[("isProduction".into(), true)]));
}
#[test]
fn satisfiable_mutex_violation() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
isProduction:
Fn::Equals: [!Ref Env, Prod]
isDevelopment:
Fn::Equals: [!Ref Env, Dev]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(!model.conditions_compatible("isProduction", "isDevelopment"));
}
#[test]
fn resources_compatible_both_unconditional() {
let input = r#"
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.resources_compatible(None, None));
}
#[test]
fn no_conditions_template() {
let input = r#"{"Resources":{"R":{"Type":"T"}}}"#;
let model = build_condition_model(input);
assert!(model.conditions.is_empty());
assert!(model.is_satisfiable(&[]));
}
#[test]
fn condition_implies_same_condition() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
isProduction:
Fn::Equals: [!Ref Env, Prod]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.condition_implies("isProduction", "isProduction"));
}
#[test]
fn is_satisfiable_contradictory_assumptions() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
isProduction:
Fn::Equals: [!Ref Env, Prod]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(!model.is_satisfiable(&[("isProduction".into(), true), ("isProduction".into(), false),]));
}
#[test]
fn condition_implies_mutex_conditions() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
isProduction:
Fn::Equals: [!Ref Env, Prod]
isDevelopment:
Fn::Equals: [!Ref Env, Dev]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(!model.condition_implies("isProduction", "isDevelopment"));
assert!(!model.condition_implies("isDevelopment", "isProduction"));
}
#[test]
fn compatible_or_condition_without_allowed_values() {
let input = r#"
Parameters:
Env:
Type: String
Conditions:
IsProd:
Fn::Equals: [!Ref Env, Prod]
IsProdOrStage:
Fn::Or:
- Condition: IsProd
- Fn::Equals: [!Ref Env, Stage]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.conditions_compatible("IsProd", "IsProdOrStage"));
assert!(model.condition_implies("IsProd", "IsProdOrStage"));
assert!(!model.condition_implies("IsProdOrStage", "IsProd"));
}
#[test]
fn tautological_equals_literal_match() {
let input = r#"
Conditions:
AlwaysTrue:
Fn::Equals: ["same", "same"]
AlwaysFalse:
Fn::Equals: ["a", "b"]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
let taut = model.tautological_equals();
assert!(taut.iter().any(|(n, v)| n == "AlwaysTrue" && *v));
assert!(taut.iter().any(|(n, v)| n == "AlwaysFalse" && !*v));
}
#[test]
fn tautological_equals_nested_in_and() {
let input = r#"
Parameters:
Env:
Type: String
Conditions:
Nested:
Fn::And:
- Fn::Equals: ["x", "x"]
- Fn::Equals: [!Ref Env, Prod]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
let taut = model.tautological_equals();
assert!(
taut.iter().any(|(n, v)| n == "Nested" && *v),
"should detect tautological Equals inside And: {:?}",
taut
);
}
#[test]
fn referenced_params_collected() {
let input = r#"
Parameters:
Env:
Type: String
Region:
Type: String
Conditions:
IsProd:
Fn::Equals: [!Ref Env, Prod]
IsUsEast:
Fn::Equals: [!Ref Region, us-east-1]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
let refs = model.referenced_params();
assert!(refs.contains(&"Env".to_string()));
assert!(refs.contains(&"Region".to_string()));
assert_eq!(refs.len(), 2);
}
#[test]
fn and_implication_extracted() {
let input = r#"
Parameters:
Env:
Type: String
DB:
Type: String
AllowedValues: [yes, no]
Conditions:
IsProd:
Fn::Equals: [!Ref Env, Prod]
CreateDB:
Fn::Equals: [!Ref DB, yes]
ProdAndDB:
Fn::And:
- Condition: IsProd
- Condition: CreateDB
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.implications.iter().any(|i| i.antecedent == "ProdAndDB" && i.consequent == "IsProd"));
assert!(model.implications.iter().any(|i| i.antecedent == "ProdAndDB" && i.consequent == "CreateDB"));
}
#[test]
fn or_implication_extracted() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev, Stage]
Conditions:
IsProd:
Fn::Equals: [!Ref Env, Prod]
IsDev:
Fn::Equals: [!Ref Env, Dev]
ProdOrDev:
Fn::Or:
- Condition: IsProd
- Condition: IsDev
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.implications.iter().any(|i| i.antecedent == "IsProd" && i.consequent == "ProdOrDev"));
assert!(model.implications.iter().any(|i| i.antecedent == "IsDev" && i.consequent == "ProdOrDev"));
}
#[test]
fn three_way_mutex_group() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev, Stage]
Conditions:
IsProd:
Fn::Equals: [!Ref Env, Prod]
IsDev:
Fn::Equals: [!Ref Env, Dev]
IsStage:
Fn::Equals: [!Ref Env, Stage]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert_eq!(model.mutex_groups.len(), 1);
assert_eq!(model.mutex_groups[0].conditions.len(), 3);
assert!(!model.conditions_compatible("IsProd", "IsDev"));
assert!(!model.conditions_compatible("IsProd", "IsStage"));
assert!(!model.conditions_compatible("IsDev", "IsStage"));
}
#[test]
fn resources_compatible_one_unconditional() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
IsProd:
Fn::Equals: [!Ref Env, Prod]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.resources_compatible(Some("IsProd"), None));
assert!(model.resources_compatible(None, Some("IsProd")));
}
#[test]
fn condition_names_iterator() {
let input = r#"
Conditions:
A:
Fn::Equals: ["x", "x"]
B:
Fn::Equals: ["y", "y"]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
let mut names: Vec<&str> = model.names().collect();
names.sort();
assert_eq!(names, vec!["A", "B"]);
}
#[test]
fn get_returns_none_for_unknown_condition() {
let input = r#"{"Resources":{"R":{"Type":"T"}}}"#;
let model = build_condition_model(input);
assert!(model.get("NonExistent").is_none(), "unknown condition should return None");
}
#[test]
fn not_condition_parsed() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
IsProd:
Fn::Equals: [!Ref Env, Prod]
NotProd:
Fn::Not:
- Condition: IsProd
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(!model.conditions_compatible("IsProd", "NotProd"));
assert!(!model.condition_implies("IsProd", "NotProd"));
}
#[test]
fn pseudo_param_without_override_is_a_free_variable_in_sat_solver() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
IsUsEast1:
Fn::Equals: [!Ref "AWS::Region", us-east-1]
IsProd:
Fn::Equals: [!Ref Env, Prod]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(
model.is_satisfiable(&[("IsUsEast1".into(), true)]),
"IsUsEast1=true must be reachable: AWS::Region can equal 'us-east-1'"
);
assert!(
model.is_satisfiable(&[("IsUsEast1".into(), false)]),
"IsUsEast1=false must be reachable: AWS::Region can equal anything other than 'us-east-1' \
(the auto-derived default region must not pin the pseudo-parameter to a constant - that \
produced false-positive W1028 unreachable-branch diagnostics on partition/region branches)"
);
assert!(
model.is_satisfiable(&[("IsProd".into(), true)]),
"IsProd=true must be reachable: Env can equal 'Prod'"
);
assert!(
model.is_satisfiable(&[("IsProd".into(), false)]),
"IsProd=false must be reachable: Env can equal 'Dev'"
);
}
#[test]
fn is_satisfiable_in_region_pins_aws_region() {
let input = r#"
Conditions:
IsUsEast1:
Fn::Equals: [!Ref "AWS::Region", us-east-1]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(
model.is_satisfiable_in_region(&[("IsUsEast1".into(), true)], "us-east-1"),
"IsUsEast1=true must hold when the target region IS us-east-1"
);
assert!(
!model.is_satisfiable_in_region(&[("IsUsEast1".into(), true)], "us-west-2"),
"IsUsEast1=true must be unsatisfiable when the target region is us-west-2"
);
assert!(
model.is_satisfiable_in_region(&[("IsUsEast1".into(), false)], "us-west-2"),
"IsUsEast1=false must hold at us-west-2"
);
}
#[test]
fn pseudo_param_explicit_override_pins_value_in_sat_solver() {
let ir = parser::parse(
r#"
Conditions:
IsAwsPartition:
Fn::Equals: [!Ref "AWS::Partition", aws]
Resources:
R:
Type: T
"#
.as_bytes(),
)
.unwrap();
let (params, _) = extract_parameters(&ir);
let (mappings, _) = extract_mappings(&ir);
let pseudo = PseudoParameterOverrides { partition: Some("aws-cn".to_string()), ..Default::default() };
let model = ConditionModel::from_ir(&ir, ¶ms, &pseudo, &mappings);
assert!(
!model.is_satisfiable(&[("IsAwsPartition".into(), true)]),
"with partition pinned to aws-cn, IsAwsPartition=true must be unsatisfiable"
);
assert!(
model.is_satisfiable(&[("IsAwsPartition".into(), false)]),
"with partition pinned to aws-cn, IsAwsPartition=false must hold"
);
}
#[test]
fn pseudo_param_custom_region_changes_satisfiability() {
let ir = parser::parse(
r#"
Conditions:
IsUsEast1:
Fn::Equals: [!Ref "AWS::Region", us-east-1]
Resources:
R:
Type: T
"#
.as_bytes(),
)
.unwrap();
let (params, _) = extract_parameters(&ir);
let (mappings, _) = extract_mappings(&ir);
let pseudo = PseudoParameterOverrides { region: Some("eu-west-1".to_string()), ..Default::default() };
let model = ConditionModel::from_ir(&ir, ¶ms, &pseudo, &mappings);
assert!(!model.is_satisfiable(&[("IsUsEast1".into(), true)]));
assert!(model.is_satisfiable(&[("IsUsEast1".into(), false)]));
}
#[test]
fn pseudo_param_partition_false_branch_is_reachable_without_override() {
let input = r#"
Conditions:
HasEcrPublic:
Fn::Equals: [!Ref "AWS::Partition", aws]
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(
model.is_satisfiable(&[("HasEcrPublic".into(), true)]),
"HasEcrPublic=true must be reachable: AWS::Partition can equal 'aws'"
);
assert!(
model.is_satisfiable(&[("HasEcrPublic".into(), false)]),
"HasEcrPublic=false must be reachable: AWS::Partition can equal 'aws-cn' or 'aws-us-gov'"
);
}
#[test]
fn mapping_lookup_resolves_in_condition() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Mappings:
EnvMap:
Prod:
Label: production
Dev:
Label: development
Conditions:
IsProdLabel:
Fn::Equals:
- !FindInMap [EnvMap, !Ref Env, Label]
- production
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.is_satisfiable(&[("IsProdLabel".into(), true)]));
assert!(model.is_satisfiable(&[("IsProdLabel".into(), false)]));
}
#[test]
fn mapping_lookup_format_value_expr_readable() {
let expr = ValueExpr::MappingLookup {
map_name: "MyMap".to_string(),
key1: Box::new(ValueExpr::ParamRef("Env".to_string())),
key2: Box::new(ValueExpr::Literal("Label".to_string())),
};
let formatted = format_value_expr(&expr);
assert!(formatted.contains("FindInMap"));
assert!(formatted.contains("MyMap"));
}
#[test]
fn mapping_lookup_param_refs_collected() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Mappings:
EnvMap:
Prod:
Label: production
Dev:
Label: development
Conditions:
IsProdLabel:
Fn::Equals:
- !FindInMap [EnvMap, !Ref Env, Label]
- production
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
let refs = model.referenced_params();
assert!(refs.contains(&"Env".to_string()));
}
#[test]
fn extract_equals_test_unwraps_not() {
let expr = ConditionExpr::Not(Box::new(ConditionExpr::Equals(
ValueExpr::ParamRef("Env".into()),
ValueExpr::Literal("Prod".into()),
)));
let result = extract_equals_test(&expr);
let (param, lit, positive) = result.expect("extract_equals_test should return Some for Not(Equals)");
assert_eq!(param, "Env");
assert_eq!(lit, "Prod");
assert!(!positive);
}
#[test]
fn nested_and_implication_extracts_deep_condition_refs() {
let input = r#"
Parameters:
A:
Type: String
AllowedValues: [yes, no]
B:
Type: String
AllowedValues: [yes, no]
C:
Type: String
AllowedValues: [yes, no]
Conditions:
CondA:
Fn::Equals: [!Ref A, yes]
CondB:
Fn::Equals: [!Ref B, yes]
CondC:
Fn::Equals: [!Ref C, yes]
NestedAnd:
Fn::And:
- Fn::And:
- Condition: CondA
- Condition: CondB
- Condition: CondC
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondA"));
assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondB"));
assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondC"));
}
#[test]
fn nested_or_implication_extracts_deep_condition_refs() {
let input = r#"
Parameters:
A:
Type: String
AllowedValues: [yes, no]
B:
Type: String
AllowedValues: [yes, no]
Conditions:
CondA:
Fn::Equals: [!Ref A, yes]
CondB:
Fn::Equals: [!Ref B, yes]
NestedOr:
Fn::Or:
- Fn::Or:
- Condition: CondA
- Condition: CondB
Resources:
R:
Type: T
"#;
let model = build_condition_model(input);
assert!(model.implications.iter().any(|i| i.antecedent == "CondA" && i.consequent == "NestedOr"));
assert!(model.implications.iter().any(|i| i.antecedent == "CondB" && i.consequent == "NestedOr"));
}
#[test]
fn register_inline_adds_condition_and_rebuilds_mutex() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Conditions:
IsProd:
Fn::Equals: [!Ref Env, Prod]
Resources:
R:
Type: T
"#;
let mut model = build_condition_model(input);
assert_eq!(model.conditions.len(), 1);
model.register_inline(
"__inline_1".to_string(),
ConditionExpr::Equals(ValueExpr::ParamRef("Env".to_string()), ValueExpr::Literal("Dev".to_string())),
);
assert_eq!(model.conditions.len(), 2);
assert!(!model.mutex_groups.is_empty());
assert!(!model.conditions_compatible("IsProd", "__inline_1"));
}
#[test]
fn ifexpr_literal_equals_eagerly_evaluated() {
let input = r#"{
"Conditions": {"C": {"Fn::Equals": ["x", "x"]}},
"Resources": {
"R": {
"Type": "T",
"Properties": {
"V": {"Fn::If": [{"Fn::Equals": ["same", "same"]}, "yes", "no"]}
}
}
}
}"#;
let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
match model.resolve("R", "Properties.V") {
Some(crate::resolver::ResolvedValue::Concrete { value: v }) => {
assert_eq!(v.as_str().unwrap(), "yes");
}
other => panic!("Expected Concrete(\"yes\") from eager eval, got {:?}", other),
}
}
#[test]
fn ifexpr_literal_not_equals_eagerly_evaluated_to_false_branch() {
let input = r#"{
"Conditions": {"C": {"Fn::Equals": ["x", "x"]}},
"Resources": {
"R": {
"Type": "T",
"Properties": {
"V": {"Fn::If": [{"Fn::Equals": ["a", "b"]}, "yes", "no"]}
}
}
}
}"#;
let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
match model.resolve("R", "Properties.V") {
Some(crate::resolver::ResolvedValue::Concrete { value: v }) => {
assert_eq!(v.as_str().unwrap(), "no");
}
other => panic!("Expected Concrete(\"no\") from eager eval, got {:?}", other),
}
}
#[test]
fn ifexpr_non_literal_registers_inline_condition() {
let input = r#"
Parameters:
Env:
Type: String
AllowedValues: [Prod, Dev]
Resources:
R:
Type: T
Properties:
V:
Fn::If:
- Fn::Equals: [!Ref Env, Prod]
- big
- small
"#;
let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
let inline_count = model.conditions.conditions.keys().filter(|k| k.starts_with("__inline_cond_")).count();
assert!(inline_count > 0, "Expected at least one inline condition registered");
}
fn chain_with_contradiction(chain_len: usize) -> String {
let mut s = String::from(
"Parameters:\n P0:\n Type: String\n AllowedValues: [yes, no]\n \
P1:\n Type: String\n AllowedValues: [yes, no]\n\
Conditions:\n C000:\n Fn::Equals: [!Ref P0, yes]\n \
C001:\n Fn::Equals: [!Ref P1, yes]\n",
);
for i in 2..chain_len {
let outer_inner = if i % 2 == 0 { "Fn::Or" } else { "Fn::And" };
let _ = write!(
s,
" C{:03}:\n Fn::Not:\n - {}:\n - Fn::Not:\n - Condition: C{:03}\n - Fn::Not:\n - Condition: C{:03}\n",
i,
outer_inner,
i - 1,
i - 2
);
}
let top = format!("C{:03}", chain_len - 1);
let _ = write!(
s,
" Contra:\n Fn::And:\n - Condition: {top}\n \
- Fn::Not:\n - Condition: {top}\n\
Resources:\n R:\n Type: T\n",
);
s
}
#[test]
fn contradiction_over_a_long_condition_chain_is_decided_exactly_and_cheaply() {
const SHORT_CHAIN: usize = 8;
const LONG_CHAIN: usize = 32;
const DOUBLED_CHAIN: usize = 64;
for chain_length in [SHORT_CHAIN, LONG_CHAIN, DOUBLED_CHAIN] {
let model = build_condition_model(&chain_with_contradiction(chain_length));
assert!(
!model.is_satisfiable(&[("Contra".to_string(), true)]),
"a contradiction over a {chain_length}-condition chain must be proven \
unsatisfiable, not conceded as satisfiable because the search was too expensive"
);
}
const MAX_GROWTH_FROM_DOUBLING: u64 = 4;
let long_model = build_condition_model(&chain_with_contradiction(LONG_CHAIN));
let _ = long_model.is_satisfiable(&[("Contra".to_string(), true)]);
let doubled_model = build_condition_model(&chain_with_contradiction(DOUBLED_CHAIN));
let _ = doubled_model.is_satisfiable(&[("Contra".to_string(), true)]);
let long_chain_work = long_model.sat_iterations_used();
let doubled_chain_work = doubled_model.sat_iterations_used();
assert!(
doubled_chain_work <= long_chain_work * MAX_GROWTH_FROM_DOUBLING,
"doubling the chain length must not multiply the work by more than \
{MAX_GROWTH_FROM_DOUBLING}x; {LONG_CHAIN} conditions cost {long_chain_work} steps and \
{DOUBLED_CHAIN} cost {doubled_chain_work}"
);
assert!(
doubled_chain_work < MAX_SAT_ITERATIONS,
"deciding a chain of {DOUBLED_CHAIN} conditions over two binary parameters must stay \
far inside the per-query budget; cost {doubled_chain_work} of {MAX_SAT_ITERATIONS}"
);
}
fn conditions_layered_over_shared_pseudo_parameters(layered: usize) -> String {
const PARTITIONS: [&str; 4] = ["aws", "aws-us-gov", "aws-cn", "aws-iso"];
const REGIONS: [&str; 4] = ["us-east-1", "us-west-2", "us-gov-west-1", "cn-north-1"];
let mut template = String::from("Parameters:\n Stage:\n Type: String\nConditions:\n");
let mut base_names = Vec::new();
for (index, partition) in PARTITIONS.iter().enumerate() {
let _ = write!(template, " IsPartition{index}:\n Fn::Equals: [!Ref 'AWS::Partition', {partition}]\n");
base_names.push(format!("IsPartition{index}"));
}
for (index, region) in REGIONS.iter().enumerate() {
let _ = write!(template, " IsRegion{index}:\n Fn::Equals: [!Ref 'AWS::Region', {region}]\n");
base_names.push(format!("IsRegion{index}"));
}
let _ = write!(template, " IsProd:\n Fn::Equals: [!Ref Stage, prod]\n");
base_names.push("IsProd".to_string());
let mut names = base_names.clone();
for index in 0..layered {
let operator = if index % 2 == 0 { "Fn::And" } else { "Fn::Or" };
let operands: Vec<&String> = (0..3).map(|offset| &names[(index * 7 + offset * 3) % names.len()]).collect();
let _ = write!(template, " Layered{index:03}:\n {operator}:\n");
for operand in operands {
let _ = writeln!(template, " - Condition: {operand}");
}
names.push(format!("Layered{index:03}"));
}
let _ = write!(
template,
"Resources:\n R:\n Type: AWS::SNS::Topic\n Condition: {}\n",
names[names.len() - 1]
);
template
}
#[test]
fn conditions_layered_over_shared_inputs_are_analyzed_in_full() {
const LAYERED_CONDITIONS: usize = 91;
let model = build_condition_model(&conditions_layered_over_shared_pseudo_parameters(LAYERED_CONDITIONS));
let mut names: Vec<&str> = model.names().collect();
names.sort_unstable();
assert_eq!(names.len(), LAYERED_CONDITIONS + 9, "the fixture must reach the scale under test");
let mut decided_pairs = 0u64;
for (offset, first) in names.iter().enumerate() {
for second in &names[offset + 1..] {
let _ = model.conditions_compatible(first, second);
decided_pairs += 1;
}
}
assert!(
!model.satisfiability_budget_exhausted(),
"the full pairwise pass over {} conditions must fit in the cumulative budget; it spent \
{} of {MAX_TOTAL_SAT_ITERATIONS} deciding {decided_pairs} pairs",
names.len(),
model.sat_iterations_used()
);
assert!(
model.budget_exhausted_queries().is_empty(),
"no query over conditions layered on shared inputs may exceed its budget: {:?}",
model.budget_exhausted_queries()
);
assert!(
!model.conditions_compatible("IsPartition0", "IsPartition1"),
"conditions comparing the same pseudo-parameter with different values must be proven \
incompatible"
);
}
#[test]
fn cumulative_budget_exhaustion_is_reported_rather_than_silently_narrowing_analysis() {
let model = build_condition_model(&chain_with_contradiction(8));
assert!(model.budget_exhausted_queries().is_empty(), "nothing is curtailed before any work is charged");
model.add_sat_iterations_for_test(MAX_TOTAL_SAT_ITERATIONS);
assert!(model.satisfiability_budget_exhausted(), "the budget must register as spent");
assert!(
!model.budget_exhausted_queries().is_empty(),
"spending the cumulative budget must be reported so a curtailed analysis is never silent"
);
}
#[test]
fn self_referential_conditions_are_undetermined_rather_than_looping() {
let model = build_condition_model(
"Parameters:\n Env:\n Type: String\n AllowedValues: [prod, dev]\n\
Conditions:\n Looping:\n Fn::And:\n - Condition: AlsoLooping\n \
- Fn::Equals: [!Ref Env, prod]\n \
AlsoLooping:\n Fn::Or:\n - Condition: Looping\n \
- Fn::Equals: [!Ref Env, dev]\n\
Resources:\n R:\n Type: T\n",
);
assert!(
model.is_satisfiable(&[("Looping".to_string(), true)]),
"a condition on a reference cycle cannot be decided, so it must be assumed satisfiable"
);
assert!(model.is_satisfiable(&[("Looping".to_string(), false)]), "the same holds for the negated assumption");
assert!(
model.sat_iterations_used() < MAX_SAT_ITERATIONS,
"a cycle must be cut immediately, not explored until the budget runs out; cost {}",
model.sat_iterations_used()
);
}
#[test]
fn condition_over_an_unresolvable_value_does_not_constrain_a_query() {
let model = build_condition_model(
"Parameters:\n Subnets:\n Type: CommaDelimitedList\n \
Env:\n Type: String\n AllowedValues: [prod, dev]\n\
Conditions:\n \
FirstSubnetIsReserved:\n Fn::Equals: [!Select [0, !Ref Subnets], reserved]\n \
IsProd:\n Fn::Equals: [!Ref Env, prod]\n \
IsDev:\n Fn::Equals: [!Ref Env, dev]\n \
ReservedInProd:\n Fn::And:\n - Condition: FirstSubnetIsReserved\n \
- Condition: IsProd\n\
Resources:\n R:\n Type: T\n",
);
assert!(
model.is_satisfiable(&[("FirstSubnetIsReserved".to_string(), true)]),
"an unresolvable comparison must be assumed able to hold"
);
assert!(
model.conditions_compatible("ReservedInProd", "IsProd"),
"an unresolvable operand must not make a combined condition look impossible"
);
assert!(
!model.conditions_compatible("ReservedInProd", "IsDev"),
"the resolvable operand must still constrain the combination: the And requires prod, \
which excludes dev"
);
}
#[test]
fn conditions_sharing_a_parameter_through_derived_conditions_stay_exact() {
let model = build_condition_model(
"Parameters:\n Env:\n Type: String\n AllowedValues: [prod, gamma, dev]\n \
Feature:\n Type: String\n AllowedValues: ['on', 'off']\n\
Conditions:\n \
IsProd:\n Fn::Equals: [!Ref Env, prod]\n \
IsDev:\n Fn::Equals: [!Ref Env, dev]\n \
FeatureOn:\n Fn::Equals: [!Ref Feature, 'on']\n \
ProdWithFeature:\n Fn::And:\n - Condition: IsProd\n - Condition: FeatureOn\n \
DevWithFeature:\n Fn::And:\n - Condition: IsDev\n - Condition: FeatureOn\n \
EitherWithFeature:\n Fn::Or:\n - Condition: ProdWithFeature\n \
- Condition: DevWithFeature\n\
Resources:\n R:\n Type: T\n",
);
assert!(
!model.conditions_compatible("ProdWithFeature", "DevWithFeature"),
"conditions requiring different values of the same parameter cannot both hold, however \
many references separate them from that parameter"
);
assert!(
model.conditions_compatible("ProdWithFeature", "EitherWithFeature"),
"a condition and a disjunction it satisfies must remain compatible"
);
assert!(
model.condition_implies("ProdWithFeature", "FeatureOn"),
"a conjunction must be proven to imply its operand"
);
assert!(
!model.condition_implies("EitherWithFeature", "IsProd"),
"a disjunction must not be proven to imply only one of its branches"
);
}
fn wide_parameter_closure(param_count: usize) -> String {
let mut s = String::from("Parameters:\n");
for i in 0..param_count {
let _ = write!(s, " P{i:02}:\n Type: String\n AllowedValues: [yes, no]\n");
}
s.push_str("Conditions:\n");
for i in 0..param_count {
let _ = write!(s, " Base{i:02}:\n Fn::Equals: [!Ref P{i:02}, yes]\n");
}
s.push_str(" Wide:\n Fn::And:\n");
for i in 0..param_count {
let _ = writeln!(s, " - Condition: Base{i:02}");
}
s.push_str("Resources:\n R:\n Type: T\n");
s
}
#[test]
fn satisfiability_with_wide_parameter_closure_is_capped_conservatively() {
const WIDE_PARAMS: usize = 24;
let mut model = build_condition_model(&wide_parameter_closure(WIDE_PARAMS));
let wide_expression = model.get("Wide").cloned().expect("the generated model must contain Wide");
model.register_inline("__inline_cond_test".to_string(), wide_expression);
assert!(
model.is_satisfiable(&[("Base00".to_string(), true), ("__inline_cond_test".to_string(), true)]),
"a condition whose closure references more parameters than the cap must be \
assumed satisfiable, not enumerated"
);
assert_eq!(
model.budget_exhausted_queries(),
vec!["<inline condition>=true, Base00=true".to_string()],
"budget advisories must canonicalize assumption order and describe synthetic conditions generically"
);
assert!(
model.sat_iterations_used() < MAX_PARAM_COMBINATIONS,
"the parameter cap must short-circuit before enumerating the cartesian product; \
charged {} iterations, expected well under {MAX_PARAM_COMBINATIONS}",
model.sat_iterations_used()
);
}
#[test]
fn satisfiability_with_narrow_parameter_closure_stays_exact() {
let model = build_condition_model(
"Parameters:\n Env:\n Type: String\n AllowedValues: [prod, dev]\n\
Conditions:\n IsProd:\n Fn::Equals: [!Ref Env, prod]\n \
IsDev:\n Fn::Equals: [!Ref Env, dev]\n\
Resources:\n R:\n Type: T\n",
);
assert!(
!model.conditions_compatible("IsProd", "IsDev"),
"mutually exclusive conditions over a small parameter space must remain provably \
incompatible"
);
}
#[test]
fn cumulative_satisfiability_budget_accumulates_across_queries_then_halts_search() {
const CHAIN: usize = 8;
let model = build_condition_model(&chain_with_contradiction(CHAIN));
assert_eq!(model.sat_iterations_used(), 0, "a freshly built model has spent none of its cumulative budget");
assert!(!model.satisfiability_budget_exhausted(), "a freshly built model's cumulative budget is not exhausted");
let mut previous = 0u64;
for _ in 0..3 {
let _ = model.is_satisfiable(&[("Contra".to_string(), true)]);
let used = model.sat_iterations_used();
assert!(
used > previous,
"each query while under budget must add to the shared cumulative counter; a \
per-query reset would be a silent denial-of-service regression. was {previous}, \
now {used}"
);
previous = used;
}
assert!(
!model.satisfiability_budget_exhausted(),
"a handful of queries must not exhaust the (large) cumulative budget"
);
let to_threshold = MAX_TOTAL_SAT_ITERATIONS - model.sat_iterations_used() - 1;
model.add_sat_iterations_for_test(to_threshold);
assert!(
!model.satisfiability_budget_exhausted(),
"one iteration short of the cap must not be exhausted; counter is {}",
model.sat_iterations_used()
);
model.add_sat_iterations_for_test(1);
assert!(
model.satisfiability_budget_exhausted(),
"reaching MAX_TOTAL_SAT_ITERATIONS must trip the exhausted flag; counter is {}",
model.sat_iterations_used()
);
let before_short_circuit = model.sat_iterations_used();
let conservative = model.is_satisfiable(&[("Contra".to_string(), true)]);
assert!(
conservative,
"a query issued after the cumulative budget is exhausted must return the conservative \
satisfiable answer"
);
assert_eq!(
model.sat_iterations_used(),
before_short_circuit,
"an exhausted-budget query must short-circuit without performing or charging further \
search work"
);
}
#[test]
fn rules_section_implication_eliminates_impossible_scenario() {
let yaml = b"
Parameters:
Env:
Type: String
Conditions:
IsProd: !Equals [!Ref Env, prod]
Rules:
RejectProd:
RuleCondition: !Equals [!Ref Env, prod]
Assertions:
- Assert: !Equals [!Ref Env, dev]
Resources:
B:
Type: AWS::S3::Bucket
Condition: IsProd
";
let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
assert!(
!model.conditions.is_satisfiable(&[("IsProd".to_string(), true)]),
"IsProd=true contradicts the Rules assertion and must be unsatisfiable"
);
assert!(model.conditions.is_satisfiable(&[("IsProd".to_string(), false)]), "IsProd=false remains satisfiable");
}
#[test]
fn rules_without_rule_condition_do_not_constrain_unrelated_queries() {
let yaml = b"
Parameters:
Env:
Type: String
Other:
Type: String
Conditions:
UsesOther: !Equals [!Ref Other, x]
Rules:
AlwaysDev:
Assertions:
- Assert: !Equals [!Ref Env, dev]
Resources:
B:
Type: AWS::S3::Bucket
Condition: UsesOther
";
let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
assert!(model.conditions.is_satisfiable(&[("UsesOther".to_string(), true)]));
assert!(model.conditions.is_satisfiable(&[("UsesOther".to_string(), false)]));
}
#[test]
fn mixed_nesting_produces_no_unsound_implications() {
let yaml = b"
Parameters:
Env:
Type: String
AllowedValues: [dev, prod]
Flag:
Type: String
AllowedValues: ['true', 'false']
Conditions:
A: !Equals [!Ref Env, prod]
B: !Equals [!Ref Env, dev]
C: !Equals [!Ref Flag, 'true']
Complex: !And [!Or [!Condition A, !Condition B], !Not [!Condition C]]
Inline: !And [!Condition B, !Condition C]
NotA: !Not [!Condition A]
Resources:
R:
Type: AWS::S3::Bucket
Condition: NotA
";
let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
assert!(
!model.conditions.implications.iter().any(|i| i.antecedent == "Complex" || i.consequent == "Complex"),
"no implication may involve the mixed-nesting condition: {:?}",
model.conditions.implications
);
assert!(
model.conditions.is_satisfiable(&[("NotA".to_string(), true), ("Inline".to_string(), false)]),
"sound implications must not prune this satisfiable scenario"
);
}
}