1use crate::authored_template::render_authored_json;
2use crate::budget::{BudgetKind, BudgetTracker};
3use crate::conditions::{ConditionModel, Satisfiability};
4use crate::consts::*;
5use crate::defect::{DefectPhase, ParseDefect};
6use crate::graph::{Edge, ReferenceGraph};
7use crate::ir::*;
8use crate::is_custom_resource_type;
9use crate::json_value::JsonValue;
10use crate::member_presence::absent_alternatives;
11use crate::regions::*;
12use crate::resolved_value::*;
13use crate::resolver::*;
14use crate::sam;
15use crate::serialization::resolved_value_to_json;
16use crate::span::SpanProvider;
17use log::{debug, info, warn};
18use serde::Serialize;
19use std::cmp::Reverse;
20use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet};
21use std::sync::Arc;
22use std::sync::Mutex;
23use std::sync::OnceLock;
24use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
25
26#[derive(Debug, Clone, Serialize, Default)]
28#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
29#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
30#[serde(rename_all = "camelCase")]
31pub struct PathValuePair {
32 pub path: String,
34 pub value: String,
35}
36
37#[derive(Debug, Clone)]
39pub struct ListMemberScenario {
40 pub container_scenario: usize,
42 pub container_conditions: HashMap<String, bool>,
44 pub index: usize,
46 pub value: ResolvedValue,
48 pub conditions: HashMap<String, bool>,
50}
51
52#[derive(Debug, Clone, Serialize, Default)]
54#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
55#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
56#[serde(rename_all = "camelCase")]
57pub struct ConditionalNullEntry {
58 pub path: String,
60 pub condition: String,
62 pub null_in_true_branch: bool,
64}
65
66#[derive(Debug, Clone, Serialize, Default)]
68#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
69#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
70#[serde(rename_all = "camelCase")]
71pub struct ResourceDiagnostics {
72 pub find_in_map_refs: Vec<String>,
74 pub simple_subs: Vec<PathValuePair>,
76 pub redundant_subs: Vec<String>,
78 pub empty_joins: Vec<String>,
80 pub condition_refs: Vec<String>,
82 pub hardcoded_partition_arns: Vec<String>,
84 pub conditionally_null_props: Vec<ConditionalNullEntry>,
85 pub foreach_expansions: Vec<ForEachExpansion>,
86 pub unsubstituted_variables: Vec<PathValuePair>,
88 pub unused_sub_keys: Vec<PathValuePair>,
90 pub raw_pseudo_params: Vec<PathValuePair>,
92 pub secretsmanager_ref_paths: Vec<String>,
94 pub invalid_refs: Vec<PathValuePair>,
96}
97
98#[derive(Debug, Clone, Serialize)]
100#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
101#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
102#[serde(rename_all = "camelCase")]
103pub struct ResolvedResource {
104 pub logical_id: String,
105 pub resource_type: String,
106 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
107 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
108 pub condition: Option<String>,
109 pub depends_on: Vec<String>,
110 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
111 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
112 pub deletion_policy: Option<ResolvedValue>,
113 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
114 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
115 pub update_replace_policy: Option<ResolvedValue>,
116 #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
117 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
118 pub update_policy: Option<JsonValue>,
119 #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
120 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
121 pub creation_policy: Option<JsonValue>,
122 #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
123 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
124 pub metadata: Option<JsonValue>,
125 #[cfg_attr(feature = "wasm-bindings", tsify(type = "Record<string, ResolvedValue>"))]
126 pub properties: HashMap<String, ResolvedValue>,
127 pub properties_dynamic: bool,
131 pub diagnostics: ResourceDiagnostics,
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct LifecycleAttributeStatus {
138 pub may_be_present: bool,
140 pub invalid_value: Option<String>,
142}
143
144struct PrimaryIdentifierScenario {
145 identity_tuple: Vec<String>,
146 display_tuple: Vec<String>,
147 assumptions: Vec<(String, bool)>,
148}
149
150type PrimaryIdentifierComparisonOrder = (usize, usize, usize, usize);
157
158struct IdentityGroupPairCursor {
164 resources: Vec<(usize, Vec<usize>)>,
167 left_resource: usize,
168 right_resource: usize,
169 left_scenario: usize,
170 right_scenario: usize,
171}
172
173impl IdentityGroupPairCursor {
174 fn new(candidates: &[(usize, usize)]) -> Option<Self> {
179 let mut resources: Vec<(usize, Vec<usize>)> = Vec::new();
180 for &(resource_index, scenario_index) in candidates {
181 match resources.last_mut() {
182 Some((last_resource_index, scenario_indices)) if *last_resource_index == resource_index => {
183 scenario_indices.push(scenario_index);
184 }
185 _ => resources.push((resource_index, vec![scenario_index])),
186 }
187 }
188 if resources.len() < 2 {
189 return None;
190 }
191 Some(Self { resources, left_resource: 0, right_resource: 1, left_scenario: 0, right_scenario: 0 })
192 }
193}
194
195impl Iterator for IdentityGroupPairCursor {
196 type Item = PrimaryIdentifierComparisonOrder;
197
198 fn next(&mut self) -> Option<Self::Item> {
199 while self.left_resource + 1 < self.resources.len() {
200 if self.right_resource >= self.resources.len() {
201 self.left_resource += 1;
202 self.right_resource = self.left_resource + 1;
203 self.left_scenario = 0;
204 self.right_scenario = 0;
205 continue;
206 }
207 if self.left_scenario >= self.resources[self.left_resource].1.len() {
208 self.right_resource += 1;
209 self.left_scenario = 0;
210 self.right_scenario = 0;
211 continue;
212 }
213 if self.right_scenario >= self.resources[self.right_resource].1.len() {
214 self.left_scenario += 1;
215 self.right_scenario = 0;
216 continue;
217 }
218 let comparison = (
219 self.resources[self.left_resource].0,
220 self.resources[self.right_resource].0,
221 self.resources[self.left_resource].1[self.left_scenario],
222 self.resources[self.right_resource].1[self.right_scenario],
223 );
224 self.right_scenario += 1;
225 return Some(comparison);
226 }
227 None
228 }
229}
230
231#[derive(Debug, Clone, Serialize)]
233#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
234#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
235#[serde(rename_all = "camelCase")]
236pub struct ForEachExpansion {
237 pub property_path: String,
239 pub identifier: String,
241 pub collection_source: String,
243}
244
245#[derive(Debug, Clone, Serialize)]
247#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
248#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
249#[serde(rename_all = "camelCase")]
250pub struct ResolvedOutput {
251 pub value: ResolvedValue,
252 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
253 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
254 pub description: Option<String>,
255 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
256 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
257 pub condition: Option<String>,
258 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
259 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
260 pub export_name: Option<ResolvedValue>,
261}
262
263#[derive(Debug, Clone, Serialize)]
264#[serde(rename_all = "camelCase")]
265pub struct TemplateRule {
266 pub name: String,
267 pub condition: Option<serde_json::Value>,
268 #[serde(skip)]
269 pub condition_node: crate::ir::NodeRef,
270 pub assertions: Vec<RuleAssertion>,
271}
272
273#[derive(Debug, Clone, Serialize)]
274#[serde(rename_all = "camelCase")]
275pub struct RuleAssertion {
276 pub assert: serde_json::Value,
277 #[serde(skip)]
278 pub assert_node: crate::ir::NodeRef,
279 pub description: Option<String>,
280}
281
282pub struct SemanticModel {
283 pub arena: Arena,
284 root: NodeRef,
286 authored_template: OnceLock<serde_json::Value>,
289 pub span_index: SourceSpanIndex,
290 conditional_branch_first_descendant_spans: HashMap<String, SourceSpan>,
291 pub format_version: Option<String>,
292 pub description: Option<String>,
293 pub transforms: Vec<String>,
294 pub raw_top_level_keys: Vec<String>,
295 pub template_metadata: Option<serde_json::Value>,
296 pub rules: Option<serde_json::Value>,
297 pub parsed_rules: Vec<TemplateRule>,
298 pub parameters: HashMap<String, ParameterInfo>,
299 pub mappings: MappingData,
300 pub conditions: ConditionModel,
301 pub resources: HashMap<String, ResolvedResource>,
302 pub outputs: HashMap<String, ResolvedOutput>,
303 pub graph: ReferenceGraph,
304 pub resources_by_type: HashMap<String, Vec<String>>,
305 pub diagnostics: Vec<ParseDefect>,
306 pub output_empty_joins: Vec<String>,
307 pub sam_globals: HashMap<String, HashMap<String, serde_json::Value>>,
308 pub sam_implicit_resources: HashSet<String>,
309 pub globals_param_refs: Vec<String>,
310 pub is_cdk: bool,
311 pub fn_if_conditions: Vec<String>,
312 pub find_in_map_names: HashSet<String>,
315 pub params_referenced_in_definitions: HashSet<String>,
318 pub has_dynamic_findinmap_name: bool,
322 pub resolution_sources: HashMap<(String, String), String>,
323 parameter_overrides: HashSet<String>,
324 intrinsic_source_paths: HashMap<String, HashSet<String>>,
330 pseudo_parameter_source_paths: HashMap<String, HashSet<String>>,
331 value_nodes: HashMap<(String, String), NodeRef>,
334 lifecycle_attribute_nodes: HashMap<(String, String), NodeRef>,
337 lifecycle_attribute_status_cache: Mutex<HashMap<(String, String), LifecycleAttributeStatus>>,
338 invalid_resource_conditions: HashSet<String>,
341 invalid_inline_conditions: HashSet<String>,
345 resolve_memo: Mutex<HashMap<(String, String), Option<ResolvedValue>>>,
346 raw_scenario_memo:
347 Mutex<HashMap<(String, String, usize), (Arc<Vec<(ResolvedValue, HashMap<String, bool>)>>, bool)>>,
348 properties_scenario_cache:
349 Mutex<HashMap<(String, Option<BTreeSet<String>>), Arc<Vec<(ResolvedValue, HashMap<String, bool>)>>>>,
350 list_member_scenario_cache: Mutex<HashMap<(String, String), Arc<Vec<ListMemberScenario>>>>,
351 scenario_memo: Mutex<HashMap<(String, String), Arc<Vec<(serde_json::Value, HashMap<String, bool>)>>>>,
352 lifecycle_policy_scenario_cache: Mutex<HashMap<(String, String), Vec<(serde_json::Value, HashMap<String, bool>)>>>,
353 scenario_combinations_used: AtomicU64,
358 budget_tracker: Arc<BudgetTracker>,
361 scenario_expansion_lock: Mutex<()>,
366 scenario_expansion_curtailed: AtomicBool,
370}
371
372#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
375#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
376#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
377#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
378#[serde(rename_all = "camelCase")]
379pub struct PseudoParameterOverrides {
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
382 pub account_id: Option<String>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
385 pub notification_arns: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
388 pub partition: Option<String>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
391 pub region: Option<String>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
394 pub stack_id: Option<String>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
397 pub stack_name: Option<String>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
400 pub url_suffix: Option<String>,
401}
402
403fn is_account_id_shaped(value: &str) -> bool {
404 value.len() == 12 && value.bytes().all(|b| b.is_ascii_digit())
405}
406
407fn is_partition_shaped(value: &str) -> bool {
408 value.starts_with("aws")
409}
410
411impl PseudoParameterOverrides {
412 pub fn region(&self) -> &str {
413 self.region.as_deref().unwrap_or(DEFAULT_REGION)
414 }
415
416 pub fn get(&self, name: &str) -> Option<String> {
417 match name {
418 PSEUDO_ACCOUNT_ID => Some(self.account_id.clone().unwrap_or_else(|| DEFAULT_ACCOUNT_ID.into())),
419 PSEUDO_NOTIFICATION_ARNS => Some(self.notification_arns.clone().unwrap_or_else(|| {
420 let r = self.region();
421 let p = self.partition.as_deref().unwrap_or_else(|| partition_for_region(r));
422 format!("arn:{}:sns:{}:{}:notification", p, r, DEFAULT_ACCOUNT_ID)
423 })),
424 PSEUDO_PARTITION => {
425 Some(self.partition.clone().unwrap_or_else(|| partition_for_region(self.region()).into()))
426 }
427 PSEUDO_REGION => Some(self.region().to_string()),
428 PSEUDO_STACK_ID => Some(self.stack_id.clone().unwrap_or_else(|| {
429 let r = self.region();
430 let p = self.partition.as_deref().unwrap_or_else(|| partition_for_region(r));
431 format!(
432 "arn:{}:cloudformation:{}:{}:stack/{}/51af3dc0-da77-11e4-872e-1234567db123",
433 p, r, DEFAULT_ACCOUNT_ID, DEFAULT_STACK_NAME
434 )
435 })),
436 PSEUDO_STACK_NAME => Some(self.stack_name.clone().unwrap_or_else(|| DEFAULT_STACK_NAME.into())),
437 PSEUDO_URL_SUFFIX => {
438 Some(self.url_suffix.clone().unwrap_or_else(|| url_suffix_for_region(self.region()).into()))
439 }
440 _ => None,
441 }
442 }
443
444 #[must_use]
445 pub fn invalid_overrides(&self) -> Vec<String> {
446 let mut problems = Vec::new();
447 if let Some(account_id) = self.account_id.as_deref()
448 && !is_account_id_shaped(account_id)
449 {
450 problems.push(format!("{PSEUDO_ACCOUNT_ID} override '{account_id}' is not a 12-digit AWS account ID"));
451 }
452 if let Some(partition) = self.partition.as_deref()
453 && !is_partition_shaped(partition)
454 {
455 problems.push(format!(
456 "{PSEUDO_PARTITION} override '{partition}' is not a valid partition \
457 (expected an 'aws'-family value such as aws, aws-cn, or aws-us-gov)"
458 ));
459 }
460 problems
461 }
462
463 pub fn fixed_value(&self, name: &str) -> Option<String> {
476 match name {
477 PSEUDO_ACCOUNT_ID => self.account_id.clone(),
478 PSEUDO_NOTIFICATION_ARNS => self.notification_arns.clone(),
479 PSEUDO_PARTITION => self.partition.clone(),
480 PSEUDO_REGION => self.region.clone(),
481 PSEUDO_STACK_ID => self.stack_id.clone(),
482 PSEUDO_STACK_NAME => self.stack_name.clone(),
483 PSEUDO_URL_SUFFIX => self.url_suffix.clone(),
484 _ => None,
485 }
486 }
487}
488
489#[derive(Default)]
490pub struct ParseConfig {
491 pub parameters: HashMap<String, String>,
492 pub pseudo_parameters: PseudoParameterOverrides,
493}
494
495#[must_use]
496pub struct ParseResult {
497 pub model: SemanticModel,
498 pub model_build_ms: f64,
500}
501
502impl SemanticModel {
503 pub fn from_bytes(bytes: &[u8]) -> Result<Self, ParseError> {
504 Self::parse(bytes, Default::default()).map(|r| r.model)
505 }
506
507 pub fn parse(bytes: &[u8], config: ParseConfig) -> Result<ParseResult, ParseError> {
508 if bytes.len() > MAX_TEMPLATE_SIZE_BYTES {
509 return Err(ParseError {
510 message: format!("Template exceeds maximum size of {}MB", MAX_TEMPLATE_SIZE_BYTES / (1024 * 1024)),
511 line: None,
512 column: None,
513 });
514 }
515
516 let total_start = web_time::Instant::now();
517
518 info!("Phase 1: Parsing IR ({} bytes)", bytes.len());
519 let mut ir = crate::parser::parse(bytes)?;
520 let foreach_diagnostics = crate::transform_expansion::expand_language_extensions(&mut ir);
521 let condition_shape_diagnostics =
522 crate::parser::condition_shape::validate_condition_bodies(&ir.arena, ir.conditions, &ir.span_index);
523 let (parameters, parameter_diagnostics) = extract_parameters(&ir);
524 let params_referenced_in_definitions = collect_parameter_definition_refs(&ir, ¶meters);
529 let (mappings, mapping_diagnostics) = extract_mappings(&ir);
530
531 let budget_tracker = Arc::new(BudgetTracker::new());
534
535 let mut conditions = ConditionModel::from_ir_with_tracker(
536 &ir,
537 ¶meters,
538 &config.pseudo_parameters,
539 &mappings,
540 &config.parameters,
541 Arc::clone(&budget_tracker),
542 );
543
544 let resource_ids: Vec<String> = if ir.resources != NULL_REF {
545 ir.arena
546 .as_map(ir.resources)
547 .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
548 .unwrap_or_default()
549 } else {
550 vec![]
551 };
552 let output_count =
553 if ir.outputs != NULL_REF { ir.arena.as_map(ir.outputs).map(|e| e.len()).unwrap_or(0) } else { 0 };
554 info!("Phase 2: Resolving {} resources, {} outputs", resource_ids.len(), output_count);
555
556 let mut resolver = Resolver::new(
557 &ir.arena,
558 ¶meters,
559 &mappings,
560 resource_ids.iter().cloned().collect(),
561 &config.parameters,
562 &config.pseudo_parameters,
563 );
564 let mut resources = HashMap::new();
565 let mut lifecycle_attribute_nodes = HashMap::new();
566 let mut invalid_resource_conditions = HashSet::new();
567 if ir.resources != NULL_REF
568 && let Some(entries) = ir.arena.as_map(ir.resources)
569 {
570 for (rname, rnode) in entries {
573 if let Some(props) = ir.arena.as_map(*rnode) {
574 for (key, val) in props {
575 if key == KEY_PROPERTIES
576 && let Some(prop_entries) = ir.arena.as_map(*val)
577 && let Some((_, subs_ref)) =
578 prop_entries.iter().find(|(k, _)| k == "DefinitionSubstitutions")
579 && let Some(subs) = ir.arena.as_map(*subs_ref)
580 {
581 resolver
582 .def_subs_resources
583 .entry(rname.clone())
584 .or_default()
585 .extend(subs.iter().map(|(k, _)| k.clone()));
586 }
587 }
588 }
589 }
590 for (name, node_ref) in entries.iter().cloned() {
591 if let Some(resource_entries) = ir.arena.as_map(node_ref) {
592 for attribute in
593 [KEY_CREATION_POLICY, KEY_UPDATE_POLICY, KEY_DELETION_POLICY, KEY_UPDATE_REPLACE_POLICY]
594 {
595 if let Some((_, value_ref)) = resource_entries.iter().find(|(key, _)| key == attribute) {
596 lifecycle_attribute_nodes.insert((name.clone(), attribute.to_string()), *value_ref);
597 }
598 }
599 }
600 if invalid_resource_condition_ref(&ir.arena, node_ref).is_some() {
601 invalid_resource_conditions.insert(name.clone());
602 }
603 if name != FN_TRANSFORM && !name.starts_with(FN_FOR_EACH_KEY_PREFIX) {
607 let has_sam_transform = ir.transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
608 let shape_defects =
609 validate_resource_shape(&ir.arena, &name, node_ref, has_sam_transform, &ir.span_index);
610 resolver.diagnostics.extend(shape_defects);
611 }
612 resolver.set_current_resource(&name);
613 let resolved = resolve_resource(&ir.arena, &name, node_ref, &mut resolver);
614 resources.insert(name.clone(), resolved);
615 }
616 }
617 for (name, res) in &resources {
618 debug!(
619 "Resolved '{}' ({}): {} properties, {} edges, condition={:?}",
620 name,
621 res.resource_type,
622 res.properties.len(),
623 resolver.edges.iter().filter(|e| e.source_resource == *name).count(),
624 res.condition
625 );
626 }
627
628 let mut outputs = HashMap::new();
629 if ir.outputs != NULL_REF
630 && let Some(entries) = ir.arena.as_map(ir.outputs)
631 {
632 for (name, node_ref) in entries.iter().cloned() {
633 resolver.set_current_resource(&format!("{}{}", OUTPUT_PSEUDO_RESOURCE_PREFIX, name));
634 resolver.set_current_path(&format!("Outputs/{}/Value", name));
635 let resolved = resolve_output(&ir.arena, node_ref, &mut resolver);
636 outputs.insert(name.clone(), resolved);
637 }
638 }
639
640 if ir.rules != NULL_REF
645 && let Some(rule_entries) = ir.arena.as_map(ir.rules)
646 {
647 for (rule_name, rule_node) in rule_entries.iter().cloned() {
648 resolver.set_current_resource(&format!("{}{}", RULE_PSEUDO_RESOURCE_PREFIX, rule_name));
649 let cond_ref = ir.arena.map_get(rule_node, KEY_RULE_CONDITION);
650 if let Some(cond_ref) = cond_ref {
651 resolver.set_current_path(&format!("Rules/{}/{}", rule_name, KEY_RULE_CONDITION));
652 resolver.resolve_node(cond_ref);
653 }
654 let assertions_ref = ir.arena.map_get(rule_node, KEY_ASSERTIONS);
655 if let Some(assertions_ref) = assertions_ref
656 && let Some(assertion_items) = ir.arena.as_list(assertions_ref)
657 {
658 for (idx, item_ref) in assertion_items.to_vec().iter().enumerate() {
659 if let Some(assert_ref) = ir.arena.map_get(*item_ref, KEY_ASSERT) {
660 resolver.set_current_path(&format!(
661 "Rules/{}/{}/{}/{}",
662 rule_name, KEY_ASSERTIONS, idx, KEY_ASSERT
663 ));
664 resolver.resolve_node(assert_ref);
665 }
666 }
667 }
668 }
669 }
670
671 info!("Phase 3: Building reference graph from {} resolver edges", resolver.edges.len());
672
673 let invalid_inline_conditions = resolver.inline_conditions.iter().map(|(name, _)| name.clone()).collect();
677 conditions.register_inline_batch(resolver.inline_conditions.drain(..));
678
679 let mut find_in_map_names: std::collections::HashSet<String> = std::collections::HashSet::new();
685 let mut has_dynamic_findinmap_name = false;
686 for idx in 0..ir.arena.len() {
687 if let Node::Intrinsic(IntrinsicFn::FindInMap(map_name_ref, _, _, _)) = ir.arena.node(idx as NodeRef) {
688 match ir.arena.as_str(*map_name_ref) {
689 Some(name) => {
690 find_in_map_names.insert(name.to_string());
691 }
692 None => has_dynamic_findinmap_name = true,
693 }
694 }
695 }
696
697 let resolution_sources = resolver.resolution_sources();
698 let value_nodes = resolver.value_nodes();
699 let resolver_depth_exceeded = resolver.depth_exceeded;
700 let resolver_enum_expansion_exceeded = resolver.enum_expansion_exceeded;
701 let mut all_edges = resolver.edges;
702 for (id, res) in &resources {
703 for dep in &res.depends_on {
704 all_edges.push(ResolverEdge {
705 source_resource: id.clone(),
706 source_path: KEY_DEPENDS_ON.to_string(),
707 target: dep.clone(),
708 kind: RefKind::DependsOn,
709 span: UNKNOWN_SPAN,
710 condition_context: None,
711 });
712 }
713 }
714 let graph = ReferenceGraph::build(all_edges, &resource_ids);
715
716 let intrinsic_source_paths = build_intrinsic_source_paths(&resolution_sources, &graph);
717 let pseudo_parameter_source_paths = build_pseudo_parameter_source_paths(&ir.arena, &value_nodes);
718 let conditional_branch_first_descendant_spans = build_conditional_branch_first_descendant_spans(&ir.span_index);
719
720 let mut resources_by_type: HashMap<String, Vec<String>> = HashMap::new();
721 for (id, res) in &resources {
722 resources_by_type.entry(res.resource_type.clone()).or_default().push(id.clone());
723 }
724 for ids in resources_by_type.values_mut() {
730 ids.sort_by_key(|id| {
731 let key = format!("Resources/{}", id);
732 ir.span_index.get(key.as_str()).map(|s| (s.start_line, s.start_column)).unwrap_or((u32::MAX, u32::MAX))
733 });
734 }
735
736 let mut diagnostics = ir.diagnostics;
737 diagnostics.extend(foreach_diagnostics);
738 diagnostics.extend(condition_shape_diagnostics);
739 diagnostics.extend(mapping_diagnostics);
740 diagnostics.extend(parameter_diagnostics);
741
742 diagnostics.extend(crate::nesting::validate_intrinsic_nesting(&ir.arena));
743 diagnostics.extend(crate::intrinsic_arg_shapes::validate_intrinsic_arg_shapes(&ir.arena, &ir.transforms));
744 diagnostics.extend(crate::lang_ext_shapes::validate_lang_ext_parameter_shapes(&ir.arena, &ir.transforms));
745 diagnostics.extend(crate::language_extensions::validate_language_extensions(&ir.arena, &ir.transforms));
746 let lifecycle_resource_conditions: HashMap<String, String> = resources
747 .iter()
748 .filter_map(|(resource_id, resource)| {
749 resource.condition.as_ref().map(|condition| (resource_id.clone(), condition.clone()))
750 })
751 .collect();
752 diagnostics.extend(crate::language_extensions::validate_lifecycle_intrinsics(
753 &ir.arena,
754 &conditions,
755 &lifecycle_attribute_nodes,
756 &lifecycle_resource_conditions,
757 ));
758 diagnostics.extend(crate::dynamic_ref::validate_dynamic_references(&ir.arena, ir.resources));
759
760 let mut fn_if_conditions: Vec<String> = Vec::new();
761 for idx in 0..ir.arena.len() {
762 match ir.arena.node(idx as NodeRef) {
763 Node::Intrinsic(IntrinsicFn::If(cond_name, _, _)) => {
764 fn_if_conditions.push(cond_name.clone());
765 let in_conditions_body =
770 ir.arena.get(idx as NodeRef).path.split('/').next() == Some(SECTION_CONDITIONS);
771 if !in_conditions_body && !conditions.conditions.contains_key(cond_name) {
772 let condition_path = format!("{}/{}/0", ir.arena.get(idx as NodeRef).path, FN_IF);
779 diagnostics.push(crate::make_parse_defect_at(
780 "E1028",
781 format!("Fn::If condition '{}' does not exist in Conditions section", cond_name),
782 ir.span_index
783 .get(&condition_path)
784 .copied()
785 .unwrap_or_else(|| ir.arena.span(idx as NodeRef)),
786 &condition_path,
787 ));
788 }
789 }
790 Node::Map(entries) if entries.len() == 1 && entries[0].0 == FN_IF => {
797 if let Some(first) = ir.arena.as_list(entries[0].1).and_then(|items| items.first())
798 && let Some(cond_name) = ir.arena.as_str(*first)
799 {
800 fn_if_conditions.push(cond_name.to_string());
801 let in_conditions_body =
807 ir.arena.get(idx as NodeRef).path.split('/').next() == Some(SECTION_CONDITIONS);
808 if !in_conditions_body && !conditions.conditions.contains_key(cond_name) {
809 diagnostics.push(crate::make_parse_defect_at(
810 "E1028",
811 format!("Fn::If condition '{}' does not exist in Conditions section", cond_name),
812 ir.arena.span(*first),
813 &ir.arena.get(*first).path,
814 ));
815 }
816 }
817 }
818 _ => {}
819 }
820 }
821 fn_if_conditions.sort();
822 fn_if_conditions.dedup();
823 let mut output_empty_joins: Vec<String> = Vec::new();
824 for (key, joins) in &resolver.empty_joins {
825 if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
826 output_empty_joins.extend(joins.iter().cloned());
827 }
828 }
829
830 {
837 let mut output_raw_pseudo: Vec<(String, String)> = Vec::new();
838 for (key, entries) in &resolver.raw_pseudo_params {
839 if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
840 for (path, value) in entries {
841 output_raw_pseudo.push((path.clone(), value.clone()));
842 }
843 }
844 }
845 output_raw_pseudo.sort();
846 for (path, value) in output_raw_pseudo {
847 diagnostics.push(crate::make_parse_defect(
848 "W1054",
849 format!(
850 "Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead",
851 value, value
852 ),
853 ir.span_index.get(&path).copied().unwrap_or(UNKNOWN_SPAN),
854 ));
855 }
856 }
857
858 {
863 let mut param_names: Vec<&String> = parameters.keys().collect();
864 param_names.sort();
865 for pname in param_names {
866 if let Some(default) = ¶meters[pname].default
867 && PSEUDO_PARAMETERS.contains(&default.as_str())
868 {
869 diagnostics.push(crate::make_parse_defect(
870 "W1054",
871 format!("Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead", default, default),
872 ir.span_index.get(&format!("Parameters/{}/Default", pname)).copied().unwrap_or(UNKNOWN_SPAN),
873 ));
874 }
875 }
876 }
877
878 for idx in 0..ir.arena.len() {
884 let spanned = ir.arena.get(idx as NodeRef);
885 let Node::String(s) = &spanned.node else {
886 continue;
887 };
888 let section = spanned.path.split('/').next().unwrap_or("");
889 let is_ref_target = spanned.path.rsplit('/').next() == Some(FN_REF);
894 if (section == SECTION_MAPPINGS || section == SECTION_CONDITIONS)
895 && !is_ref_target
896 && PSEUDO_PARAMETERS.contains(&s.as_str())
897 {
898 diagnostics.push(crate::make_parse_defect(
899 "W1054",
900 format!(
901 "Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead",
902 s, s
903 ),
904 spanned.span,
905 ));
906 }
907 }
908
909 {
914 let mut output_unsubstituted: Vec<(String, String)> = Vec::new();
915 for (key, entries) in &resolver.unsubstituted_variables {
916 if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
917 for (path, variable) in entries {
918 output_unsubstituted.push((path.clone(), variable.clone()));
919 }
920 }
921 }
922 output_unsubstituted.sort();
923 for (path, variable) in output_unsubstituted {
924 diagnostics.push(crate::make_parse_defect(
925 "E1029",
926 format!("Found an embedded parameter '{}' outside of an 'Fn::Sub' at {}", variable, path),
927 ir.span_index.get(&path).copied().unwrap_or(UNKNOWN_SPAN),
928 ));
929 }
930 }
931
932 diagnostics.extend(resolver.diagnostics);
933 let is_sam = ir.transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
939 for d in graph.cycle_diagnostics(&ir.span_index) {
940 if is_sam && sam::cycle_involves_sam_diagnostic(&d, &resources) {
941 continue;
942 }
943 diagnostics.push(d);
944 }
945 for (cond_name, always_val) in conditions.tautological_equals() {
946 let result_str = if always_val { "True" } else { "False" };
947 diagnostics.push(crate::make_parse_defect_at(
948 "W8003",
949 format!("Fn::Equals in condition '{}' will always return {}", cond_name, result_str),
950 ir.span_index.get(&format!("Conditions/{}", cond_name)).copied().unwrap_or(UNKNOWN_SPAN),
951 &format!("Conditions/{}", cond_name),
952 ));
953 }
954 {
961 let mut resource_ids_sorted: Vec<&String> = resources.keys().collect();
962 resource_ids_sorted.sort();
963 for rid in resource_ids_sorted {
964 if let Some(cond) = &resources[rid].condition
965 && !conditions.conditions.contains_key(cond)
966 {
967 diagnostics.push(crate::make_parse_defect_for_resource(
968 "E8002",
969 format!("Condition '{}' referenced by resource '{}' is not defined", cond, rid),
970 ir.span_index.get(&format!("Resources/{}", rid)).copied().unwrap_or(UNKNOWN_SPAN),
971 rid,
972 ));
973 }
974 }
975 let mut output_names_sorted: Vec<&String> = outputs.keys().collect();
976 output_names_sorted.sort();
977 for oname in output_names_sorted {
978 if let Some(cond) = &outputs[oname].condition
979 && !conditions.conditions.contains_key(cond)
980 {
981 diagnostics.push(crate::make_parse_defect_for_resource(
982 "E6005",
983 format!("Condition '{}' referenced by output '{}' is not defined", cond, oname),
984 ir.span_index.get(&format!("Outputs/{}", oname)).copied().unwrap_or(UNKNOWN_SPAN),
985 oname,
986 ));
987 }
988 }
989 }
990 for (owner, undefined_ref) in conditions.undefined_condition_refs() {
991 if owner.starts_with("__") || undefined_ref.starts_with("__") {
994 continue;
995 }
996 diagnostics.push(crate::make_parse_defect(
997 "E8007",
998 format!("Condition '{}' references undefined condition '{}'", owner, undefined_ref),
999 ir.span_index.get(&format!("Conditions/{}", owner)).copied().unwrap_or(UNKNOWN_SPAN),
1000 ));
1001 }
1002 for cycle in conditions.detect_cycles() {
1003 let cycle_desc = cycle.join(" -> ");
1004 let first = &cycle[0];
1005 diagnostics.push(crate::make_parse_defect(
1006 "E9106",
1007 format!("Circular dependency in conditions: {}", cycle_desc),
1008 ir.span_index.get(&format!("Conditions/{}", first)).copied().unwrap_or(UNKNOWN_SPAN),
1009 ));
1010 }
1011 for (first, other) in conditions.detect_equivalent_conditions() {
1012 diagnostics.push(crate::make_parse_defect(
1013 "W9053",
1014 format!("Condition '{}' is equivalent to condition '{}' - consider consolidating", other, first),
1015 ir.span_index.get(&format!("Conditions/{}", other)).copied().unwrap_or(UNKNOWN_SPAN),
1016 ));
1017 }
1018 let model_build_ms = total_start.elapsed().as_secs_f64() * 1000.0;
1024
1025 if !diagnostics.is_empty() {
1026 warn!("{} parse-time diagnostics", diagnostics.len());
1027 }
1028 let total_props: usize = resources.values().map(|r| r.properties.len()).sum();
1029 info!(
1030 "Semantic model: {} resources ({} types, {} total properties), {} outputs, {} edges, {} conditions, {} diagnostics",
1031 resources.len(),
1032 resources_by_type.len(),
1033 total_props,
1034 outputs.len(),
1035 graph.edges.len(),
1036 conditions.conditions.len(),
1037 diagnostics.len()
1038 );
1039
1040 let template_metadata =
1041 if ir.template_metadata != NULL_REF { Some(node_to_json(&ir.arena, ir.template_metadata)) } else { None };
1042 let rules = if ir.rules != NULL_REF { Some(node_to_json(&ir.arena, ir.rules)) } else { None };
1043 let parsed_rules = parse_rules(&rules, &ir.arena, ir.rules);
1044 let rule_implications: Vec<(String, crate::ir::NodeRef, Vec<crate::ir::NodeRef>)> = parsed_rules
1045 .iter()
1046 .map(|r| {
1047 let assertion_nodes: Vec<crate::ir::NodeRef> = r.assertions.iter().map(|a| a.assert_node).collect();
1048 (r.name.clone(), r.condition_node, assertion_nodes)
1049 })
1050 .collect();
1051 if !rule_implications.is_empty() {
1052 conditions.register_rule_implications(&ir.arena, &rule_implications);
1053 }
1054 let rule_diagnostics = crate::rules::validate_rules(&rules, &ir.arena, ir.rules);
1055 diagnostics.extend(rule_diagnostics);
1056 let sam_globals = sam::extract_sam_globals(&ir.arena, ir.globals);
1057 if !sam_globals.is_empty() {
1058 sam::apply_sam_globals(&mut resources, &sam_globals, &mut ir.span_index);
1059 }
1060 let sam_implicit_resources =
1061 if is_sam { sam::collect_sam_implicit_resources(&resources) } else { HashSet::new() };
1062 let globals_param_refs =
1063 if is_sam { sam::collect_globals_param_refs(&ir.arena, ir.globals) } else { Vec::new() };
1064 if is_sam {
1065 let parameter_names: HashSet<String> = parameters.keys().cloned().collect();
1066 diagnostics.extend(sam::collect_transform_errors(
1067 &ir.arena,
1068 ir.resources,
1069 ir.globals,
1070 &resources,
1071 ¶meter_names,
1072 &ir.span_index,
1073 ));
1074 }
1075 let is_cdk = resources_by_type.contains_key(CDK_METADATA_TYPE);
1076
1077 if resolver_depth_exceeded {
1078 budget_tracker.record(BudgetKind::ResolverDepth);
1079 }
1080 if resolver_enum_expansion_exceeded {
1081 budget_tracker.record(BudgetKind::EnumExpansion);
1082 }
1083 {
1090 let has_module_or_foreach = resources.values().any(|r| r.resource_type.ends_with("::MODULE"))
1091 || resources.keys().any(|k| k.contains("Fn::ForEach"));
1092 if !has_module_or_foreach {
1093 let mut output_invalid_refs: Vec<(String, String, String)> = Vec::new();
1094 for (key, entries) in &resolver.invalid_refs {
1095 if let Some(output_name) = key.strip_prefix(OUTPUT_PSEUDO_RESOURCE_PREFIX) {
1096 for (path, target) in entries {
1097 if sam_implicit_resources.contains(target) {
1098 continue;
1099 }
1100 output_invalid_refs.push((output_name.to_string(), path.clone(), target.clone()));
1101 }
1102 }
1103 }
1104 output_invalid_refs.sort();
1105 for (output_name, path, target) in &output_invalid_refs {
1106 let mut defect =
1107 ParseDefect::new(
1108 "F6101",
1109 format!(
1110 "Ref '{}' does not reference a valid resource, parameter, or pseudo-parameter",
1111 target,
1112 ),
1113 )
1114 .location(ir.span_index.get(path).copied().unwrap_or(UNKNOWN_SPAN))
1115 .phase(DefectPhase::Parse);
1116 defect = defect.property_path(format!("Outputs.{}.Value.Ref", output_name));
1117 diagnostics.push(defect);
1118 }
1119 }
1120 }
1121
1122 Ok(ParseResult {
1123 model: SemanticModel {
1124 arena: ir.arena,
1125 root: ir.root,
1126 authored_template: OnceLock::new(),
1127 span_index: ir.span_index,
1128 conditional_branch_first_descendant_spans,
1129 format_version: ir.format_version,
1130 description: ir.description,
1131 transforms: ir.transforms,
1132 raw_top_level_keys: ir.raw_top_level_keys,
1133 template_metadata,
1134 rules,
1135 parsed_rules,
1136 parameters,
1137 mappings,
1138 conditions,
1139 resources,
1140 outputs,
1141 graph,
1142 resources_by_type,
1143 diagnostics,
1144 output_empty_joins,
1145 sam_globals,
1146 sam_implicit_resources,
1147 globals_param_refs,
1148 is_cdk,
1149 fn_if_conditions,
1150 find_in_map_names,
1151 params_referenced_in_definitions,
1152 has_dynamic_findinmap_name,
1153 resolution_sources,
1154 parameter_overrides: config.parameters.keys().cloned().collect(),
1155 intrinsic_source_paths,
1156 pseudo_parameter_source_paths,
1157 value_nodes,
1158 lifecycle_attribute_nodes,
1159 lifecycle_attribute_status_cache: Mutex::new(HashMap::new()),
1160 invalid_resource_conditions,
1161 invalid_inline_conditions,
1162 resolve_memo: Mutex::new(HashMap::new()),
1163 raw_scenario_memo: Mutex::new(HashMap::new()),
1164 properties_scenario_cache: Mutex::new(HashMap::new()),
1165 list_member_scenario_cache: Mutex::new(HashMap::new()),
1166 scenario_memo: Mutex::new(HashMap::new()),
1167 lifecycle_policy_scenario_cache: Mutex::new(HashMap::new()),
1168 scenario_combinations_used: AtomicU64::new(0),
1169 budget_tracker,
1170 scenario_expansion_lock: Mutex::new(()),
1171 scenario_expansion_curtailed: AtomicBool::new(false),
1172 },
1173 model_build_ms,
1174 })
1175 }
1176
1177 pub fn resource(&self, id: &str) -> Option<&ResolvedResource> {
1178 self.resources.get(id)
1179 }
1180
1181 pub fn authored_template_json(&self) -> &serde_json::Value {
1186 self.authored_template.get_or_init(|| render_authored_json(&self.arena, self.root))
1187 }
1188
1189 #[must_use]
1195 pub fn lifecycle_attribute_status(&self, resource_id: &str, attribute: &str) -> LifecycleAttributeStatus {
1196 if !matches!(
1197 attribute,
1198 KEY_CREATION_POLICY | KEY_UPDATE_POLICY | KEY_DELETION_POLICY | KEY_UPDATE_REPLACE_POLICY
1199 ) {
1200 return LifecycleAttributeStatus::default();
1201 }
1202 let cache_key = (resource_id.to_string(), attribute.to_string());
1203 let mut cache = self.lifecycle_attribute_status_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1204 if let Some(status) = cache.get(&cache_key) {
1205 return status.clone();
1206 }
1207
1208 let mut status = LifecycleAttributeStatus::default();
1209 if let Some(node) = self.lifecycle_attribute_nodes.get(&cache_key) {
1210 let mut assumptions = self
1211 .resources
1212 .get(resource_id)
1213 .and_then(|resource| resource.condition.as_ref())
1214 .map(|condition| vec![(condition.clone(), true)])
1215 .unwrap_or_default();
1216 collect_lifecycle_attribute_status(
1217 &self.arena,
1218 *node,
1219 &self.conditions,
1220 attribute == KEY_UPDATE_POLICY,
1221 &mut assumptions,
1222 &mut status,
1223 );
1224 }
1225 cache.insert(cache_key, status.clone());
1226 status
1227 }
1228
1229 #[must_use]
1233 pub fn lifecycle_policy_scenarios(
1234 &self,
1235 resource_id: &str,
1236 attribute: &str,
1237 ) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
1238 let cache_key = (resource_id.to_string(), attribute.to_string());
1239 let mut cache = self.lifecycle_policy_scenario_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1240 if let Some(scenarios) = cache.get(&cache_key) {
1241 return scenarios.clone();
1242 }
1243
1244 let scenarios = if !self.resource_condition_is_valid(resource_id) {
1245 Vec::new()
1246 } else {
1247 let resolved = match attribute {
1248 KEY_DELETION_POLICY => self.resources.get(resource_id).and_then(|r| r.deletion_policy.as_ref()),
1249 KEY_UPDATE_REPLACE_POLICY => {
1250 self.resources.get(resource_id).and_then(|r| r.update_replace_policy.as_ref())
1251 }
1252 _ => None,
1253 };
1254 match resolved {
1255 None => Vec::new(),
1256 Some(value) => {
1257 let initial_assumptions: HashMap<String, bool> = self
1258 .resources
1259 .get(resource_id)
1260 .and_then(|resource| resource.condition.as_ref())
1261 .map(|condition| [(condition.clone(), true)].into_iter().collect())
1262 .unwrap_or_default();
1263 let assumptions: Vec<_> =
1264 initial_assumptions.iter().map(|(name, value)| (name.clone(), *value)).collect();
1265 if !assumptions.is_empty() && !self.conditions.is_satisfiable(&assumptions) {
1266 Vec::new()
1267 } else {
1268 self.collect_scenarios_with_budget_from(
1269 value,
1270 &initial_assumptions,
1271 MAX_SCENARIO_COMBINATIONS,
1272 MAX_TOTAL_SCENARIO_COMBINATIONS,
1273 )
1274 .0
1275 .into_iter()
1276 .filter_map(|(value, conditions)| {
1277 let assumptions: Vec<_> =
1278 conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
1279 if !assumptions.is_empty() && !self.conditions.is_satisfiable(&assumptions) {
1280 return None;
1281 }
1282 let value = match value {
1283 ResolvedValue::Reference { .. }
1284 | ResolvedValue::Dynamic { .. }
1285 | ResolvedValue::TypedDynamic { .. } => return None,
1286 other => crate::serialization::resolved_value_to_json(&other),
1287 };
1288 Some((value, conditions))
1289 })
1290 .collect()
1291 }
1292 }
1293 }
1294 };
1295 cache.insert(cache_key, scenarios.clone());
1296 scenarios
1297 }
1298
1299 pub fn resources_of_type(&self, type_name: &str) -> &[String] {
1300 self.resources_by_type.get(type_name).map(|v| v.as_slice()).unwrap_or(&[])
1301 }
1302
1303 #[must_use]
1304 pub fn resource_condition_is_valid(&self, resource_id: &str) -> bool {
1305 !self.invalid_resource_conditions.contains(resource_id)
1306 }
1307
1308 #[must_use]
1312 pub fn primary_identifier_conflicts(
1313 &self,
1314 resource_type: &str,
1315 identifier_properties: &[String],
1316 ) -> Vec<(Vec<String>, BTreeSet<String>)> {
1317 let mut per_resource = Vec::new();
1318 for resource_id in self.resources_of_type(resource_type) {
1319 let scenarios = self.primary_identifier_scenarios(resource_id, identifier_properties);
1320 if !scenarios.is_empty() {
1321 per_resource.push((resource_id, scenarios));
1322 }
1323 }
1324
1325 let first_identity = per_resource
1331 .first()
1332 .and_then(|(_, scenarios)| scenarios.first())
1333 .map(|scenario| scenario.identity_tuple.as_slice());
1334 let has_one_identity = first_identity.is_some_and(|identity| {
1335 per_resource
1336 .iter()
1337 .flat_map(|(_, scenarios)| scenarios)
1338 .all(|scenario| scenario.identity_tuple.as_slice() == identity)
1339 });
1340 if has_one_identity {
1341 let mut conflicts: BTreeMap<Vec<String>, (Vec<String>, BTreeSet<String>)> = BTreeMap::new();
1342 for left_index in 0..per_resource.len() {
1343 for right_index in (left_index + 1)..per_resource.len() {
1344 let (left_resource, left_scenarios) = &per_resource[left_index];
1345 let (right_resource, right_scenarios) = &per_resource[right_index];
1346 for left in left_scenarios {
1347 for right in right_scenarios {
1348 let mut assumptions = left.assumptions.clone();
1349 assumptions.extend(right.assumptions.iter().cloned());
1350 if assumptions.is_empty() || self.conditions.is_satisfiable(&assumptions) {
1351 let (_, resources) = conflicts
1352 .entry(left.identity_tuple.clone())
1353 .or_insert_with(|| (left.display_tuple.clone(), BTreeSet::new()));
1354 resources.insert((*left_resource).clone());
1355 resources.insert((*right_resource).clone());
1356 }
1357 }
1358 }
1359 }
1360 }
1361 return conflicts.into_values().collect();
1362 }
1363
1364 let mut candidates_by_identity: HashMap<&[String], Vec<(usize, usize)>> = HashMap::new();
1370 for (resource_index, (_, scenarios)) in per_resource.iter().enumerate() {
1371 for (scenario_index, scenario) in scenarios.iter().enumerate() {
1372 candidates_by_identity
1373 .entry(scenario.identity_tuple.as_slice())
1374 .or_default()
1375 .push((resource_index, scenario_index));
1376 }
1377 }
1378 let mut cursors: Vec<IdentityGroupPairCursor> = candidates_by_identity
1379 .into_values()
1380 .filter_map(|candidates| IdentityGroupPairCursor::new(&candidates))
1381 .collect();
1382
1383 let mut conflicts: BTreeMap<Vec<String>, (Vec<String>, BTreeSet<String>)> = BTreeMap::new();
1384 {
1385 let mut record_conflict = |comparison: PrimaryIdentifierComparisonOrder| {
1386 let (left_resource_index, right_resource_index, left_scenario_index, right_scenario_index) = comparison;
1387 let (left_resource, left_scenarios) = &per_resource[left_resource_index];
1388 let (right_resource, right_scenarios) = &per_resource[right_resource_index];
1389 let left = &left_scenarios[left_scenario_index];
1390 let right = &right_scenarios[right_scenario_index];
1391 let mut assumptions = left.assumptions.clone();
1392 assumptions.extend(right.assumptions.iter().cloned());
1393 if assumptions.is_empty() || self.conditions.is_satisfiable(&assumptions) {
1394 let (_, resources) = conflicts
1395 .entry(left.identity_tuple.clone())
1396 .or_insert_with(|| (left.display_tuple.clone(), BTreeSet::new()));
1397 resources.insert((*left_resource).clone());
1398 resources.insert((*right_resource).clone());
1399 }
1400 };
1401
1402 if cursors.len() <= 1 {
1409 if let Some(cursor) = cursors.pop() {
1410 for comparison in cursor {
1411 record_conflict(comparison);
1412 }
1413 }
1414 } else {
1415 let mut frontier = BinaryHeap::with_capacity(cursors.len());
1416 for (cursor_index, cursor) in cursors.iter_mut().enumerate() {
1417 if let Some(comparison) = cursor.next() {
1418 frontier.push(Reverse((comparison, cursor_index)));
1419 }
1420 }
1421 while let Some(Reverse((comparison, cursor_index))) = frontier.pop() {
1422 record_conflict(comparison);
1423 if let Some(next_comparison) = cursors[cursor_index].next() {
1424 frontier.push(Reverse((next_comparison, cursor_index)));
1425 }
1426 }
1427 }
1428 }
1429 conflicts.into_values().collect()
1430 }
1431
1432 fn primary_identifier_scenarios(
1433 &self,
1434 resource_id: &str,
1435 identifier_properties: &[String],
1436 ) -> Vec<PrimaryIdentifierScenario> {
1437 if !self.resource_condition_is_valid(resource_id) {
1438 return Vec::new();
1439 }
1440 let base_assumptions = self
1441 .resources
1442 .get(resource_id)
1443 .and_then(|resource| resource.condition.as_deref())
1444 .map(|condition| vec![(condition.to_string(), true)])
1445 .unwrap_or_default();
1446 let mut scenarios = vec![PrimaryIdentifierScenario {
1447 identity_tuple: Vec::with_capacity(identifier_properties.len()),
1448 display_tuple: Vec::with_capacity(identifier_properties.len()),
1449 assumptions: base_assumptions,
1450 }];
1451
1452 for property in identifier_properties {
1453 let path = format!("Properties.{property}");
1454 let property_scenarios = self.resolve_scenarios_json(resource_id, &path);
1455 let expression_identity =
1456 self.value_identity(resource_id, &path).filter(|identity| identity.starts_with("expr:"));
1457 let effective_scenarios: Vec<(String, String, HashMap<String, bool>)> = if property_scenarios.is_empty()
1458 || (expression_identity.is_some()
1459 && property_scenarios.iter().all(|(_, conditions)| conditions.is_empty()))
1460 {
1461 expression_identity
1462 .map(|identity| {
1463 let display = identity.strip_prefix("expr:").unwrap_or(&identity).to_string();
1464 vec![(identity, display, HashMap::new())]
1465 })
1466 .unwrap_or_default()
1467 } else {
1468 property_scenarios
1469 .into_iter()
1470 .filter_map(|(value, conditions)| {
1471 if value.is_null() {
1472 return None;
1473 }
1474 let display = value.as_str().map(ToOwned::to_owned).unwrap_or_else(|| value.to_string());
1475 let identity = format!("value:{}", crate::value_identity::concrete_value_fingerprint(&value));
1476 Some((identity, display, conditions))
1477 })
1478 .collect()
1479 };
1480 let mut next = Vec::new();
1481 for existing in &scenarios {
1482 for (identity, display, conditions) in &effective_scenarios {
1483 let mut assumptions = existing.assumptions.clone();
1484 let mut consistent = true;
1485 for (condition, truth) in conditions {
1486 if let Some((_, prior)) = assumptions.iter().find(|(name, _)| name == condition) {
1487 if prior != truth {
1488 consistent = false;
1489 break;
1490 }
1491 } else {
1492 assumptions.push((condition.clone(), *truth));
1493 }
1494 }
1495 if consistent {
1496 let mut identity_tuple = existing.identity_tuple.clone();
1497 identity_tuple.push(identity.clone());
1498 let mut display_tuple = existing.display_tuple.clone();
1499 display_tuple.push(display.clone());
1500 next.push(PrimaryIdentifierScenario { identity_tuple, display_tuple, assumptions });
1501 }
1502 }
1503 }
1504 scenarios = next;
1505 if scenarios.is_empty() {
1506 break;
1507 }
1508 }
1509
1510 scenarios.retain(|scenario| {
1511 scenario.identity_tuple.len() == identifier_properties.len()
1512 && (scenario.assumptions.is_empty() || self.conditions.is_satisfiable(&scenario.assumptions))
1513 });
1514 scenarios
1515 }
1516
1517 #[must_use]
1518 pub fn condition_is_valid_for_reachability(&self, condition: &str) -> bool {
1519 !self.invalid_inline_conditions.contains(condition)
1520 }
1521
1522 #[must_use]
1523 pub fn resolve(&self, resource_id: &str, path: &str) -> Option<&ResolvedValue> {
1524 let resource = self.resources.get(resource_id)?;
1525 resource.properties.get(path.strip_prefix("Properties.").unwrap_or(path))
1526 }
1527
1528 #[must_use]
1529 pub fn resolve_deep(&self, resource_id: &str, path: &str) -> Option<ResolvedValue> {
1530 let memo_key = (resource_id.to_string(), path.to_string());
1531 if let Some(memoized) = self.resolve_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&memo_key)
1532 {
1533 return memoized.clone();
1534 }
1535 let resource = self.resources.get(resource_id)?;
1536 let prop_path = path.strip_prefix("Properties.").unwrap_or(path);
1537 let mut segments = prop_path.splitn(2, '.');
1538 let top_key = segments.next()?;
1539 let resolved = resource.properties.get(top_key)?;
1540 let result = match segments.next() {
1541 Some(r) if !r.is_empty() => resolved_value_at_path(resolved, r),
1542 _ => Some(resolved.clone()),
1543 };
1544 self.resolve_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(memo_key, result.clone());
1545 result
1546 }
1547
1548 #[must_use]
1554 pub fn resolve_unexpanded(&self, resource_id: &str, path: &str) -> Option<ResolvedValue> {
1555 self.resolve_deep(resource_id, path)
1556 .or_else(|| self.resolve(resource_id, path).cloned())
1557 .or_else(|| self.resolve_via_properties_if(resource_id, path))
1558 }
1559
1560 #[must_use]
1565 pub fn resolve_list_member_scenarios_shared(&self, resource_id: &str, path: &str) -> Arc<Vec<ListMemberScenario>> {
1566 let cache_key = (resource_id.to_string(), path.to_string());
1567 let mut cache = self.list_member_scenario_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1568 if let Some(scenarios) = cache.get(&cache_key) {
1569 return Arc::clone(scenarios);
1570 }
1571
1572 let mut member_scenarios = Vec::new();
1573 if let Some(value) = self.resolve_unexpanded(resource_id, path) {
1574 let mut next_container_scenario = 0;
1575 self.collect_list_member_scenarios(
1576 &value,
1577 &HashMap::new(),
1578 &mut next_container_scenario,
1579 &mut member_scenarios,
1580 );
1581 }
1582 let shared = Arc::new(member_scenarios);
1583 cache.insert(cache_key, Arc::clone(&shared));
1584 shared
1585 }
1586
1587 #[must_use]
1590 pub fn duplicate_object_field_value_scenario(
1591 &self,
1592 resource_id: &str,
1593 path: &str,
1594 field_name: &str,
1595 ) -> Option<HashMap<String, bool>> {
1596 let mut alternatives_by_value: BTreeMap<String, Vec<(usize, usize, HashMap<String, bool>)>> = BTreeMap::new();
1597 for (container, index, value, conditions) in self.list_member_value_scenarios(resource_id, path) {
1598 let field_value = match &value {
1599 ResolvedValue::Map { entries } => {
1600 entries.iter().find(|entry| entry.key == field_name).map(|entry| entry.value.clone())
1601 }
1602 ResolvedValue::Concrete { value } => value
1603 .as_object()
1604 .and_then(|object| object.get(field_name))
1605 .cloned()
1606 .map(|value| ResolvedValue::Concrete { value: value.into() }),
1607 _ => None,
1608 };
1609 let Some(field_value) = field_value else {
1610 continue;
1611 };
1612 let field_path = format!("{path}.{index}.{field_name}");
1613 let uses_authored_identity = contains_dynamic_resolved(&field_value)
1614 || self.scenario_uses_unresolved_parameter(resource_id, &field_path, &conditions)
1615 || self.scenario_uses_pseudo_parameter(resource_id, &field_path, &conditions);
1616 let comparison_key = if uses_authored_identity {
1617 let source_path = self
1618 .scenario_source_path(resource_id, &field_path, &conditions)
1619 .unwrap_or_else(|| field_path.clone());
1620 let Some(identity) = self.resolved_value_identity(resource_id, &source_path, &field_value) else {
1621 continue;
1622 };
1623 identity
1624 } else {
1625 let rendered = resolved_value_to_json(&field_value);
1626 let Some(field_value) = rendered.as_str() else {
1627 continue;
1628 };
1629 format!("literal:{field_value}")
1630 };
1631 alternatives_by_value.entry(comparison_key).or_default().push((container, index, conditions));
1632 }
1633 for alternatives in alternatives_by_value.values() {
1634 for (left_position, (left_container, left_index, left_conditions)) in alternatives.iter().enumerate() {
1635 for (right_container, right_index, right_conditions) in alternatives.iter().skip(left_position + 1) {
1636 if self.conditions.satisfiability_budget_exhausted() {
1637 return None;
1638 }
1639 if left_container == right_container
1640 && left_index != right_index
1641 && let Some(conditions) =
1642 self.merge_reachable_conditions(resource_id, left_conditions, right_conditions)
1643 {
1644 return Some(conditions);
1645 }
1646 }
1647 }
1648 }
1649 None
1650 }
1651
1652 #[must_use]
1655 pub fn list_object_missing_required_field_scenarios(
1656 &self,
1657 resource_id: &str,
1658 path: &str,
1659 required_field: &str,
1660 trigger_field: &str,
1661 ) -> Vec<(usize, HashMap<String, bool>)> {
1662 let mut missing = Vec::new();
1663 for (_, index, value, conditions) in self.list_member_value_scenarios(resource_id, path) {
1664 let value = resolved_value_to_json(&value);
1665 let Some(object) = value.as_object() else {
1666 continue;
1667 };
1668 if object.get(trigger_field).is_some_and(|value| !value.is_null())
1669 && object.get(required_field).is_none_or(serde_json::Value::is_null)
1670 {
1671 missing.push((index, conditions));
1672 }
1673 }
1674 missing.sort_by(|(left_index, left_conditions), (right_index, right_conditions)| {
1675 left_index
1676 .cmp(right_index)
1677 .then_with(|| canonical_conditions(left_conditions).cmp(&canonical_conditions(right_conditions)))
1678 });
1679 missing.dedup_by(|left, right| left == right);
1680 missing
1681 }
1682
1683 fn list_member_value_scenarios(
1684 &self,
1685 resource_id: &str,
1686 path: &str,
1687 ) -> Vec<(usize, usize, ResolvedValue, HashMap<String, bool>)> {
1688 let members = self.resolve_list_member_scenarios_shared(resource_id, path);
1689 let mut alternatives = Vec::new();
1690 for member in members.iter() {
1691 let (scenarios, _) = self.collect_scenarios_with_budget_from(
1692 &member.value,
1693 &member.conditions,
1694 MAX_SCENARIO_COMBINATIONS,
1695 MAX_TOTAL_SCENARIO_COMBINATIONS,
1696 );
1697 alternatives.extend(
1698 scenarios
1699 .into_iter()
1700 .map(|(value, conditions)| (member.container_scenario, member.index, value, conditions)),
1701 );
1702 }
1703 alternatives
1704 }
1705
1706 #[must_use]
1711 pub fn list_member_pair_source_paths(
1712 &self,
1713 resource_id: &str,
1714 path: &str,
1715 base_conditions: &HashMap<String, bool>,
1716 ) -> BTreeSet<String> {
1717 let member_scenarios = self.resolve_list_member_scenarios_shared(resource_id, path);
1718 let container_scenarios: BTreeSet<usize> =
1719 member_scenarios.iter().map(|scenario| scenario.container_scenario).collect();
1720 let mut source_paths = BTreeSet::new();
1721 for container_scenario in container_scenarios {
1722 if self.conditions.satisfiability_budget_exhausted() {
1723 break;
1724 }
1725 let Some(container) =
1726 member_scenarios.iter().find(|scenario| scenario.container_scenario == container_scenario)
1727 else {
1728 continue;
1729 };
1730 let Some(base_and_container) =
1731 self.merge_reachable_conditions(resource_id, base_conditions, &container.container_conditions)
1732 else {
1733 continue;
1734 };
1735 'container_members: for (left_position, left) in member_scenarios.iter().enumerate() {
1736 if left.container_scenario != container_scenario || is_resolved_null(&left.value) {
1737 continue;
1738 }
1739 let Some(base_and_left) =
1740 self.merge_reachable_conditions(resource_id, &base_and_container, &left.conditions)
1741 else {
1742 continue;
1743 };
1744 for right in member_scenarios.iter().skip(left_position + 1) {
1745 if self.conditions.satisfiability_budget_exhausted() {
1746 return source_paths;
1747 }
1748 if right.container_scenario != container_scenario
1749 || left.index == right.index
1750 || is_resolved_null(&right.value)
1751 {
1752 continue;
1753 }
1754 if let Some(conditions) =
1755 self.merge_reachable_conditions(resource_id, &base_and_left, &right.conditions)
1756 {
1757 let source_path = self
1758 .scenario_source_path(resource_id, path, &conditions)
1759 .unwrap_or_else(|| path.to_string());
1760 let value_branch_prefix = format!("{path}.{FN_IF}.");
1761 source_paths.insert(if source_path.starts_with(&value_branch_prefix) {
1762 path.to_string()
1763 } else {
1764 source_path
1765 });
1766 break 'container_members;
1767 }
1768 }
1769 }
1770 }
1771 source_paths
1772 }
1773
1774 fn merge_reachable_conditions(
1775 &self,
1776 resource_id: &str,
1777 left: &HashMap<String, bool>,
1778 right: &HashMap<String, bool>,
1779 ) -> Option<HashMap<String, bool>> {
1780 let mut merged = left.clone();
1781 for (condition, value) in right {
1782 if merged.get(condition).is_some_and(|existing| existing != value) {
1783 return None;
1784 }
1785 merged.insert(condition.clone(), *value);
1786 }
1787 if let Some(resource_condition) =
1788 self.resources.get(resource_id).and_then(|resource| resource.condition.as_ref())
1789 {
1790 if merged.get(resource_condition) == Some(&false) {
1791 return None;
1792 }
1793 merged.insert(resource_condition.clone(), true);
1794 }
1795 let assumptions: Vec<(String, bool)> =
1796 merged.iter().map(|(condition, value)| (condition.clone(), *value)).collect();
1797 (self.conditions.satisfiability(&assumptions) == Satisfiability::Satisfiable).then_some(merged)
1798 }
1799
1800 fn collect_list_member_scenarios(
1801 &self,
1802 value: &ResolvedValue,
1803 assumptions: &HashMap<String, bool>,
1804 next_container_scenario: &mut usize,
1805 member_scenarios: &mut Vec<ListMemberScenario>,
1806 ) {
1807 match value {
1808 ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
1809 Some(true) => {
1810 self.collect_list_member_scenarios(if_true, assumptions, next_container_scenario, member_scenarios)
1811 }
1812 Some(false) => {
1813 self.collect_list_member_scenarios(if_false, assumptions, next_container_scenario, member_scenarios)
1814 }
1815 None => {
1816 let mut true_assumptions = assumptions.clone();
1817 true_assumptions.insert(condition.clone(), true);
1818 self.collect_list_member_scenarios(
1819 if_true,
1820 &true_assumptions,
1821 next_container_scenario,
1822 member_scenarios,
1823 );
1824 let mut false_assumptions = assumptions.clone();
1825 false_assumptions.insert(condition.clone(), false);
1826 self.collect_list_member_scenarios(
1827 if_false,
1828 &false_assumptions,
1829 next_container_scenario,
1830 member_scenarios,
1831 );
1832 }
1833 },
1834 ResolvedValue::Enum { variants } => {
1835 for variant in variants {
1836 self.collect_list_member_scenarios(variant, assumptions, next_container_scenario, member_scenarios);
1837 }
1838 }
1839 ResolvedValue::List { items } => {
1840 let container_scenario = *next_container_scenario;
1841 *next_container_scenario = (*next_container_scenario).saturating_add(1);
1842 for (index, item) in items.iter().enumerate() {
1843 Self::collect_member_scenarios(
1844 item,
1845 assumptions,
1846 container_scenario,
1847 index,
1848 assumptions,
1849 member_scenarios,
1850 );
1851 }
1852 }
1853 ResolvedValue::Concrete { value } => {
1854 if let Some(items) = value.as_array() {
1855 let container_scenario = *next_container_scenario;
1856 *next_container_scenario = (*next_container_scenario).saturating_add(1);
1857 member_scenarios.extend(items.iter().enumerate().map(|(index, item)| ListMemberScenario {
1858 container_scenario,
1859 container_conditions: assumptions.clone(),
1860 index,
1861 value: ResolvedValue::Concrete { value: item.clone().into() },
1862 conditions: assumptions.clone(),
1863 }));
1864 }
1865 }
1866 ResolvedValue::Map { .. }
1867 | ResolvedValue::Reference { .. }
1868 | ResolvedValue::Dynamic { .. }
1869 | ResolvedValue::TypedDynamic { .. } => {}
1870 }
1871 }
1872
1873 fn collect_member_scenarios(
1874 value: &ResolvedValue,
1875 assumptions: &HashMap<String, bool>,
1876 container_scenario: usize,
1877 index: usize,
1878 container_conditions: &HashMap<String, bool>,
1879 member_scenarios: &mut Vec<ListMemberScenario>,
1880 ) {
1881 match value {
1882 ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
1883 Some(true) => Self::collect_member_scenarios(
1884 if_true,
1885 assumptions,
1886 container_scenario,
1887 index,
1888 container_conditions,
1889 member_scenarios,
1890 ),
1891 Some(false) => Self::collect_member_scenarios(
1892 if_false,
1893 assumptions,
1894 container_scenario,
1895 index,
1896 container_conditions,
1897 member_scenarios,
1898 ),
1899 None => {
1900 let mut true_assumptions = assumptions.clone();
1901 true_assumptions.insert(condition.clone(), true);
1902 Self::collect_member_scenarios(
1903 if_true,
1904 &true_assumptions,
1905 container_scenario,
1906 index,
1907 container_conditions,
1908 member_scenarios,
1909 );
1910 let mut false_assumptions = assumptions.clone();
1911 false_assumptions.insert(condition.clone(), false);
1912 Self::collect_member_scenarios(
1913 if_false,
1914 &false_assumptions,
1915 container_scenario,
1916 index,
1917 container_conditions,
1918 member_scenarios,
1919 );
1920 }
1921 },
1922 ResolvedValue::Enum { variants } => {
1923 for variant in variants {
1924 Self::collect_member_scenarios(
1925 variant,
1926 assumptions,
1927 container_scenario,
1928 index,
1929 container_conditions,
1930 member_scenarios,
1931 );
1932 }
1933 }
1934 _ => member_scenarios.push(ListMemberScenario {
1935 container_scenario,
1936 container_conditions: container_conditions.clone(),
1937 index,
1938 value: value.clone(),
1939 conditions: assumptions.clone(),
1940 }),
1941 }
1942 }
1943
1944 #[must_use]
1954 pub fn has_property(&self, resource_id: &str, path: &str) -> bool {
1955 let Some(resource) = self.resources.get(resource_id) else {
1956 return false;
1957 };
1958 let property_path = path.strip_prefix(&format!("{KEY_PROPERTIES}.")).unwrap_or(path);
1959 resource.properties.contains_key(property_path)
1960 || self.resolve_deep(resource_id, path).is_some()
1961 || self.resolve_via_properties_if(resource_id, path).is_some()
1962 }
1963
1964 #[must_use]
1968 pub fn has_unresolved_scenario(&self, resource_id: &str, path: &str) -> bool {
1969 let scenarios = self.resolve_scenarios_shared(resource_id, path);
1970 scenarios.is_empty() || scenarios.iter().any(|(value, _)| contains_dynamic_resolved(value))
1971 }
1972
1973 #[must_use]
1980 pub fn referenced_resource_or_value_identity(&self, resource_id: &str, path: &str) -> Option<String> {
1981 if let Some(target) = self.follow_ref(resource_id, path) {
1982 return Some(format!("resource:{target}"));
1983 }
1984 self.value_identity(resource_id, path)
1985 }
1986
1987 #[must_use]
1988 pub fn is_from_parameter(&self, resource_id: &str, path: &str) -> bool {
1989 self.parameter_name_at(resource_id, path).is_some()
1990 }
1991
1992 pub(crate) fn authored_sub_template_at(&self, resource_id: &str, path: &str) -> Option<&str> {
1993 let resource_id = resource_id.to_string();
1994 let mut current = path;
1995 loop {
1996 if let Some(node) = self.value_nodes.get(&(resource_id.clone(), current.to_string()))
1997 && let Node::Intrinsic(IntrinsicFn::Sub(template, _)) = self.arena.node(*node)
1998 {
1999 return Some(template.as_str());
2000 }
2001 let (parent, _) = current.rsplit_once('.')?;
2002 current = parent;
2003 }
2004 }
2005
2006 #[must_use]
2012 pub fn parameter_name_at(&self, resource_id: &str, path: &str) -> Option<&str> {
2013 let source = self.resolution_sources.get(&(resource_id.to_string(), path.to_string()))?;
2014 parameter_name_from_source(source)
2015 }
2016
2017 #[must_use]
2027 pub fn value_identity(&self, resource_id: &str, path: &str) -> Option<String> {
2028 let resolved = self.resolve_deep(resource_id, path).or_else(|| self.resolve(resource_id, path).cloned())?;
2029 self.resolved_value_identity(resource_id, path, &resolved)
2030 }
2031
2032 pub(crate) fn resolved_value_identity(
2036 &self,
2037 resource_id: &str,
2038 path: &str,
2039 resolved: &ResolvedValue,
2040 ) -> Option<String> {
2041 let reference_prefix = format!("{path}.");
2042 let depends_on_reference = self
2043 .graph
2044 .outgoing_edges(resource_id)
2045 .any(|edge| edge.source_path == path || edge.source_path.starts_with(&reference_prefix));
2046 let as_json = crate::serialization::resolved_value_to_json(resolved);
2047 if !depends_on_reference && !json_contains_markers(&as_json) {
2048 let fingerprint = crate::value_identity::concrete_value_fingerprint(&as_json);
2049 return Some(format!("value:{fingerprint}"));
2050 }
2051 let node = *self.value_nodes.get(&(resource_id.to_string(), path.to_string()))?;
2052 let (result, depth_exhausted) = crate::value_identity::expression_fingerprint_signaled(&self.arena, node);
2053 if depth_exhausted {
2054 self.budget_tracker.record(BudgetKind::ExpressionFingerprintDepth);
2055 }
2056 result.map(|fingerprint| format!("expr:{fingerprint}"))
2057 }
2058
2059 #[must_use]
2063 pub fn is_from_intrinsic(&self, resource_id: &str, path: &str) -> bool {
2064 if self.path_from_intrinsic(resource_id, path) {
2065 return true;
2066 }
2067 let properties_prefix = format!("{}.", KEY_PROPERTIES);
2073 if let Some(rest) = path.strip_prefix(&properties_prefix) {
2074 for branch in ["1", "2"] {
2075 let branch_path = format!("{}.{}.{}.{}", KEY_PROPERTIES, FN_IF, branch, rest);
2076 if self.path_from_intrinsic(resource_id, &branch_path) {
2077 return true;
2078 }
2079 }
2080 }
2081 false
2082 }
2083
2084 #[must_use]
2085 pub fn substituted_paths_under(&self, resource_id: &str, base_path: &str) -> HashSet<String> {
2086 let prefix = format!("{}.", base_path);
2087 let relative_path = |path: &str| {
2088 if path == base_path { Some(String::new()) } else { path.strip_prefix(&prefix).map(String::from) }
2089 };
2090 let mut paths: HashSet<String> =
2091 self.graph.outgoing_edges(resource_id).filter_map(|edge| relative_path(&edge.source_path)).collect();
2092 paths.extend(self.resolution_sources.iter().filter_map(|((source_resource, source_path), _)| {
2093 (source_resource == resource_id).then(|| relative_path(source_path)).flatten()
2094 }));
2095 paths
2096 }
2097
2098 fn path_from_intrinsic(&self, resource_id: &str, path: &str) -> bool {
2099 let Some(intrinsic_paths) = self.intrinsic_source_paths.get(resource_id) else {
2100 return false;
2101 };
2102 let mut ancestor = path;
2108 loop {
2109 if intrinsic_paths.contains(ancestor) {
2110 return true;
2111 }
2112 match ancestor.rfind('.') {
2113 Some(dot) => ancestor = &ancestor[..dot],
2114 None => return false,
2115 }
2116 }
2117 }
2118
2119 #[must_use]
2124 pub fn scenario_budget_exhausted(&self) -> bool {
2125 self.scenario_combinations_used.load(Ordering::Relaxed) >= MAX_TOTAL_SCENARIO_COMBINATIONS
2126 }
2127
2128 pub fn record_budget_exhaustion(&self, kind: BudgetKind) {
2130 self.budget_tracker.record(kind);
2131 }
2132
2133 pub fn exhausted_budget_kinds(&self) -> BTreeSet<BudgetKind> {
2135 self.budget_tracker.exhausted_kinds()
2136 }
2137
2138 pub fn budget_analysis_incomplete(&self) -> bool {
2140 self.budget_tracker.analysis_incomplete()
2141 }
2142
2143 #[must_use]
2145 pub fn scenario_combinations_used(&self) -> u64 {
2146 self.scenario_combinations_used.load(Ordering::Relaxed)
2147 }
2148
2149 #[must_use]
2154 pub fn scenario_expansion_curtailed(&self) -> bool {
2155 self.scenario_expansion_curtailed.load(Ordering::Relaxed)
2156 }
2157
2158 fn collect_scenarios_with_budget_status(
2159 &self,
2160 value: &ResolvedValue,
2161 per_value_limit: usize,
2162 total_limit: u64,
2163 ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
2164 self.collect_scenarios_with_budget_from(value, &HashMap::new(), per_value_limit, total_limit)
2165 }
2166
2167 fn collect_scenarios_with_budget_from(
2168 &self,
2169 value: &ResolvedValue,
2170 assumptions: &HashMap<String, bool>,
2171 per_value_limit: usize,
2172 total_limit: u64,
2173 ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
2174 let _expansion_guard = self.scenario_expansion_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2175 let used = self.scenario_combinations_used.load(Ordering::Relaxed);
2176 if used >= total_limit {
2177 self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
2178 if total_limit == MAX_TOTAL_SCENARIO_COMBINATIONS {
2179 self.budget_tracker.record(BudgetKind::ScenarioCombinationsTotal);
2180 }
2181 return (Vec::new(), true);
2182 }
2183
2184 let remaining = total_limit - used;
2185 let remaining_limit = usize::try_from(remaining).unwrap_or(usize::MAX);
2186 let effective_limit = per_value_limit.min(remaining_limit);
2187 let mut scenarios = Vec::new();
2188 let is_reachable = |scenario_assumptions: &HashMap<String, bool>| {
2189 let assumptions: Vec<(String, bool)> =
2190 scenario_assumptions.iter().map(|(condition, value)| (condition.clone(), *value)).collect();
2191 self.conditions.is_satisfiable(&assumptions)
2192 };
2193 let mut used_covering = false;
2194 let was_curtailed = collect_scenarios_reachable_with_covering(
2195 value,
2196 assumptions,
2197 effective_limit,
2198 &mut scenarios,
2199 &is_reachable,
2200 &mut used_covering,
2201 );
2202 if used_covering {
2203 self.budget_tracker.record(BudgetKind::ScenarioProductCovering);
2204 }
2205 if was_curtailed {
2206 self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
2207 if per_value_limit == MAX_SCENARIO_COMBINATIONS && per_value_limit <= remaining_limit {
2208 self.budget_tracker.record(BudgetKind::ScenarioCombinationsPerValue);
2209 }
2210 if total_limit == MAX_TOTAL_SCENARIO_COMBINATIONS && remaining_limit <= per_value_limit {
2211 self.budget_tracker.record(BudgetKind::ScenarioCombinationsTotal);
2212 }
2213 }
2214 self.scenario_combinations_used.store(used + scenarios.len() as u64, Ordering::Relaxed);
2215 (scenarios, was_curtailed)
2216 }
2217
2218 fn collect_scenarios_with_budget(
2219 &self,
2220 value: &ResolvedValue,
2221 per_value_limit: usize,
2222 total_limit: u64,
2223 ) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
2224 self.collect_scenarios_with_budget_status(value, per_value_limit, total_limit).0
2225 }
2226
2227 #[cfg(test)]
2231 pub(crate) fn add_scenario_combinations_for_test(&self, count: u64) {
2232 let _expansion_guard = self.scenario_expansion_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2233 self.scenario_combinations_used.fetch_add(count, Ordering::Relaxed);
2234 }
2235
2236 #[cfg(test)]
2239 fn set_scenario_expansion_curtailed_for_test(&self) {
2240 self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
2241 }
2242
2243 fn resolved_properties_value(&self, resource_id: &str) -> Option<ResolvedValue> {
2244 let resource = self.resources.get(resource_id)?;
2245 if resource.properties_dynamic {
2246 return None;
2247 }
2248 if resource.properties.len() == 1
2249 && let Some(conditional) = resource.properties.get(FN_IF)
2250 {
2251 return Some(conditional.clone());
2252 }
2253 let mut entries: Vec<MapEntry> = resource
2254 .properties
2255 .iter()
2256 .map(|(key, value)| MapEntry { key: key.clone(), value: value.clone() })
2257 .collect();
2258 entries.sort_by(|left, right| left.key.cmp(&right.key));
2259 Some(ResolvedValue::Map { entries })
2260 }
2261
2262 pub fn resolve_properties_scenarios_shared(
2267 &self,
2268 resource_id: &str,
2269 ) -> Arc<Vec<(ResolvedValue, HashMap<String, bool>)>> {
2270 self.resolve_properties_scenarios_selected(resource_id, None)
2271 }
2272
2273 pub fn resolve_properties_scenarios(&self, resource_id: &str) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
2277 self.resolve_properties_scenarios_shared(resource_id).as_ref().clone()
2278 }
2279
2280 pub fn resolve_properties_scenarios_projected(
2286 &self,
2287 resource_id: &str,
2288 property_names: &[&str],
2289 ) -> Arc<Vec<(ResolvedValue, HashMap<String, bool>)>> {
2290 let selection: BTreeSet<String> = property_names.iter().map(|name| (*name).to_string()).collect();
2291 self.resolve_properties_scenarios_selected(resource_id, Some(selection))
2292 }
2293
2294 #[must_use]
2304 pub fn property_absence_scenarios(&self, resource_id: &str, property_name: &str) -> Vec<HashMap<String, bool>> {
2305 let Some(properties) = self.resolved_properties_value(resource_id) else {
2306 return Vec::new();
2307 };
2308 let mut absences = Vec::new();
2309 collect_property_absences(&properties, property_name, &mut Vec::new(), &mut absences);
2310 absences.retain(|conditions| {
2311 let mut assumptions: Vec<(String, bool)> =
2312 conditions.iter().map(|(condition, value)| (condition.clone(), *value)).collect();
2313 if let Some(resource_condition) =
2314 self.resources.get(resource_id).and_then(|resource| resource.condition.as_ref())
2315 {
2316 match conditions.get(resource_condition) {
2317 Some(false) => return false,
2318 Some(true) => {}
2319 None => assumptions.push((resource_condition.clone(), true)),
2320 }
2321 }
2322 assumptions.is_empty() || self.conditions.is_satisfiable(&assumptions)
2323 });
2324 absences
2325 }
2326
2327 fn resolve_properties_scenarios_selected(
2328 &self,
2329 resource_id: &str,
2330 selection: Option<BTreeSet<String>>,
2331 ) -> Arc<Vec<(ResolvedValue, HashMap<String, bool>)>> {
2332 let cache_key = (resource_id.to_string(), selection);
2333 let mut cache = self.properties_scenario_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2334 if let Some(scenarios) = cache.get(&cache_key) {
2335 return Arc::clone(scenarios);
2336 }
2337 let properties = self.resolved_properties_value(resource_id).map(|properties| match &cache_key.1 {
2338 Some(selection) => project_properties_value(&properties, selection),
2339 None => properties,
2340 });
2341 let scenarios = match properties {
2342 Some(properties) => self.collect_scenarios_with_budget(
2343 &properties,
2344 MAX_SCENARIO_COMBINATIONS,
2345 MAX_TOTAL_SCENARIO_COMBINATIONS,
2346 ),
2347 None => Vec::new(),
2348 };
2349 let shared = Arc::new(scenarios);
2350 cache.insert(cache_key, Arc::clone(&shared));
2351 shared
2352 }
2353
2354 #[doc(hidden)]
2356 #[must_use]
2357 pub fn authored_intrinsic_name_at(&self, resource_id: &str, path: &str) -> Option<&'static str> {
2358 let node_ref = self.value_nodes.get(&(resource_id.to_string(), path.to_string()))?;
2359 match self.arena.node(*node_ref) {
2360 Node::Intrinsic(intrinsic) => Some(cfn_function_name(intrinsic)),
2361 _ => None,
2362 }
2363 }
2364
2365 #[doc(hidden)]
2368 #[must_use]
2369 pub fn authored_select_ref_target_at(&self, resource_id: &str, path: &str) -> Option<&str> {
2370 let node_ref = self.value_nodes.get(&(resource_id.to_string(), path.to_string()))?;
2371 selected_ref_target(&self.arena, *node_ref)
2372 }
2373
2374 #[must_use]
2376 pub fn is_authored_ref_at(&self, resource_id: &str, path: &str, target: &str) -> bool {
2377 self.value_nodes
2378 .get(&(resource_id.to_string(), path.to_string()))
2379 .is_some_and(|node_ref| {
2380 matches!(self.arena.node(*node_ref), Node::Intrinsic(IntrinsicFn::Ref(actual)) if actual == target)
2381 })
2382 }
2383
2384 #[must_use]
2386 pub fn parameter_has_override(&self, parameter_name: &str) -> bool {
2387 self.parameter_overrides.contains(parameter_name)
2388 }
2389
2390 pub fn scenario_source_path(
2394 &self,
2395 resource_id: &str,
2396 effective_path: &str,
2397 conditions: &HashMap<String, bool>,
2398 ) -> Option<String> {
2399 let properties = self.resolved_properties_value(resource_id)?;
2400 let relative_path = effective_path
2401 .strip_prefix("Properties.")
2402 .or_else(|| (effective_path == KEY_PROPERTIES).then_some(""))
2403 .unwrap_or(effective_path);
2404 scenario_source_path_at(&properties, relative_path, conditions, KEY_PROPERTIES)
2405 }
2406
2407 #[must_use]
2409 pub fn scenario_uses_pseudo_parameter(
2410 &self,
2411 resource_id: &str,
2412 effective_path: &str,
2413 conditions: &HashMap<String, bool>,
2414 ) -> bool {
2415 let source_path = self
2416 .scenario_source_path(resource_id, effective_path, conditions)
2417 .unwrap_or_else(|| effective_path.to_string());
2418 let descendant_prefix = format!("{source_path}.");
2419 let Some(source_paths) = self.pseudo_parameter_source_paths.get(resource_id) else {
2420 return false;
2421 };
2422 source_paths
2423 .iter()
2424 .any(|candidate_path| candidate_path == &source_path || candidate_path.starts_with(&descendant_prefix))
2425 }
2426
2427 #[must_use]
2431 pub fn scenario_uses_unresolved_parameter(
2432 &self,
2433 resource_id: &str,
2434 effective_path: &str,
2435 conditions: &HashMap<String, bool>,
2436 ) -> bool {
2437 self.graph.outgoing_edges(resource_id).any(|edge| {
2438 self.unresolved_parameter_edge_applies(edge, conditions)
2439 && Self::reference_path_overlaps(&edge.source_path, effective_path)
2440 })
2441 }
2442
2443 pub(crate) fn scenario_value_uses_unresolved_parameter(
2447 &self,
2448 resource_id: &str,
2449 effective_path: &str,
2450 conditions: &HashMap<String, bool>,
2451 ) -> bool {
2452 self.graph.outgoing_edges(resource_id).any(|edge| {
2453 self.unresolved_parameter_edge_applies(edge, conditions)
2454 && Self::reference_path_contains_value(&edge.source_path, effective_path)
2455 })
2456 }
2457
2458 fn unresolved_parameter_edge_applies(&self, edge: &Edge, conditions: &HashMap<String, bool>) -> bool {
2459 self.parameters.get(&edge.target).is_some_and(|parameter| {
2460 (!self.parameter_overrides.contains(&edge.target)
2461 || parameter.param_type.starts_with(SSM_PARAMETER_VALUE_TYPE_PREFIX))
2462 && Self::edge_applies_to_scenario(edge.condition_context.as_deref(), conditions)
2463 })
2464 }
2465
2466 fn reference_path_overlaps(source_path: &str, effective_path: &str) -> bool {
2467 let Some(reference_anchor) = Self::reference_anchor(source_path) else {
2468 return false;
2469 };
2470 Self::path_is_at_or_below(&reference_anchor, effective_path)
2471 || Self::path_is_at_or_below(effective_path, &reference_anchor)
2472 }
2473
2474 fn reference_path_contains_value(source_path: &str, effective_path: &str) -> bool {
2475 Self::reference_anchor(source_path)
2476 .is_some_and(|reference_anchor| Self::path_is_at_or_below(effective_path, &reference_anchor))
2477 }
2478
2479 fn reference_anchor(source_path: &str) -> Option<String> {
2480 let normalized_source = effective_path_from_scenario_source_path(source_path)?;
2481 Some(normalized_source.split_once(".Fn::").map_or(normalized_source.as_str(), |(prefix, _)| prefix).to_string())
2482 }
2483
2484 fn path_is_at_or_below(candidate_path: &str, root_path: &str) -> bool {
2485 if root_path.is_empty() {
2486 return true;
2487 }
2488 let mut candidate_segments = candidate_path.split('.');
2489 root_path.split('.').all(|root_segment| {
2490 !root_segment.is_empty()
2491 && candidate_segments.next().is_some_and(|candidate_segment| {
2492 !candidate_segment.is_empty() && (root_segment == "{}" || root_segment == candidate_segment)
2493 })
2494 })
2495 }
2496
2497 fn edge_applies_to_scenario(condition_context: Option<&str>, conditions: &HashMap<String, bool>) -> bool {
2498 condition_context.is_none_or(|context| {
2499 context.split(',').all(|selector| {
2500 let (condition, expected) =
2501 selector.strip_prefix('!').map_or((selector, true), |condition| (condition, false));
2502 !condition.is_empty() && conditions.get(condition).is_none_or(|actual| *actual == expected)
2503 })
2504 })
2505 }
2506
2507 pub fn resolve_scenarios_shared(
2510 &self,
2511 resource_id: &str,
2512 path: &str,
2513 ) -> Arc<Vec<(ResolvedValue, HashMap<String, bool>)>> {
2514 self.resolve_scenarios_with_limit_shared(resource_id, path, MAX_SCENARIO_COMBINATIONS).0
2515 }
2516
2517 pub fn resolve_scenarios(&self, resource_id: &str, path: &str) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
2520 self.resolve_scenarios_shared(resource_id, path).as_ref().clone()
2521 }
2522
2523 pub fn resolve_scenarios_with_limit_shared(
2529 &self,
2530 resource_id: &str,
2531 path: &str,
2532 per_value_limit: usize,
2533 ) -> (Arc<Vec<(ResolvedValue, HashMap<String, bool>)>>, bool) {
2534 let key = (resource_id.to_string(), path.to_string(), per_value_limit);
2535 let mut memo = self.raw_scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2536 if let Some((scenarios, was_curtailed)) = memo.get(&key) {
2537 return (Arc::clone(scenarios), *was_curtailed);
2538 }
2539 let (scenarios, was_curtailed) = self.expand_scenarios_at_path(resource_id, path, per_value_limit);
2540 let scenarios = Arc::new(scenarios);
2541 memo.insert(key, (Arc::clone(&scenarios), was_curtailed));
2542 (scenarios, was_curtailed)
2543 }
2544
2545 pub fn resolve_scenarios_with_limit(
2549 &self,
2550 resource_id: &str,
2551 path: &str,
2552 per_value_limit: usize,
2553 ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
2554 let (scenarios, was_curtailed) = self.resolve_scenarios_with_limit_shared(resource_id, path, per_value_limit);
2555 (scenarios.as_ref().clone(), was_curtailed)
2556 }
2557
2558 fn expand_scenarios_at_path(
2559 &self,
2560 resource_id: &str,
2561 path: &str,
2562 per_value_limit: usize,
2563 ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
2564 let Some(value) = self.resolve_unexpanded(resource_id, path) else {
2565 return (Vec::new(), false);
2566 };
2567 self.collect_scenarios_with_budget_status(&value, per_value_limit, MAX_TOTAL_SCENARIO_COMBINATIONS)
2568 }
2569
2570 fn resolve_via_properties_if(&self, resource_id: &str, path: &str) -> Option<ResolvedValue> {
2574 let resource = self.resources.get(resource_id)?;
2575 let conditional = resource.properties.get(FN_IF)?;
2576 let properties_prefix = format!("{}.", KEY_PROPERTIES);
2577 let prop_path = path.strip_prefix(&properties_prefix).unwrap_or(path);
2578 if prop_path.is_empty() {
2579 return Some(conditional.clone());
2580 }
2581 resolved_value_at_path(conditional, prop_path)
2582 }
2583
2584 fn resolve_scenarios_json_uncached(
2585 &self,
2586 resource_id: &str,
2587 path: &str,
2588 ) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
2589 self.resolve_scenarios_shared(resource_id, path)
2590 .iter()
2591 .filter_map(|(val, conds)| {
2592 if contains_dynamic_resolved(val) {
2593 return None;
2594 }
2595 if !conds.is_empty() {
2596 let assumptions: Vec<(String, bool)> = conds.iter().map(|(k, v)| (k.clone(), *v)).collect();
2597 if !self.conditions.is_satisfiable(&assumptions) {
2598 return None;
2599 }
2600 }
2601 let json = crate::serialization::resolved_value_to_json_clean(val)?;
2602 if json_contains_markers(&json) {
2603 return None;
2604 }
2605 Some((json, conds.clone()))
2606 })
2607 .collect()
2608 }
2609
2610 fn cache_json_scenarios(
2611 &self,
2612 memo_key: (String, String),
2613 scenarios: Vec<(serde_json::Value, HashMap<String, bool>)>,
2614 ) -> Arc<Vec<(serde_json::Value, HashMap<String, bool>)>> {
2615 let mut memo = self.scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2616 Arc::clone(memo.entry(memo_key).or_insert_with(|| Arc::new(scenarios)))
2617 }
2618
2619 pub fn resolve_scenarios_json_shared(
2621 &self,
2622 resource_id: &str,
2623 path: &str,
2624 ) -> Arc<Vec<(serde_json::Value, HashMap<String, bool>)>> {
2625 let memo_key = (resource_id.to_string(), path.to_string());
2626 {
2627 let memo = self.scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2628 if let Some(memoized) = memo.get(&memo_key) {
2629 return Arc::clone(memoized);
2630 }
2631 }
2632 let scenarios = self.resolve_scenarios_json_uncached(resource_id, path);
2633 self.cache_json_scenarios(memo_key, scenarios)
2634 }
2635
2636 pub fn resolve_scenarios_json(
2637 &self,
2638 resource_id: &str,
2639 path: &str,
2640 ) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
2641 let memo_key = (resource_id.to_string(), path.to_string());
2642 {
2643 let memo = self.scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2644 if let Some(memoized) = memo.get(&memo_key) {
2645 return memoized.as_ref().clone();
2646 }
2647 }
2648 let scenarios = self.resolve_scenarios_json_uncached(resource_id, path);
2649 self.cache_json_scenarios(memo_key, scenarios).as_ref().clone()
2650 }
2651
2652 pub fn follow_ref(&self, resource_id: &str, path: &str) -> Option<&str> {
2653 if let Some(ResolvedValue::Reference { target, kind: _ }) = self.resolve(resource_id, path) {
2654 return Some(target.as_str());
2655 }
2656 if let Some(ResolvedValue::Reference { target, kind: _ }) = self.resolve_deep(resource_id, path).as_ref() {
2657 for edge in self.graph.outgoing_edges(resource_id) {
2658 if edge.target == *target {
2659 return Some(&edge.target);
2660 }
2661 }
2662 }
2663 None
2664 }
2665
2666 pub fn source_location(&self, path: &str) -> Option<&SourceSpan> {
2667 self.span_index.get(path)
2668 }
2669
2670 pub fn resource_span(&self, resource_id: &str, prop_path: &str) -> SourceSpan {
2680 if resource_id.is_empty() {
2687 return self.walk_up_span(prop_path).unwrap_or(UNKNOWN_SPAN);
2688 }
2689 if let Some((authored_path, function_name)) = prop_path.rsplit_once('.')
2693 && let Some(node_ref) = self.value_nodes.get(&(resource_id.to_string(), authored_path.to_string()))
2694 && let Node::Intrinsic(intrinsic) = self.arena.node(*node_ref)
2695 && cfn_function_name(intrinsic) == function_name
2696 {
2697 let intrinsic_path = format!("{}/{}", self.arena.get(*node_ref).path, function_name);
2698 if let Some(span) = self.walk_up_span(&intrinsic_path) {
2699 return span;
2700 }
2701 }
2702 let specific = if prop_path.is_empty() {
2703 format!("Resources/{}", resource_id)
2704 } else {
2705 format!("Resources/{}/{}", resource_id, prop_path.replace('.', "/"))
2706 };
2707 let exact_span = self.span_index.get(&specific).copied().filter(|span| *span != UNKNOWN_SPAN);
2708 if matches!(specific.rsplit_once('/'), Some((parent, "1" | "2")) if parent.ends_with("/Fn::If"))
2709 && let Some(descendant) = self.conditional_branch_first_descendant_spans.get(&specific).copied()
2710 && exact_span.is_none_or(|exact| {
2711 (descendant.start_line, descendant.start_column) < (exact.start_line, exact.start_column)
2712 })
2713 {
2714 return descendant;
2715 }
2716 if let Some(exact) = exact_span {
2717 return exact;
2718 }
2719 self.walk_up_span(&specific).unwrap_or(UNKNOWN_SPAN)
2725 }
2726
2727 fn walk_up_span(&self, key: &str) -> Option<SourceSpan> {
2733 let mut current = key;
2734 loop {
2735 match self.span_index.get(current) {
2740 Some(&span) if span != UNKNOWN_SPAN => return Some(span),
2741 _ => match current.rfind('/') {
2742 Some(cut) => current = ¤t[..cut],
2743 None => return None,
2744 },
2745 }
2746 }
2747 }
2748
2749 pub fn diagnostic_span(&self, resource_id: Option<&str>, property_path: &str) -> Option<SourceSpan> {
2765 let rid = resource_id.filter(|r| !r.is_empty());
2766
2767 if property_path.contains('/') || (rid.is_none() && !property_path.is_empty()) {
2768 if let Some(span) = self.walk_up_span(property_path) {
2770 return Some(span);
2771 }
2772 } else if let Some(rid) = rid {
2773 let key = if property_path.is_empty() {
2776 format!("Resources/{}", rid)
2777 } else {
2778 format!("Resources/{}/{}", rid, property_path.replace('.', "/"))
2779 };
2780 if let Some(span) = self.walk_up_span(&key) {
2781 return Some(span);
2782 }
2783 }
2784
2785 rid.and_then(|rid| self.span_index.get(&format!("Resources/{}", rid)))
2787 .filter(|span| **span != UNKNOWN_SPAN)
2788 .copied()
2789 }
2790
2791 pub fn estimated_string_length_bounds(&self, resource_id: &str, path: &str) -> Option<(usize, usize)> {
2800 let val = self.resolve_deep(resource_id, path).or_else(|| self.resolve(resource_id, path).cloned())?;
2801 if matches!(&val, ResolvedValue::Concrete { value } if value.is_string()) {
2802 return None;
2803 }
2804 estimate_resolved_string_length_bounds(&val)
2805 }
2806}
2807
2808impl SpanProvider for SemanticModel {
2809 fn source_location(&self, path: &str) -> Option<SourceSpan> {
2810 self.span_index.get(path).copied()
2811 }
2812}
2813
2814fn collect_property_absences(
2815 properties: &ResolvedValue,
2816 property_name: &str,
2817 literals: &mut Vec<(String, bool)>,
2818 absences: &mut Vec<HashMap<String, bool>>,
2819) {
2820 let assignment = |literals: &[(String, bool)]| literals.iter().cloned().collect::<HashMap<String, bool>>();
2821 match properties {
2822 ResolvedValue::Conditional { condition, if_true, if_false } => {
2823 for (branch, holds) in [(if_true, true), (if_false, false)] {
2824 match literals.iter().find(|(name, _)| name == condition) {
2825 Some((_, decided)) if *decided == holds => {
2826 collect_property_absences(branch, property_name, literals, absences);
2827 }
2828 Some(_) => {}
2829 None => {
2830 literals.push((condition.clone(), holds));
2831 collect_property_absences(branch, property_name, literals, absences);
2832 literals.pop();
2833 }
2834 }
2835 }
2836 }
2837 ResolvedValue::Map { entries } if entries.iter().any(|entry| entry.key == FN_TRANSFORM) => {}
2838 ResolvedValue::Map { entries } => match entries.iter().find(|entry| entry.key == property_name) {
2839 None => absences.push(assignment(literals)),
2840 Some(entry) => {
2841 for absence_literals in absent_alternatives(&entry.value, literals) {
2842 absences.push(assignment(&absence_literals));
2843 }
2844 }
2845 },
2846 ResolvedValue::Concrete { value } if value.get(property_name).is_none_or(serde_json::Value::is_null) => {
2847 absences.push(assignment(literals));
2848 }
2849 _ => {}
2850 }
2851}
2852
2853fn project_properties_value(properties: &ResolvedValue, selection: &BTreeSet<String>) -> ResolvedValue {
2856 match properties {
2857 ResolvedValue::Conditional { condition, if_true, if_false } => ResolvedValue::Conditional {
2858 condition: condition.clone(),
2859 if_true: Box::new(project_properties_value(if_true, selection)),
2860 if_false: Box::new(project_properties_value(if_false, selection)),
2861 },
2862 ResolvedValue::Map { entries } => ResolvedValue::Map {
2863 entries: entries.iter().filter(|entry| selection.contains(&entry.key)).cloned().collect(),
2864 },
2865 ResolvedValue::Concrete { value } => match value.as_object() {
2866 Some(object) => ResolvedValue::Concrete {
2867 value: serde_json::Value::Object(
2868 object
2869 .iter()
2870 .filter(|(key, _)| selection.contains(*key))
2871 .map(|(k, v)| (k.clone(), v.clone()))
2872 .collect(),
2873 )
2874 .into(),
2875 },
2876 None => properties.clone(),
2877 },
2878 other => other.clone(),
2879 }
2880}
2881
2882fn parse_rules(rules_json: &Option<serde_json::Value>, arena: &Arena, rules_node: NodeRef) -> Vec<TemplateRule> {
2883 let Some(rules) = rules_json else {
2884 return Vec::new();
2885 };
2886 let Some(obj) = rules.as_object() else {
2887 return Vec::new();
2888 };
2889
2890 let rule_nodes: HashMap<&str, NodeRef> =
2892 arena.as_map(rules_node).unwrap_or(&[]).iter().map(|(k, v)| (k.as_str(), *v)).collect();
2893
2894 obj.iter()
2895 .filter_map(|(name, rule)| {
2896 let rule_obj = rule.as_object()?;
2897 let condition = rule_obj.get(KEY_RULE_CONDITION).cloned();
2898
2899 let rule_ir = rule_nodes.get(name.as_str()).copied().unwrap_or(NULL_REF);
2901 let condition_node = arena.map_get(rule_ir, KEY_RULE_CONDITION).unwrap_or(NULL_REF);
2902
2903 let assertions_node = arena.map_get(rule_ir, KEY_ASSERTIONS).unwrap_or(NULL_REF);
2904 let assertion_items = arena.as_list(assertions_node).unwrap_or(&[]);
2905
2906 let assertions = rule_obj
2907 .get(KEY_ASSERTIONS)
2908 .and_then(|a| a.as_array())
2909 .map(|arr| {
2910 arr.iter()
2911 .enumerate()
2912 .filter_map(|(idx, a)| {
2913 let a_obj = a.as_object()?;
2914 let assert_node = assertion_items
2915 .get(idx)
2916 .and_then(|item_ref| arena.map_get(*item_ref, KEY_ASSERT))
2917 .unwrap_or(NULL_REF);
2918 Some(RuleAssertion {
2919 assert: a_obj.get(KEY_ASSERT).cloned().unwrap_or(serde_json::Value::Null),
2920 assert_node,
2921 description: a_obj
2922 .get(KEY_ASSERT_DESCRIPTION)
2923 .and_then(|d| d.as_str())
2924 .map(String::from),
2925 })
2926 })
2927 .collect()
2928 })
2929 .unwrap_or_default();
2930 Some(TemplateRule { name: name.clone(), condition, condition_node, assertions })
2931 })
2932 .collect()
2933}
2934
2935fn collect_parameter_definition_refs(ir: &TemplateIR, parameters: &HashMap<String, ParameterInfo>) -> HashSet<String> {
2939 let mut referenced = HashSet::new();
2940 if ir.parameters == NULL_REF {
2941 return referenced;
2942 }
2943 let Some(param_entries) = ir.arena.as_map(ir.parameters) else {
2944 return referenced;
2945 };
2946 for (_, param_ref) in param_entries {
2947 collect_refs_in_subtree(&ir.arena, *param_ref, parameters, &mut referenced);
2948 }
2949 referenced
2950}
2951
2952fn collect_refs_in_subtree(
2953 arena: &Arena,
2954 node_ref: NodeRef,
2955 parameters: &HashMap<String, ParameterInfo>,
2956 referenced: &mut HashSet<String>,
2957) {
2958 match arena.node(node_ref) {
2959 Node::Intrinsic(IntrinsicFn::Ref(target)) => {
2960 if parameters.contains_key(target) {
2961 referenced.insert(target.clone());
2962 }
2963 }
2964 Node::Intrinsic(IntrinsicFn::Sub(_, Some(bindings))) => {
2965 for (_, v) in bindings {
2966 collect_refs_in_subtree(arena, *v, parameters, referenced);
2967 }
2968 }
2969 Node::List(items) => {
2970 for item in items {
2971 collect_refs_in_subtree(arena, *item, parameters, referenced);
2972 }
2973 }
2974 Node::Map(entries) => {
2975 for (_, v) in entries {
2976 collect_refs_in_subtree(arena, *v, parameters, referenced);
2977 }
2978 }
2979 _ => {}
2980 }
2981}
2982
2983fn selected_ref_target(arena: &Arena, node_ref: NodeRef) -> Option<&str> {
2984 match arena.node(node_ref) {
2985 Node::Intrinsic(IntrinsicFn::Ref(target)) => Some(target),
2986 Node::Intrinsic(IntrinsicFn::Select(index_ref, values_ref)) => {
2987 let index = match arena.node(*index_ref) {
2988 Node::Int(index) => usize::try_from(*index).ok()?,
2989 Node::String(index) => index.parse().ok()?,
2990 _ => return None,
2991 };
2992 let selected_ref = arena.as_list(*values_ref)?.get(index)?;
2993 selected_ref_target(arena, *selected_ref)
2994 }
2995 _ => None,
2996 }
2997}
2998
2999fn canonical_conditions(conditions: &HashMap<String, bool>) -> Vec<(String, bool)> {
3000 let mut canonical: Vec<_> = conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
3001 canonical.sort_unstable();
3002 canonical
3003}
3004
3005fn parameter_name_from_source(source: &str) -> Option<&str> {
3006 let rest = source.strip_prefix(SECTION_PARAMETERS)?.strip_prefix('/')?;
3007 let name = rest.split('/').next()?;
3008 if name.is_empty() { None } else { Some(name) }
3009}
3010
3011const INTRINSIC_SOURCE_PREFIX: &str = "Intrinsic/";
3013
3014fn build_intrinsic_source_paths(
3022 resolution_sources: &HashMap<(String, String), String>,
3023 graph: &ReferenceGraph,
3024) -> HashMap<String, HashSet<String>> {
3025 let mut paths_by_resource: HashMap<String, HashSet<String>> = HashMap::new();
3026 for ((resource_id, path), source) in resolution_sources {
3027 if source.starts_with(INTRINSIC_SOURCE_PREFIX) {
3028 paths_by_resource.entry(resource_id.clone()).or_default().insert(path.clone());
3029 }
3030 }
3031 for edge in &graph.edges {
3032 paths_by_resource.entry(edge.source_resource.clone()).or_default().insert(edge.source_path.clone());
3033 }
3034 paths_by_resource
3035}
3036
3037fn build_conditional_branch_first_descendant_spans(span_index: &SourceSpanIndex) -> HashMap<String, SourceSpan> {
3038 let mut first_by_branch = HashMap::new();
3039 for (path, span) in span_index {
3040 if *span == UNKNOWN_SPAN {
3041 continue;
3042 }
3043 let segments: Vec<&str> = path.split('/').collect();
3044 for index in 0..segments.len().saturating_sub(2) {
3045 if segments[index] != FN_IF || !matches!(segments[index + 1], "1" | "2") {
3046 continue;
3047 }
3048 let branch_path = segments[..=index + 1].join("/");
3049 first_by_branch
3050 .entry(branch_path)
3051 .and_modify(|known: &mut SourceSpan| {
3052 if (span.start_line, span.start_column, span.end_line, span.end_column)
3053 < (known.start_line, known.start_column, known.end_line, known.end_column)
3054 {
3055 *known = *span;
3056 }
3057 })
3058 .or_insert(*span);
3059 }
3060 }
3061 first_by_branch
3062}
3063
3064fn build_pseudo_parameter_source_paths(
3065 arena: &Arena,
3066 value_nodes: &HashMap<(String, String), NodeRef>,
3067) -> HashMap<String, HashSet<String>> {
3068 let mut paths_by_resource: HashMap<String, HashSet<String>> = HashMap::new();
3069 for ((resource_id, path), node_ref) in value_nodes {
3070 if node_uses_pseudo_parameter(arena, *node_ref) {
3071 paths_by_resource.entry(resource_id.clone()).or_default().insert(path.clone());
3072 }
3073 }
3074 paths_by_resource
3075}
3076
3077fn node_uses_pseudo_parameter(arena: &Arena, node_ref: NodeRef) -> bool {
3078 match arena.node(node_ref) {
3079 Node::Intrinsic(IntrinsicFn::Ref(target)) => {
3080 target != PSEUDO_NO_VALUE && PSEUDO_PARAMETERS.contains(&target.as_str())
3081 }
3082 Node::Intrinsic(IntrinsicFn::Sub(template, bindings)) => {
3083 let binding_overrides = |name: &str| {
3084 bindings.as_ref().is_some_and(|entries| entries.iter().any(|(binding, _)| binding == name))
3085 };
3086 PSEUDO_PARAMETERS
3087 .iter()
3088 .filter(|name| **name != PSEUDO_NO_VALUE)
3089 .any(|name| !binding_overrides(name) && template.contains(&format!("${{{name}}}")))
3090 || bindings
3091 .as_ref()
3092 .is_some_and(|entries| entries.iter().any(|(_, value)| node_uses_pseudo_parameter(arena, *value)))
3093 }
3094 _ => false,
3095 }
3096}
3097
3098fn intrinsic_synthetic_key(arena: &Arena, node_ref: NodeRef) -> Option<String> {
3105 let Node::Intrinsic(func) = arena.node(node_ref) else {
3106 return None;
3107 };
3108 match func {
3109 IntrinsicFn::If(_, _, _) | IntrinsicFn::IfExpr(_, _, _) => Some(FN_IF.to_string()),
3110 IntrinsicFn::ForEach(uid, _, _, _) => Some(format!("{}::{}", FN_FOR_EACH, uid)),
3111 _ => None,
3112 }
3113}
3114
3115const VALID_RESOURCE_ATTRIBUTES: &[&str] = &[
3119 KEY_TYPE,
3120 KEY_PROPERTIES,
3121 KEY_DEPENDS_ON,
3122 KEY_CONDITION,
3123 SECTION_METADATA,
3124 KEY_DELETION_POLICY,
3125 KEY_UPDATE_REPLACE_POLICY,
3126 KEY_UPDATE_POLICY,
3127 KEY_CREATION_POLICY,
3128 FN_TRANSFORM,
3129 "Version",
3130];
3131
3132const SAM_RESOURCE_ATTRIBUTES: &[&str] = &["Connectors", "IgnoreGlobals"];
3136
3137fn invalid_resource_condition_ref(arena: &Arena, node_ref: NodeRef) -> Option<NodeRef> {
3138 let entries = arena.as_map(node_ref)?;
3139 let condition_ref = entries.iter().find(|(key, _)| key == KEY_CONDITION).map(|(_, value)| *value)?;
3140 (!matches!(arena.node(condition_ref), Node::String(_))).then_some(condition_ref)
3141}
3142
3143fn validate_resource_shape(
3147 arena: &Arena,
3148 name: &str,
3149 node_ref: NodeRef,
3150 is_sam: bool,
3151 span_index: &SourceSpanIndex,
3152) -> Vec<ParseDefect> {
3153 let mut out = Vec::new();
3154 let resource_span = span_index.get(&format!("Resources/{}", name)).copied().unwrap_or(UNKNOWN_SPAN);
3155
3156 let entries = match arena.as_map(node_ref) {
3158 Some(entries) => entries,
3159 None => {
3160 let shape = match arena.node(node_ref) {
3161 Node::Null => "null",
3162 Node::Bool(_) => "a boolean",
3163 Node::Int(_) | Node::Float(_) => "a number",
3164 Node::String(_) => "a string",
3165 Node::List(_) => "a list",
3166 Node::Intrinsic(_) => "an intrinsic function",
3167 Node::Map(_) => return out,
3168 };
3169 out.push(crate::make_parse_defect_for_resource(
3170 "E3001",
3171 format!("Resource '{}' body must be an object, got {}", name, shape),
3172 resource_span,
3173 name,
3174 ));
3175 return out;
3176 }
3177 };
3178
3179 match entries.iter().find(|(k, _)| k == KEY_TYPE) {
3181 None => {
3182 out.push(crate::make_parse_defect_for_resource(
3183 "E3001",
3184 format!("Resource '{}' is missing required property 'Type'", name),
3185 resource_span,
3186 name,
3187 ));
3188 }
3189 Some((_, type_ref)) if !matches!(arena.node(*type_ref), Node::String(_)) => {
3190 let type_span = span_index.get(&format!("Resources/{}/Type", name)).copied().unwrap_or(resource_span);
3191 out.push(crate::make_parse_defect_for_resource(
3192 "E3001",
3193 format!("Resource '{}' property 'Type' must be a string", name),
3194 type_span,
3195 name,
3196 ));
3197 }
3198 _ => {}
3199 }
3200
3201 if invalid_resource_condition_ref(arena, node_ref).is_some() {
3203 let cond_span = span_index.get(&format!("Resources/{}/Condition", name)).copied().unwrap_or(resource_span);
3204 out.push(crate::make_parse_defect_for_resource(
3205 "E3001",
3206 format!("Resource '{}' property 'Condition' must be a string", name),
3207 cond_span,
3208 name,
3209 ));
3210 }
3211
3212 if let Some((_, dep_ref)) = entries.iter().find(|(k, _)| k == KEY_DEPENDS_ON) {
3214 match arena.node(*dep_ref) {
3215 Node::String(_) => {}
3216 Node::List(items) => {
3217 for item_ref in items {
3218 if !matches!(arena.node(*item_ref), Node::String(_)) {
3219 let dep_span =
3220 span_index.get(&format!("Resources/{}/DependsOn", name)).copied().unwrap_or(resource_span);
3221 out.push(crate::make_parse_defect_for_resource(
3222 "E3001",
3223 format!("Resource '{}' property 'DependsOn' list elements must be strings", name),
3224 dep_span,
3225 name,
3226 ));
3227 break;
3228 }
3229 }
3230 }
3231 _ => {
3232 let dep_span =
3233 span_index.get(&format!("Resources/{}/DependsOn", name)).copied().unwrap_or(resource_span);
3234 out.push(crate::make_parse_defect_for_resource(
3235 "E3001",
3236 format!("Resource '{}' property 'DependsOn' must be a string or list of strings", name),
3237 dep_span,
3238 name,
3239 ));
3240 }
3241 }
3242 }
3243
3244 let resource_type = entries.iter().find(|(key, _)| key == KEY_TYPE).and_then(|(_, value)| arena.as_str(*value));
3245 if let Some(resource_type) = resource_type {
3246 let is_custom_resource = is_custom_resource_type(resource_type);
3247 if let Some((_, version_ref)) = entries.iter().find(|(key, _)| key == "Version") {
3248 let version_span = span_index.get(&format!("Resources/{}/Version", name)).copied().unwrap_or(resource_span);
3249 if !is_custom_resource {
3250 out.push(crate::make_parse_defect_for_resource(
3251 "E3001",
3252 format!("Resource '{}' property 'Version' is only valid for custom resources", name),
3253 version_span,
3254 name,
3255 ));
3256 } else if !matches!(arena.node(*version_ref), Node::String(_) | Node::Int(_)) {
3257 out.push(crate::make_parse_defect_for_resource(
3258 "E3001",
3259 format!("Resource '{}' property 'Version' must be a string or integer", name),
3260 version_span,
3261 name,
3262 ));
3263 }
3264 }
3265
3266 for policy_name in [KEY_CREATION_POLICY, KEY_UPDATE_POLICY] {
3267 let Some((_, policy_ref)) = entries.iter().find(|(key, _)| key == policy_name) else {
3268 continue;
3269 };
3270 let policy_span =
3271 span_index.get(&format!("Resources/{}/{}", name, policy_name)).copied().unwrap_or(resource_span);
3272 if is_custom_resource {
3273 out.push(crate::make_parse_defect_for_resource(
3274 "E3001",
3275 format!("Resource '{}' property '{}' is not valid for custom resources", name, policy_name),
3276 policy_span,
3277 name,
3278 ));
3279 } else if !matches!(arena.node(*policy_ref), Node::Map(_) | Node::Intrinsic(_)) {
3280 out.push(crate::make_parse_defect_for_resource(
3281 "E3001",
3282 format!("Resource '{}' property '{}' must be an object", name, policy_name),
3283 policy_span,
3284 name,
3285 ));
3286 }
3287 }
3288 }
3289
3290 for (key, _) in entries {
3291 if VALID_RESOURCE_ATTRIBUTES.contains(&key.as_str()) {
3292 continue;
3293 }
3294 if is_sam && SAM_RESOURCE_ATTRIBUTES.contains(&key.as_str()) {
3295 continue;
3296 }
3297 let key_span = span_index.get(&format!("Resources/{}/{}", name, key)).copied().unwrap_or(resource_span);
3298 out.push(crate::make_parse_defect_for_resource(
3299 "E3001",
3300 format!(
3301 "Resource '{}' has invalid property '{}'. Valid resource attributes: {}",
3302 name,
3303 key,
3304 valid_attributes_display(is_sam),
3305 ),
3306 key_span,
3307 name,
3308 ));
3309 }
3310
3311 out
3312}
3313
3314fn valid_attributes_display(is_sam: bool) -> String {
3316 let mut attrs: Vec<&str> = VALID_RESOURCE_ATTRIBUTES.to_vec();
3317 if is_sam {
3318 attrs.extend_from_slice(SAM_RESOURCE_ATTRIBUTES);
3319 }
3320 attrs.join(", ")
3321}
3322
3323fn collect_lifecycle_attribute_status(
3324 arena: &Arena,
3325 node: NodeRef,
3326 conditions: &ConditionModel,
3327 allows_no_value_omission: bool,
3328 assumptions: &mut Vec<(String, bool)>,
3329 status: &mut LifecycleAttributeStatus,
3330) {
3331 if matches!(conditions.satisfiability(assumptions), Satisfiability::Unsatisfiable) {
3332 return;
3333 }
3334
3335 if !allows_no_value_omission {
3336 status.may_be_present = true;
3337 if matches!(
3338 arena.node(node),
3339 Node::Null | Node::Bool(_) | Node::Int(_) | Node::Float(_) | Node::String(_) | Node::List(_)
3340 ) && status.invalid_value.is_none()
3341 {
3342 status.invalid_value = Some(crate::message::render_value(&node_to_json(arena, node)));
3343 }
3344 return;
3345 }
3346
3347 match arena.node(node) {
3348 Node::Intrinsic(IntrinsicFn::Ref(target)) if target == PSEUDO_NO_VALUE => {}
3349 Node::Intrinsic(IntrinsicFn::If(condition, when_true, when_false)) => {
3350 assumptions.push((condition.clone(), true));
3351 collect_lifecycle_attribute_status(
3352 arena,
3353 *when_true,
3354 conditions,
3355 allows_no_value_omission,
3356 assumptions,
3357 status,
3358 );
3359 assumptions.pop();
3360 assumptions.push((condition.clone(), false));
3361 collect_lifecycle_attribute_status(
3362 arena,
3363 *when_false,
3364 conditions,
3365 allows_no_value_omission,
3366 assumptions,
3367 status,
3368 );
3369 assumptions.pop();
3370 }
3371 Node::Intrinsic(_) | Node::Map(_) => status.may_be_present = true,
3372 Node::Null | Node::Bool(_) | Node::Int(_) | Node::Float(_) | Node::String(_) | Node::List(_) => {
3373 status.may_be_present = true;
3374 if status.invalid_value.is_none() {
3375 status.invalid_value = Some(crate::message::render_value(&node_to_json(arena, node)));
3376 }
3377 }
3378 }
3379}
3380
3381fn resolve_resource(arena: &Arena, name: &str, node_ref: NodeRef, resolver: &mut Resolver) -> ResolvedResource {
3382 let entries = arena.as_map(node_ref).unwrap_or(&[]);
3383 let resource_type =
3384 entries.iter().find(|(k, _)| k == KEY_TYPE).and_then(|(_, v)| arena.as_str(*v)).unwrap_or("").to_string();
3385 let condition =
3386 entries.iter().find(|(k, _)| k == KEY_CONDITION).and_then(|(_, v)| arena.as_str(*v)).map(|s| s.to_string());
3387 let depends_on = entries
3388 .iter()
3389 .find(|(k, _)| k == KEY_DEPENDS_ON)
3390 .map(|(_, v)| match arena.node(*v) {
3391 Node::String(s) => vec![s.clone()],
3392 Node::List(items) => items.iter().filter_map(|r| arena.as_str(*r).map(|s| s.to_string())).collect(),
3393 _ => vec![],
3394 })
3395 .unwrap_or_default();
3396 let deletion_policy = entries.iter().find(|(key, _)| key == KEY_DELETION_POLICY).map(|(_, value)| {
3397 resolver.set_current_path(KEY_DELETION_POLICY);
3398 resolver.resolve_node(*value)
3399 });
3400 let update_replace_policy = entries.iter().find(|(key, _)| key == KEY_UPDATE_REPLACE_POLICY).map(|(_, value)| {
3401 resolver.set_current_path(KEY_UPDATE_REPLACE_POLICY);
3402 resolver.resolve_node(*value)
3403 });
3404 let metadata_ref = entries.iter().find(|(k, _)| k == SECTION_METADATA).map(|(_, v)| *v);
3405 let metadata = metadata_ref.map(|v| node_to_json(arena, v));
3406 let update_policy = entries.iter().find(|(k, _)| k == KEY_UPDATE_POLICY).map(|(_, v)| node_to_json(arena, *v));
3407 let creation_policy = entries.iter().find(|(k, _)| k == KEY_CREATION_POLICY).map(|(_, v)| node_to_json(arena, *v));
3408
3409 let resolved_metadata = if let Some(meta_ref) = metadata_ref {
3410 resolver.set_current_path(SECTION_METADATA);
3411 Some(resolver.resolve_node(meta_ref))
3412 } else {
3413 None
3414 };
3415
3416 let mut properties = HashMap::new();
3417 let mut properties_dynamic = entries.iter().any(|(key, _)| key == FN_TRANSFORM);
3418 if let Some((_, props_ref)) = entries.iter().find(|(k, _)| k == KEY_PROPERTIES) {
3419 if let Some(prop_entries) = arena.as_map(*props_ref) {
3420 for (key, val_ref) in prop_entries {
3421 resolver.set_current_path(&format!("Properties.{}", key));
3422 properties.insert(key.clone(), resolver.resolve_node(*val_ref));
3423 }
3424 } else if let Some(synthetic_key) = intrinsic_synthetic_key(arena, *props_ref) {
3425 resolver.set_current_path(KEY_PROPERTIES);
3433 properties.insert(synthetic_key, resolver.resolve_node(*props_ref));
3434 } else if matches!(
3435 arena.node(*props_ref),
3436 Node::Intrinsic(IntrinsicFn::Ref(target)) if target == PSEUDO_NO_VALUE
3437 ) && !properties_dynamic
3438 {
3439 resolver.set_current_path(KEY_PROPERTIES);
3442 resolver.resolve_node(*props_ref);
3443 properties_dynamic = true;
3444 } else if matches!(arena.node(*props_ref), Node::Intrinsic(_)) {
3445 properties_dynamic = true;
3448 }
3449 }
3450
3451 let mut conditionally_null_props = Vec::new();
3452 for (key, val) in &properties {
3453 collect_conditional_nulls(val, key, &mut conditionally_null_props);
3454 }
3455
3456 let mut condition_refs = Vec::new();
3457 for v in properties.values() {
3458 collect_condition_refs_from_resolved(v, &mut condition_refs);
3459 }
3460 if let Some(ref meta_resolved) = resolved_metadata {
3461 collect_condition_refs_from_resolved(meta_resolved, &mut condition_refs);
3462 }
3463 if let Some(ref dp) = deletion_policy {
3464 collect_condition_refs_from_resolved(dp, &mut condition_refs);
3465 }
3466 if let Some(ref urp) = update_replace_policy {
3467 collect_condition_refs_from_resolved(urp, &mut condition_refs);
3468 }
3469 if let Some(mut extra) = resolver.extra_condition_refs.remove(name) {
3470 condition_refs.append(&mut extra);
3471 }
3472 condition_refs.sort();
3473 condition_refs.dedup();
3474
3475 ResolvedResource {
3476 logical_id: name.to_string(),
3477 resource_type,
3478 condition,
3479 depends_on,
3480 deletion_policy,
3481 update_replace_policy,
3482 update_policy: update_policy.map(JsonValue),
3483 creation_policy: creation_policy.map(JsonValue),
3484 metadata: metadata.map(JsonValue),
3485 properties,
3486 properties_dynamic,
3487 diagnostics: ResourceDiagnostics {
3488 find_in_map_refs: resolver.find_in_map_refs.remove(name).unwrap_or_default(),
3489 simple_subs: resolver
3490 .simple_subs
3491 .remove(name)
3492 .unwrap_or_default()
3493 .into_iter()
3494 .map(|(a, b)| PathValuePair { path: a, value: b })
3495 .collect(),
3496 redundant_subs: resolver.redundant_subs.remove(name).unwrap_or_default(),
3497 empty_joins: resolver.empty_joins.remove(name).unwrap_or_default(),
3498 hardcoded_partition_arns: collapse_list_sibling_arn_paths(
3499 resolver.hardcoded_partition_arns.remove(name).unwrap_or_default(),
3500 ),
3501 foreach_expansions: resolver
3502 .foreach_expansions
3503 .remove(name)
3504 .unwrap_or_default()
3505 .into_iter()
3506 .map(|(path, ident, coll)| ForEachExpansion {
3507 property_path: path,
3508 identifier: ident,
3509 collection_source: coll,
3510 })
3511 .collect(),
3512 unsubstituted_variables: resolver
3513 .unsubstituted_variables
3514 .remove(name)
3515 .unwrap_or_default()
3516 .into_iter()
3517 .map(|(a, b)| PathValuePair { path: a, value: b })
3518 .collect(),
3519 unused_sub_keys: resolver
3520 .unused_sub_keys
3521 .remove(name)
3522 .unwrap_or_default()
3523 .into_iter()
3524 .map(|(a, b)| PathValuePair { path: a, value: b })
3525 .collect(),
3526 raw_pseudo_params: resolver
3527 .raw_pseudo_params
3528 .remove(name)
3529 .unwrap_or_default()
3530 .into_iter()
3531 .map(|(a, b)| PathValuePair { path: a, value: b })
3532 .collect(),
3533 secretsmanager_ref_paths: resolver.secretsmanager_ref_paths.remove(name).unwrap_or_default(),
3534 invalid_refs: resolver
3535 .invalid_refs
3536 .remove(name)
3537 .unwrap_or_default()
3538 .into_iter()
3539 .map(|(a, b)| PathValuePair { path: a, value: b })
3540 .collect(),
3541 conditionally_null_props: conditionally_null_props
3542 .into_iter()
3543 .map(|(a, b, c)| ConditionalNullEntry { path: a, condition: b, null_in_true_branch: c })
3544 .collect(),
3545 condition_refs,
3546 },
3547 }
3548}
3549
3550fn collapse_list_sibling_arn_paths(paths: Vec<String>) -> Vec<String> {
3559 let mut min_index: HashMap<String, usize> = HashMap::new();
3563 let mut ungrouped: Vec<String> = Vec::new();
3564 for path in &paths {
3565 match split_trailing_list_index(path) {
3566 Some((prefix, idx, suffix)) => {
3567 let key = format!("{}\u{0}{}", prefix, suffix);
3568 min_index.entry(key).and_modify(|m| *m = (*m).min(idx)).or_insert(idx);
3569 }
3570 None => ungrouped.push(path.clone()),
3571 }
3572 }
3573 let mut out = ungrouped;
3574 for (key, idx) in min_index {
3575 let (prefix, suffix) = key.split_once('\u{0}').unwrap_or((key.as_str(), ""));
3576 out.push(format!("{}{}.{}", prefix, idx, suffix));
3577 }
3578 out.sort();
3579 out
3580}
3581
3582fn split_trailing_list_index(path: &str) -> Option<(String, usize, String)> {
3585 let suffix = "Fn::Sub";
3586 let stem = path.strip_suffix(suffix)?.strip_suffix('.')?;
3587 let (head, last) = stem.rsplit_once('.')?;
3588 let idx: usize = last.parse().ok()?;
3589 Some((format!("{}.", head), idx, suffix.to_string()))
3590}
3591
3592fn check_output_value_is_string(arena: &Arena, value_ref: NodeRef, output_name: &str, resolver: &mut Resolver) {
3603 let build_path = format!("Outputs/{}/Value", output_name);
3604 check_output_value_node(arena, value_ref, output_name, &build_path, resolver);
3605}
3606
3607fn check_output_value_node(
3613 arena: &Arena,
3614 value_ref: NodeRef,
3615 output_name: &str,
3616 build_path: &str,
3617 resolver: &mut Resolver,
3618) {
3619 let span = arena.span(value_ref);
3620 match arena.node(value_ref) {
3621 Node::List(items) if !items.is_empty() => {
3622 resolver.diagnostics.push(crate::make_parse_defect_at(
3623 "F6101",
3624 format!("Output '{}' value must be a string, not a list", output_name),
3625 span,
3626 build_path,
3627 ));
3628 }
3629 Node::Map(entries) if !entries.is_empty() => {
3630 resolver.diagnostics.push(crate::make_parse_defect_at(
3631 "F6101",
3632 format!("Output '{}' value must be a string, not an object", output_name),
3633 span,
3634 build_path,
3635 ));
3636 }
3637 Node::Intrinsic(IntrinsicFn::If(_, if_true, if_false) | IntrinsicFn::IfExpr(_, if_true, if_false)) => {
3638 let (if_true, if_false) = (*if_true, *if_false);
3639 check_output_value_node(arena, if_true, output_name, &format!("{}/{}/1", build_path, FN_IF), resolver);
3640 check_output_value_node(arena, if_false, output_name, &format!("{}/{}/2", build_path, FN_IF), resolver);
3641 }
3642 Node::Intrinsic(intrinsic) if returns_list(intrinsic) => {
3643 resolver.diagnostics.push(crate::make_parse_defect_at(
3644 "F6101",
3645 format!("Output '{}' value must be a string, not a list", output_name),
3646 span,
3647 build_path,
3648 ));
3649 }
3650 _ => {}
3651 }
3652}
3653
3654fn returns_list(intrinsic: &IntrinsicFn) -> bool {
3659 matches!(intrinsic, IntrinsicFn::GetAZs(_) | IntrinsicFn::Split(_, _) | IntrinsicFn::Cidr(_, _, _))
3660}
3661
3662fn resolve_output(arena: &Arena, node_ref: NodeRef, resolver: &mut Resolver) -> ResolvedOutput {
3663 let entries = arena.as_map(node_ref).unwrap_or(&[]);
3664
3665 const VALID_OUTPUT_KEYS: &[&str] = &[KEY_VALUE, SECTION_DESCRIPTION, KEY_CONDITION, KEY_EXPORT];
3667 let output_name = resolver.current_resource.as_deref().unwrap_or("");
3668 let display_name = output_name.strip_prefix(OUTPUT_PSEUDO_RESOURCE_PREFIX).unwrap_or(output_name);
3669 for (key, _) in entries {
3670 if !VALID_OUTPUT_KEYS.contains(&key.as_str()) {
3671 resolver.diagnostics.push(crate::make_parse_defect(
3672 "E6001",
3673 format!(
3674 "Output '{}' has invalid property '{}'. Valid properties: Value, Description, Condition, Export",
3675 display_name, key
3676 ),
3677 crate::ir::UNKNOWN_SPAN,
3678 ));
3679 }
3680 }
3681
3682 if let Some((_, value_ref)) = entries.iter().find(|(k, _)| k == KEY_VALUE) {
3683 let value_ref = *value_ref;
3684 let name = display_name.to_string();
3685 check_output_value_is_string(arena, value_ref, &name, resolver);
3686 }
3687
3688 let value = entries
3689 .iter()
3690 .find(|(k, _)| k == KEY_VALUE)
3691 .map(|(_, v)| resolver.resolve_node(*v))
3692 .unwrap_or(ResolvedValue::Dynamic { reason: "missing output value".into() });
3693 let description = entries
3694 .iter()
3695 .find(|(k, _)| k == SECTION_DESCRIPTION)
3696 .and_then(|(_, v)| arena.as_str(*v))
3697 .map(|s| s.to_string());
3698 let condition =
3699 entries.iter().find(|(k, _)| k == KEY_CONDITION).and_then(|(_, v)| arena.as_str(*v)).map(|s| s.to_string());
3700 let export_name = entries
3701 .iter()
3702 .find(|(k, _)| k == KEY_EXPORT)
3703 .and_then(|(_, v)| arena.as_map(*v))
3704 .and_then(|m| m.iter().find(|(k, _)| k == KEY_NAME))
3705 .map(|(_, v)| resolver.resolve_node(*v));
3706 ResolvedOutput { value, description, condition, export_name }
3707}
3708
3709#[cfg(test)]
3710mod tests {
3711 use super::*;
3712
3713 #[test]
3714 fn invalid_overrides_reports_nothing_when_unset_or_valid() {
3715 assert!(PseudoParameterOverrides::default().invalid_overrides().is_empty(), "unset overrides are valid");
3716 let valid = PseudoParameterOverrides {
3717 account_id: Some("123456789012".to_string()),
3718 partition: Some("aws-cn".to_string()),
3719 ..Default::default()
3720 };
3721 assert!(valid.invalid_overrides().is_empty(), "well-formed overrides produce no warnings");
3722 }
3723
3724 #[test]
3725 fn invalid_overrides_reports_each_malformed_provided_value() {
3726 let bad = PseudoParameterOverrides {
3727 account_id: Some("unknown-account".to_string()),
3728 partition: Some("gcp".to_string()),
3729 ..Default::default()
3730 };
3731 let problems = bad.invalid_overrides();
3732 assert_eq!(problems.len(), 2, "one entry per invalid provided field");
3733 assert!(problems.iter().any(|p| p.contains("AccountId") && p.contains("unknown-account")));
3734 assert!(problems.iter().any(|p| p.contains("Partition") && p.contains("gcp")));
3735 }
3736
3737 #[test]
3738 fn invalid_overrides_only_checks_account_id_and_partition() {
3739 let only_region = PseudoParameterOverrides { region: Some("not a region".to_string()), ..Default::default() };
3740 assert!(only_region.invalid_overrides().is_empty());
3741 }
3742
3743 #[test]
3744 fn account_id_shape_requires_exactly_twelve_digits() {
3745 assert!(is_account_id_shaped("123456789012"));
3746 assert!(!is_account_id_shaped("12345678901"));
3747 assert!(!is_account_id_shaped("1234567890123"));
3748 assert!(!is_account_id_shaped("12345678901a"));
3749 assert!(!is_account_id_shaped(""));
3750 }
3751
3752 #[test]
3753 fn partition_shape_accepts_aws_family_and_rejects_others() {
3754 for ok in ["aws", "aws-cn", "aws-us-gov", "aws-iso", "aws-iso-b"] {
3755 assert!(is_partition_shaped(ok), "{ok} should be accepted");
3756 }
3757 for bad in ["gcp", "AWS", "azure", ""] {
3758 assert!(!is_partition_shaped(bad), "{bad} should be rejected");
3759 }
3760 }
3761
3762 #[test]
3763 fn model_from_simple_template() {
3764 let input = r#"
3765AWSTemplateFormatVersion: "2010-09-09"
3766Resources:
3767 MyBucket:
3768 Type: AWS::S3::Bucket
3769 Properties:
3770 BucketName: my-bucket
3771 MyBucket2:
3772 Type: AWS::S3::Bucket
3773 Properties:
3774 BucketName: my-bucket-2
3775"#;
3776 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3777 assert_eq!(model.resources.len(), 2);
3778 assert_eq!(model.resources_of_type("AWS::S3::Bucket").len(), 2);
3779 assert_eq!(model.resources_of_type("AWS::Fake::Thing").len(), 0);
3780 }
3781
3782 fn intrinsic_source_probe_model() -> SemanticModel {
3788 let input = r#"
3789Resources:
3790 Other:
3791 Type: AWS::S3::Bucket
3792 Properties:
3793 BucketName: other-bucket
3794 R:
3795 Type: AWS::SNS::Topic
3796 Properties:
3797 LiteralProp: plain-literal
3798 RefProp: !Ref Other
3799 GetAttProp: !GetAtt Other.Arn
3800 JoinProp: !Join ["-", ["a", "b"]]
3801 NestedRef:
3802 Inner: !Ref Other
3803 IfR:
3804 Type: AWS::SNS::Topic
3805 Condition: MakeIt
3806 Properties:
3807 Fn::If:
3808 - MakeIt
3809 - TopicName: !Ref Other
3810 DisplayName: literal-in-true-branch
3811 BranchLiteral: literal-one
3812 - TopicName: plain-literal-two
3813 DisplayName: !Ref Other
3814 BranchLiteral: literal-two
3815Conditions:
3816 MakeIt: !Equals ["a", "a"]
3817"#;
3818 SemanticModel::from_bytes(input.as_bytes()).unwrap()
3819 }
3820
3821 #[test]
3822 fn is_from_intrinsic_separates_intrinsic_values_from_literals() {
3823 let model = intrinsic_source_probe_model();
3824 assert!(model.is_from_intrinsic("R", "Properties.RefProp"), "a Ref value is intrinsic-sourced");
3825 assert!(model.is_from_intrinsic("R", "Properties.GetAttProp"), "a GetAtt value is intrinsic-sourced");
3826 assert!(
3827 model.is_from_intrinsic("R", "Properties.JoinProp"),
3828 "an Fn::Join value keeps its Intrinsic/* source even when it resolves concretely"
3829 );
3830 assert!(!model.is_from_intrinsic("R", "Properties.LiteralProp"), "a plain literal is not intrinsic-sourced");
3831 }
3832
3833 #[test]
3834 fn is_from_intrinsic_inherits_from_intrinsic_ancestors() {
3835 let model = intrinsic_source_probe_model();
3836 assert!(model.is_from_intrinsic("R", "Properties.NestedRef.Inner"));
3839 assert!(model.is_from_intrinsic("R", "Properties.NestedRef.Inner.Deeper"));
3840 assert!(!model.is_from_intrinsic("R", "Properties.NestedRef"));
3842 assert!(!model.is_from_intrinsic("Missing", "Properties.Anything"));
3844 }
3845
3846 #[test]
3847 fn is_from_intrinsic_attributes_fn_if_branch_sources_to_the_bare_path() {
3848 let model = intrinsic_source_probe_model();
3849 assert!(model.is_from_intrinsic("IfR", "Properties.TopicName"));
3852 assert!(model.is_from_intrinsic("IfR", "Properties.DisplayName"));
3854 assert!(!model.is_from_intrinsic("IfR", "Properties.BranchLiteral"));
3856 }
3857
3858 #[test]
3859 fn diagnostic_span_resolves_dotted_resource_property_path() {
3860 let input = r#"
3861Resources:
3862 MyBucket:
3863 Type: AWS::S3::Bucket
3864 Properties:
3865 BucketName: my-bucket
3866"#;
3867 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3868 let via_diag = model.diagnostic_span(Some("MyBucket"), "Properties.BucketName").expect("should resolve");
3871 let expected = model.source_location("Resources/MyBucket/Properties/BucketName").copied().expect("indexed");
3872 assert_eq!(via_diag, expected, "dotted path should resolve to the exact property span");
3873 }
3874
3875 #[test]
3876 fn diagnostic_span_resolves_bare_top_level_path_without_resource() {
3877 let input = r#"
3878BogusSection:
3879 Value: invalid
3880Resources:
3881 MyBucket:
3882 Type: AWS::S3::Bucket
3883"#;
3884 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3885 let via_diag = model.diagnostic_span(None, "BogusSection").expect("should resolve");
3886 let expected = model.source_location("BogusSection").copied().expect("indexed");
3887 assert_eq!(via_diag, expected, "bare top-level path should resolve to its authored key");
3888 }
3889
3890 #[test]
3891 fn diagnostic_span_resource_relative_path_never_matches_top_level_section() {
3892 let input = r#"
3895Metadata:
3896 AWS::CloudFormation::Interface:
3897 ParameterGroups: []
3898Resources:
3899 MyBucket:
3900 Type: AWS::S3::Bucket
3901 Metadata:
3902 cfn_nag: skip
3903"#;
3904 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3905 let via_diag = model.diagnostic_span(Some("MyBucket"), "Metadata").expect("should resolve");
3906 let resource_meta = model.source_location("Resources/MyBucket/Metadata").copied().expect("indexed");
3907 let top_level_meta = model.source_location("Metadata").copied().expect("indexed");
3908 assert_eq!(via_diag, resource_meta, "resource-relative Metadata must anchor at the resource");
3909 assert_ne!(via_diag, top_level_meta, "must not mislocate onto the top-level Metadata section");
3910 }
3911
3912 #[test]
3913 fn diagnostic_span_resolves_absolute_section_rooted_path() {
3914 let input = r#"
3915Resources:
3916 MyBucket:
3917 Type: AWS::S3::Bucket
3918Outputs:
3919 BucketRef:
3920 Value: !Ref MyBucket
3921"#;
3922 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3923 let span = model.diagnostic_span(Some("BucketRef"), "Outputs/BucketRef/Value");
3926 assert!(span.is_some() && span != Some(UNKNOWN_SPAN), "output path should resolve to a real span");
3927 }
3928
3929 #[test]
3930 fn diagnostic_span_walks_up_when_leaf_node_is_unindexed() {
3931 let input = r#"
3932Resources:
3933 MyBucket:
3934 Type: AWS::S3::Bucket
3935 Properties:
3936 BucketName: my-bucket
3937"#;
3938 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3939 let span = model.diagnostic_span(Some("MyBucket"), "Properties.BucketName.DoesNotExist.Deeper");
3942 assert!(span.is_some() && span != Some(UNKNOWN_SPAN), "unindexed leaf should walk up to a real span");
3943 }
3944
3945 #[test]
3946 fn diagnostic_span_returns_none_when_nothing_resolvable() {
3947 let input = r#"
3948Resources:
3949 MyBucket:
3950 Type: AWS::S3::Bucket
3951"#;
3952 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3953 assert_eq!(model.diagnostic_span(None, ""), None, "no resource and no path yields no span");
3955 assert_eq!(
3958 model.diagnostic_span(None, "NoSuchSection/Deep/Path"),
3959 None,
3960 "an unindexed absolute path yields no span"
3961 );
3962 }
3963
3964 #[test]
3965 fn model_resolve_property() {
3966 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
3967 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3968 match model.resolve("R", "Properties.Name").unwrap() {
3969 ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "hello"),
3970 other => panic!("Expected Concrete, got {:?}", other),
3971 }
3972 }
3973
3974 #[test]
3975 fn model_follow_ref() {
3976 let input = r#"{"Resources":{"Svc":{"Type":"T","Properties":{"TaskDef":{"Ref":"TD"}}},"TD":{"Type":"T2"}}}"#;
3977 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3978 assert_eq!(model.follow_ref("Svc", "Properties.TaskDef"), Some("TD"));
3979 }
3980
3981 #[test]
3982 fn model_to_diagnostic_json() {
3983 let input = r#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","Properties":{"Name":"test"}}}}"#;
3984 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3985 let json = serde_json::to_value(model.to_diagnostic_json()).unwrap();
3986 assert_ne!(json.get("resources"), None, "expected 'resources' key in diagnostic JSON");
3987 assert_ne!(json["resources"].get("R"), None, "expected resource 'R' in diagnostic JSON");
3988 }
3989
3990 #[test]
3991 fn model_send_sync() {
3992 fn assert_send_sync<T: Send + Sync>() {}
3993 assert_send_sync::<SemanticModel>();
3994 }
3995
3996 #[test]
3997 fn model_with_conditions() {
3998 let input = r#"
3999Parameters:
4000 Env:
4001 Type: String
4002 AllowedValues: [Prod, Dev]
4003Conditions:
4004 IsProd:
4005 Fn::Equals: [!Ref Env, Prod]
4006Resources:
4007 R:
4008 Type: T
4009 Condition: IsProd
4010 Properties:
4011 Size:
4012 Fn::If: [IsProd, 100, 20]
4013"#;
4014 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4015 assert_eq!(model.conditions.conditions.len(), 1);
4016 let r = model.resource("R").unwrap();
4017 assert_eq!(r.condition.as_deref(), Some("IsProd"));
4018 assert!(matches!(
4019 r.properties.get("Size"),
4020 Some(ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ })
4021 ));
4022 }
4023
4024 #[test]
4025 fn model_with_outputs() {
4026 let input =
4027 "Resources:\n R:\n Type: T\nOutputs:\n Out1:\n Value: !Ref R\n Description: test output\n";
4028 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4029 assert_eq!(model.outputs.len(), 1);
4030 assert!(model.outputs.contains_key("Out1"));
4031 }
4032
4033 #[test]
4034 fn resolve_deep_nested_object() {
4035 let input =
4036 r#"{"Resources":{"R":{"Type":"T","Properties":{"Config":{"SubKey":"value","Nested":{"Deep":"found"}}}}}}"#;
4037 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4038 match model.resolve_deep("R", "Properties.Config.SubKey") {
4039 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "value"),
4040 other => panic!("Expected Concrete, got {:?}", other),
4041 }
4042 match model.resolve_deep("R", "Properties.Config.Nested.Deep") {
4043 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "found"),
4044 other => panic!("Expected Concrete, got {:?}", other),
4045 }
4046 }
4047
4048 #[test]
4049 fn resolve_deep_array_index() {
4050 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Tags":[{"Key":"Env","Value":"prod"},{"Key":"App","Value":"web"}]}}}}"#;
4051 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4052 match model.resolve_deep("R", "Properties.Tags.0.Key") {
4053 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "Env"),
4054 other => panic!("Expected Concrete, got {:?}", other),
4055 }
4056 }
4057
4058 #[test]
4059 fn resolve_deep_out_of_bounds() {
4060 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Tags":[{"Key":"A"}]}}}}"#;
4061 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4062 assert!(model.resolve_deep("R", "Properties.Tags.5.Key").is_none(), "out-of-bounds index should return None");
4063 }
4064
4065 #[test]
4066 fn resolve_deep_missing_intermediate() {
4067 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
4068 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4069 assert!(model.resolve_deep("R", "Properties.NonExistent.Sub").is_none());
4070 }
4071
4072 #[test]
4073 fn resolve_deep_top_level_still_works() {
4074 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
4075 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4076 match model.resolve_deep("R", "Properties.Name") {
4077 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "hello"),
4078 other => panic!("Expected Concrete, got {:?}", other),
4079 }
4080 }
4081
4082 #[test]
4083 fn resolve_unexpanded_preserves_whole_properties_and_nested_conditions_without_spending_budget() {
4084 let input = br#"{
4085 "Parameters": {"Mode": {"Type": "String"}},
4086 "Conditions": {
4087 "ChooseProperties": {"Fn::Equals": [{"Ref": "Mode"}, "properties"]},
4088 "ChooseValue": {"Fn::Equals": [{"Ref": "Mode"}, "value"]}
4089 },
4090 "Resources": {
4091 "Direct": {"Type": "T", "Properties": {"Values": ["direct"]}},
4092 "Conditional": {"Type": "T", "Properties": {
4093 "Fn::If": [
4094 "ChooseProperties",
4095 {"Values": [{"Fn::If": ["ChooseValue", "first", "second"]}]},
4096 {"Values": ["fallback"]}
4097 ]
4098 }}
4099 }
4100 }"#;
4101 let model = SemanticModel::from_bytes(input).unwrap();
4102 let direct = model.resolve_unexpanded("Direct", "Properties.Values").expect("direct value");
4103 let direct_deep = model.resolve_deep("Direct", "Properties.Values").expect("direct deep value");
4104 assert_eq!(resolved_value_to_json(&direct), serde_json::json!(["direct"]));
4105 assert_eq!(resolved_value_to_json(&direct_deep), serde_json::json!(["direct"]));
4106
4107 let conditional = model
4108 .resolve_unexpanded("Conditional", "Properties.Values")
4109 .expect("value inside whole-Properties condition");
4110 let ResolvedValue::Conditional { condition, if_true, if_false } = conditional else {
4111 panic!("expected the whole-Properties condition");
4112 };
4113 assert_eq!(condition, "ChooseProperties");
4114 let ResolvedValue::List { items: true_items } = *if_true else {
4115 panic!("expected true-branch list");
4116 };
4117 assert!(
4118 matches!(true_items.as_slice(), [ResolvedValue::Conditional { condition, .. }] if condition == "ChooseValue")
4119 );
4120 assert_eq!(resolved_value_to_json(if_false.as_ref()), serde_json::json!(["fallback"]));
4121 assert_eq!(model.scenario_combinations_used(), 0, "an unexpanded lookup must not materialize scenarios");
4122 }
4123
4124 #[test]
4125 fn list_member_scenarios_expand_members_independently_and_reuse_cache() {
4126 const MEMBER_COUNT: usize = 13;
4127 let mut parameters = serde_json::Map::new();
4128 let mut conditions = serde_json::Map::new();
4129 let mut members = Vec::new();
4130 for index in 0..MEMBER_COUNT {
4131 let parameter = format!("Toggle{index:02}");
4132 let condition = format!("Enabled{index:02}");
4133 parameters.insert(parameter.clone(), serde_json::json!({"Type": "String"}));
4134 conditions.insert(condition.clone(), serde_json::json!({"Fn::Equals": [{"Ref": parameter}, "true"]}));
4135 members.push(serde_json::json!({
4136 "Fn::If": [condition, format!("value-{index:02}"), {"Ref": "AWS::NoValue"}]
4137 }));
4138 }
4139 let template = serde_json::json!({
4140 "Parameters": parameters,
4141 "Conditions": conditions,
4142 "Resources": {"R": {"Type": "T", "Properties": {"Values": members}}}
4143 })
4144 .to_string();
4145 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
4146
4147 let first = model.resolve_list_member_scenarios_shared("R", "Properties.Values");
4148 let combinations_after_first = model.scenario_combinations_used();
4149 let second = model.resolve_list_member_scenarios_shared("R", "Properties.Values");
4150
4151 assert_eq!(first.len(), MEMBER_COUNT * 2, "each member contributes only its own two branches");
4152 for index in 0..MEMBER_COUNT {
4153 let member_scenarios: Vec<&ListMemberScenario> =
4154 first.iter().filter(|scenario| scenario.index == index).collect();
4155 assert_eq!(member_scenarios.len(), 2, "member {index} must retain both branches");
4156 assert!(member_scenarios.iter().all(|scenario| scenario.container_scenario == 0));
4157 assert!(member_scenarios.iter().all(|scenario| scenario.container_conditions.is_empty()));
4158 assert!(member_scenarios.iter().all(|scenario| scenario.conditions.len() == 1));
4159 }
4160 assert_eq!(combinations_after_first, 0, "member extraction must not materialize joint scenarios");
4161 assert!(Arc::ptr_eq(&first, &second), "repeated member queries must share their cached allocation");
4162 assert_eq!(model.scenario_combinations_used(), combinations_after_first);
4163 }
4164
4165 #[test]
4166 fn list_member_scenarios_keep_outer_enum_alternatives_separate() {
4167 let model = SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap();
4168 let value = ResolvedValue::Enum {
4169 variants: vec![
4170 ResolvedValue::Concrete { value: serde_json::json!(["left", null]).into() },
4171 ResolvedValue::Concrete { value: serde_json::json!([null, "right"]).into() },
4172 ],
4173 };
4174 let mut next_container_scenario = 0;
4175 let mut member_scenarios = Vec::new();
4176
4177 model.collect_list_member_scenarios(
4178 &value,
4179 &HashMap::new(),
4180 &mut next_container_scenario,
4181 &mut member_scenarios,
4182 );
4183
4184 assert_eq!(next_container_scenario, 2);
4185 assert_eq!(member_scenarios.len(), 4);
4186 for container_scenario in 0..2 {
4187 let members: Vec<&ListMemberScenario> =
4188 member_scenarios.iter().filter(|scenario| scenario.container_scenario == container_scenario).collect();
4189 assert_eq!(members.len(), 2);
4190 assert_eq!(members.iter().filter(|scenario| !is_resolved_null(&scenario.value)).count(), 1);
4191 }
4192 }
4193
4194 #[test]
4195 fn list_member_pair_source_paths_collapse_value_branches() {
4196 let input = br#"{
4197 "Parameters": {"Mode": {"Type": "String"}},
4198 "Conditions": {"ChooseFirst": {"Fn::Equals": [{"Ref": "Mode"}, "first"]}},
4199 "Resources": {
4200 "Invalid": {"Type": "T", "Properties": {
4201 "Values": {"Fn::If": ["ChooseFirst", ["a", "b"], ["c", "d"]]}
4202 }},
4203 "Valid": {"Type": "T", "Properties": {
4204 "Values": {"Fn::If": ["ChooseFirst", ["a"], ["b"]]}
4205 }}
4206 }
4207 }"#;
4208 let model = SemanticModel::from_bytes(input).unwrap();
4209
4210 assert_eq!(
4211 model.list_member_pair_source_paths("Invalid", "Properties.Values", &HashMap::new()),
4212 BTreeSet::from(["Properties.Values".to_string()])
4213 );
4214 assert!(
4215 model.list_member_pair_source_paths("Valid", "Properties.Values", &HashMap::new()).is_empty(),
4216 "members from mutually exclusive one-item branches must not form a pair"
4217 );
4218 }
4219
4220 #[test]
4221 fn model_findinmap_refs_tracked() {
4222 let input = br#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["MyMap","k1","k2"]}}}}}"#;
4223 let model = SemanticModel::from_bytes(input).unwrap();
4224 assert!(model.resource("R").unwrap().diagnostics.find_in_map_refs.contains(&"MyMap".to_string()));
4225 }
4226
4227 #[test]
4228 fn model_findinmap_refs_tracked_yaml() {
4229 let input = b"Resources:\n R:\n Type: T\n Properties:\n V: !FindInMap [MyMap, k1, k2]\n";
4230 let model = SemanticModel::from_bytes(input).unwrap();
4231 assert!(model.resource("R").unwrap().diagnostics.find_in_map_refs.contains(&"MyMap".to_string()));
4232 }
4233
4234 #[test]
4235 fn model_to_diagnostic_json_has_outputs_and_mappings() {
4236 let input = b"AWSTemplateFormatVersion: '2010-09-09'\nMappings:\n M:\n k1:\n k2: val\nResources:\n R:\n Type: AWS::S3::Bucket\nOutputs:\n Out:\n Value: !Ref R\n";
4237 let model = SemanticModel::from_bytes(input).unwrap();
4238 let json = serde_json::to_value(model.to_diagnostic_json()).unwrap();
4239 assert_ne!(json.get("outputs"), None, "expected 'outputs' key in diagnostic JSON");
4240 assert_ne!(json.get("mappings"), None, "expected 'mappings' key in diagnostic JSON");
4241 assert_eq!(json["mappings"]["M"]["k1"]["k2"], "val");
4242 }
4243
4244 #[test]
4245 fn resolve_deep_into_reference() {
4246 let input = r#"{"Resources":{"Svc":{"Type":"T","Properties":{"TaskDef":{"Ref":"TD"}}},"TD":{"Type":"T2"}}}"#;
4247 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4248 assert!(
4249 model.resolve_deep("Svc", "Properties.TaskDef.Something").is_none(),
4250 "path through Reference should return None"
4251 );
4252 match model.resolve_deep("Svc", "Properties.TaskDef") {
4253 Some(ResolvedValue::Reference { target, kind: _ }) => assert_eq!(target, "TD"),
4254 other => panic!("Expected Reference, got {:?}", other),
4255 }
4256 }
4257
4258 #[test]
4259 fn scenario_source_path_maps_effective_indexes_to_authored_branches() {
4260 let input = br#"{
4261 "Parameters": {"Mode": {"Type": "String"}},
4262 "Conditions": {"ChooseFirst": {"Fn::Equals": [{"Ref": "Mode"}, "first"]}},
4263 "Resources": {"R": {"Type": "T", "Properties": {
4264 "Values": {"Fn::If": ["ChooseFirst", ["bad"], ["good"]]}
4265 }}}
4266 }"#;
4267 let model = SemanticModel::from_bytes(input).unwrap();
4268 let true_conditions = HashMap::from([("ChooseFirst".to_string(), true)]);
4269 let false_conditions = HashMap::from([("ChooseFirst".to_string(), false)]);
4270 assert_eq!(
4271 model.scenario_source_path("R", "Properties.Values.0", &true_conditions),
4272 Some("Properties.Values.Fn::If.1.0".to_string())
4273 );
4274 assert_eq!(
4275 model.scenario_source_path("R", "Properties.Values.0", &false_conditions),
4276 Some("Properties.Values.Fn::If.2.0".to_string())
4277 );
4278 }
4279
4280 #[test]
4281 fn resolve_scenarios_json_filters_dynamic() {
4282 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":"Stack"}}}}}"#;
4283 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4284 let scenarios = model.resolve_scenarios_json("R", "Properties.V");
4285 assert!(scenarios.is_empty(), "ImportValue should be filtered as dynamic");
4286 }
4287
4288 #[test]
4289 fn resolve_scenarios_json_returns_concrete() {
4290 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
4291 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4292 let scenarios = model.resolve_scenarios_json("R", "Properties.V");
4293 assert_eq!(scenarios.len(), 1);
4294 assert_eq!(scenarios[0].0, serde_json::json!("hello"));
4295 }
4296
4297 #[test]
4298 fn resolve_scenarios_json_memoized() {
4299 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
4300 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4301 let s1 = model.resolve_scenarios_json("R", "Properties.V");
4302 let s2 = model.resolve_scenarios_json("R", "Properties.V");
4303 assert_eq!(s1, s2);
4304 }
4305
4306 const CONDITIONAL_PROPERTY_TEMPLATE: &[u8] = br#"{
4307 "Parameters": {"Mode": {"Type": "String"}},
4308 "Conditions": {"ChooseFirst": {"Fn::Equals": [{"Ref": "Mode"}, "first"]}},
4309 "Resources": {"R": {"Type": "T", "Properties": {
4310 "V": {"Fn::If": ["ChooseFirst", "a", "b"]}
4311 }}}
4312 }"#;
4313
4314 #[test]
4315 fn resolve_scenarios_json_shared_reuses_allocation_and_matches_owned() {
4316 let model = SemanticModel::from_bytes(CONDITIONAL_PROPERTY_TEMPLATE).unwrap();
4317
4318 let first = model.resolve_scenarios_json_shared("R", "Properties.V");
4319 let combinations_after_first = model.scenario_combinations_used();
4320 let second = model.resolve_scenarios_json_shared("R", "Properties.V");
4321 let owned = model.resolve_scenarios_json("R", "Properties.V");
4322
4323 assert!(Arc::ptr_eq(&first, &second));
4324 assert_eq!(first.as_ref(), owned.as_slice());
4325 assert!(combinations_after_first > 0);
4326 assert_eq!(model.scenario_combinations_used(), combinations_after_first);
4327 }
4328
4329 #[test]
4330 fn resolve_properties_scenarios_shared_reuses_allocation_and_matches_owned() {
4331 let model = SemanticModel::from_bytes(CONDITIONAL_PROPERTY_TEMPLATE).unwrap();
4332
4333 let first = model.resolve_properties_scenarios_shared("R");
4334 let combinations_after_first = model.scenario_combinations_used();
4335 let second = model.resolve_properties_scenarios_shared("R");
4336 let owned = model.resolve_properties_scenarios("R");
4337
4338 assert!(Arc::ptr_eq(&first, &second), "repeated shared access must reuse one allocation");
4339 assert!(first.len() > 1, "the Fn::If value must expand the whole-properties object into multiple scenarios");
4340 assert_eq!(
4341 serde_json::to_value(first.as_ref()).expect("shared scenarios serialize"),
4342 serde_json::to_value(&owned).expect("owned scenarios serialize"),
4343 "the owned API must return the same scenarios as the shared accessor"
4344 );
4345 assert_eq!(
4346 model.scenario_combinations_used(),
4347 combinations_after_first,
4348 "a cached shared access must not consume additional scenario budget"
4349 );
4350 }
4351
4352 #[test]
4353 fn resolve_scenarios_shared_reuses_allocation_and_matches_owned() {
4354 let model = SemanticModel::from_bytes(CONDITIONAL_PROPERTY_TEMPLATE).unwrap();
4355
4356 let first = model.resolve_scenarios_shared("R", "Properties.V");
4357 let combinations_after_first = model.scenario_combinations_used();
4358 let second = model.resolve_scenarios_shared("R", "Properties.V");
4359 let owned = model.resolve_scenarios("R", "Properties.V");
4360
4361 assert!(Arc::ptr_eq(&first, &second), "repeated shared access must reuse one allocation");
4362 assert_eq!(first.len(), 2, "the Fn::If value must expand into one scenario per branch");
4363 assert_eq!(
4364 serde_json::to_value(first.as_ref()).expect("shared scenarios serialize"),
4365 serde_json::to_value(&owned).expect("owned scenarios serialize"),
4366 "the owned API must return the same scenarios as the shared accessor"
4367 );
4368 assert_eq!(
4369 model.scenario_combinations_used(),
4370 combinations_after_first,
4371 "cached shared and owned access must not consume additional scenario budget"
4372 );
4373 }
4374
4375 #[test]
4376 fn resolve_scenarios_with_limit_owned_matches_shared_including_curtailment() {
4377 let model = SemanticModel::from_bytes(CONDITIONAL_PROPERTY_TEMPLATE).unwrap();
4378
4379 let (shared, shared_was_curtailed) = model.resolve_scenarios_with_limit_shared("R", "Properties.V", 1);
4380 let (repeated, repeated_was_curtailed) = model.resolve_scenarios_with_limit_shared("R", "Properties.V", 1);
4381 let (owned, owned_was_curtailed) = model.resolve_scenarios_with_limit("R", "Properties.V", 1);
4382
4383 assert!(Arc::ptr_eq(&shared, &repeated), "a repeated limited query must reuse its memoized allocation");
4384 assert_eq!(shared.len(), 1, "a per-value limit of one must keep only the first branch scenario");
4385 assert!(shared_was_curtailed, "omitting the second branch scenario must be reported as curtailment");
4386 assert!(repeated_was_curtailed, "a memoized result must keep its curtailment status");
4387 assert!(owned_was_curtailed, "the owned API must report the memoized curtailment status");
4388 assert_eq!(
4389 serde_json::to_value(shared.as_ref()).expect("shared scenarios serialize"),
4390 serde_json::to_value(&owned).expect("owned scenarios serialize"),
4391 "the owned API must return the same scenarios as the shared accessor"
4392 );
4393 }
4394
4395 #[test]
4396 fn resolve_scenarios_with_limit_shared_memoizes_each_limit_separately() {
4397 let model = SemanticModel::from_bytes(CONDITIONAL_PROPERTY_TEMPLATE).unwrap();
4398
4399 let (limited, _) = model.resolve_scenarios_with_limit_shared("R", "Properties.V", 1);
4400 let default_limit = model.resolve_scenarios_shared("R", "Properties.V");
4401 let (explicit_default_limit, was_curtailed) =
4402 model.resolve_scenarios_with_limit_shared("R", "Properties.V", MAX_SCENARIO_COMBINATIONS);
4403
4404 assert_eq!(limited.len(), 1, "a per-value limit of one must keep only the first branch scenario");
4405 assert_eq!(default_limit.len(), 2, "a curtailed limited expansion must not be served to a default-limit query");
4406 assert!(
4407 Arc::ptr_eq(&default_limit, &explicit_default_limit),
4408 "the default-limit accessor and an explicit default-limit query must share one memoized allocation"
4409 );
4410 assert!(!was_curtailed, "the complete default-limit expansion must not report curtailment");
4411 }
4412
4413 #[test]
4414 fn default_scenario_queries_are_memoized_and_global_budget_halts_uncached_expansion() {
4415 let input = br#"{
4416 "Parameters": {"Env": {"Type": "String"}},
4417 "Conditions": {"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}},
4418 "Resources": {"R": {"Type": "T", "Properties": {
4419 "V": {"Fn::If": ["IsProd", "a", "b"]},
4420 "W": {"Fn::If": ["IsProd", "c", "d"]},
4421 "X": {"Fn::If": ["IsProd", "e", "f"]}
4422 }}}
4423 }"#;
4424 let model = SemanticModel::from_bytes(input).unwrap();
4425
4426 assert_eq!(model.scenario_combinations_used(), 0, "a freshly built model has materialized no scenarios");
4427 assert!(!model.scenario_budget_exhausted());
4428
4429 let first = model.resolve_scenarios("R", "Properties.V");
4430 assert!(first.len() > 1, "an Fn::If value must expand into multiple scenarios; got {}", first.len());
4431 let after_first = model.scenario_combinations_used();
4432 assert_eq!(after_first, first.len() as u64, "the first query charges exactly the scenarios it produced");
4433
4434 let repeated = model.resolve_scenarios("R", "Properties.V");
4435 assert_eq!(repeated.len(), first.len());
4436 assert_eq!(
4437 model.scenario_combinations_used(),
4438 after_first,
4439 "a repeated default-limit query must reuse its raw scenarios without consuming budget"
4440 );
4441
4442 let distinct = model.resolve_scenarios("R", "Properties.W");
4443 assert!(!distinct.is_empty());
4444 assert!(
4445 model.scenario_combinations_used() > after_first,
4446 "a distinct scenario path must still charge the model-wide budget"
4447 );
4448
4449 let to_threshold = MAX_TOTAL_SCENARIO_COMBINATIONS - model.scenario_combinations_used();
4450 model.add_scenario_combinations_for_test(to_threshold);
4451 assert!(model.scenario_budget_exhausted());
4452
4453 let cached = model.resolve_scenarios("R", "Properties.V");
4454 assert_eq!(
4455 cached.len(),
4456 first.len(),
4457 "cached scenarios remain usable after unrelated queries consume the budget"
4458 );
4459 assert!(!model.scenario_expansion_curtailed(), "reading a cached result does not curtail expansion");
4460
4461 let before_short_circuit = model.scenario_combinations_used();
4462 let curtailed = model.resolve_scenarios("R", "Properties.X");
4463 assert!(curtailed.is_empty(), "an uncached query must stop when the model-wide budget is exhausted");
4464 assert_eq!(model.scenario_combinations_used(), before_short_circuit);
4465 assert!(model.scenario_expansion_curtailed());
4466 }
4467
4468 fn two_scenario_value() -> ResolvedValue {
4469 ResolvedValue::Conditional {
4470 condition: "C".to_string(),
4471 if_true: Box::new(ResolvedValue::Concrete { value: serde_json::json!("yes").into() }),
4472 if_false: Box::new(ResolvedValue::Concrete { value: serde_json::json!("no").into() }),
4473 }
4474 }
4475
4476 #[test]
4477 fn exact_remaining_scenario_budget_does_not_mark_curtailment() {
4478 let model = SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap();
4479 let scenarios = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 2);
4480 assert_eq!(scenarios.len(), 2);
4481 assert_eq!(model.scenario_combinations_used(), 2);
4482 assert!(
4483 !model.scenario_expansion_curtailed(),
4484 "materializing exactly every possible scenario at the budget boundary is not curtailment"
4485 );
4486 }
4487
4488 #[test]
4489 fn remaining_scenario_budget_truncates_and_marks_curtailment() {
4490 let model = SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap();
4491 let first = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
4492 assert_eq!(first.len(), 2);
4493 assert!(!model.scenario_expansion_curtailed());
4494
4495 let second = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
4496 assert_eq!(second.len(), 1, "only the one remaining global slot may be used");
4497 assert_eq!(model.scenario_combinations_used(), 3);
4498 assert!(model.scenario_expansion_curtailed());
4499
4500 let exhausted = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
4501 assert!(exhausted.is_empty());
4502 assert_eq!(model.scenario_combinations_used(), 3, "the global limit must never be exceeded");
4503 }
4504
4505 #[test]
4506 fn concurrent_scenario_queries_cannot_exceed_global_budget() {
4507 let model = std::sync::Arc::new(SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap());
4508 let handles: Vec<_> = (0..8)
4509 .map(|_| {
4510 let model = std::sync::Arc::clone(&model);
4511 std::thread::spawn(move || {
4512 let value = ResolvedValue::Concrete { value: serde_json::json!("value").into() };
4513 model.collect_scenarios_with_budget(&value, 8, 4).len()
4514 })
4515 })
4516 .collect();
4517 let produced: usize =
4518 handles.into_iter().map(|handle| handle.join().expect("scenario worker must not panic")).sum();
4519
4520 assert_eq!(produced, 4, "exactly the global budget may be materialized across all workers");
4521 assert_eq!(model.scenario_combinations_used(), 4, "concurrent accounting must not overshoot the limit");
4522 assert!(model.scenario_expansion_curtailed(), "workers denied by exhaustion must mark curtailment");
4523 }
4524
4525 #[test]
4526 fn scenario_expansion_curtailment_flag_starts_false() {
4527 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"literal"}}}}"#;
4528 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4529 assert!(
4530 !model.scenario_expansion_curtailed(),
4531 "a freshly built model must not have scenario expansion curtailed"
4532 );
4533 }
4534
4535 #[test]
4536 fn scenario_expansion_curtailment_flag_set_by_test_helper() {
4537 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"literal"}}}}"#;
4538 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4539 model.set_scenario_expansion_curtailed_for_test();
4540 assert!(model.scenario_expansion_curtailed(), "the test helper must set the curtailment flag");
4541 }
4542
4543 #[test]
4544 fn scenario_expansion_curtailment_flag_is_monotonic() {
4545 let input = r#"{
4548 "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::If": ["C", "a", "b"]}}}}
4549 }"#;
4550 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4551 model.set_scenario_expansion_curtailed_for_test();
4552 let _ = model.resolve_scenarios("R", "Properties.V");
4554 assert!(model.scenario_expansion_curtailed(), "the curtailment flag must be monotonic — once set it stays set");
4555 }
4556
4557 #[test]
4558 fn estimated_bounds_are_absent_for_a_literal_string() {
4559 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
4562 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4563 assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), None);
4564 }
4565
4566 #[test]
4567 fn estimated_bounds_span_every_allowed_value() {
4568 let input = r#"{"Parameters":{"Size":{"Type":"String","AllowedValues":["xs","large"]}},
4569 "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Size"}}}}}"#;
4570 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4571 assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), Some((2, 5)));
4572 }
4573
4574 #[test]
4575 fn estimated_bounds_are_absent_when_a_possibility_is_unknown() {
4576 let input = r#"{"Parameters":{"Free":{"Type":"String"}},
4577 "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Free"}}}}}"#;
4578 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4579 assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), None);
4580 }
4581
4582 #[test]
4583 fn estimate_string_length_missing_property() {
4584 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
4585 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4586 assert!(model.estimated_string_length_bounds("R", "Properties.Missing").is_none());
4587 }
4588
4589 #[test]
4590 fn resource_span_specific_path() {
4591 let input = "Resources:\n R:\n Type: T\n Properties:\n Name: hello\n";
4592 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4593 let span = model.resource_span("R", "Properties/Name");
4594 assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span for Properties/Name");
4596 }
4597
4598 #[test]
4599 fn resource_span_specific_path_dotted_form() {
4600 let input = "Resources:\n R:\n Type: T\n Properties:\n Name: hello\n";
4601 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4602 let dotted = model.resource_span("R", "Properties.Name");
4605 let slashed = model.resource_span("R", "Properties/Name");
4606 let resource = model.resource_span("R", "");
4607 assert_eq!(dotted, slashed, "dotted and slash forms must resolve to the same span");
4608 assert_ne!(dotted, resource, "specific property span must differ from the resource span");
4609 }
4610
4611 #[test]
4612 fn resource_span_falls_back_to_resource() {
4613 let input = "Resources:\n R:\n Type: T\n Properties:\n Name: hello\n";
4614 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4615 let span = model.resource_span("R", "Properties/NonExistent/Deep");
4616 assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span, got {:?}", span);
4618 }
4619
4620 #[test]
4621 fn resource_span_nested_array_property_anchors_at_element() {
4622 let input =
4625 "Resources:\n R:\n Type: T\n Properties:\n Ingress:\n - Port: 80\n - Port: 443\n";
4626 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4627 let first = model.resource_span("R", "Properties.Ingress.0.Port");
4628 let second = model.resource_span("R", "Properties.Ingress.1.Port");
4629 assert_eq!(first.start_line, 6, "Ingress[0].Port is on line 6, got {:?}", first);
4630 assert_eq!(second.start_line, 7, "Ingress[1].Port is on line 7, got {:?}", second);
4631 }
4632
4633 #[test]
4634 fn resource_span_empty_id_resolves_section_absolute_path_precisely() {
4635 let input = concat!(
4640 "Resources:\n R:\n Type: T\n", "Outputs:\n Combined:\n Value: !Join [\"\", [\"a\", \"b\"]]\n", );
4643 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4644 let value = model.resource_span("", "Outputs/Combined/Value");
4646 assert_eq!(value.start_line, 6, "Outputs/Combined/Value is on line 6, got {:?}", value);
4647 let combined = model.resource_span("", "Outputs/Combined");
4649 assert_eq!(combined.start_line, 5, "Outputs/Combined is on line 5, got {:?}", combined);
4650 }
4651
4652 #[test]
4653 fn resource_span_empty_id_fused_intrinsic_suffix_anchors_at_nearest_slash_ancestor() {
4654 let input = concat!(
4663 "Resources:\n R:\n Type: T\n", "Outputs:\n Combined:\n Value: !Join [\"\", [\"a\", \"b\"]]\n", );
4666 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4667 let span = model.resource_span("", "Outputs/Combined/Value.Fn::Join");
4668 assert_eq!(
4669 span.start_line, 5,
4670 "fused-suffix path must anchor at the enclosing output (line 5), got {:?}",
4671 span
4672 );
4673 }
4674
4675 #[test]
4676 fn resource_span_empty_id_does_not_leak_onto_resources_block() {
4677 let input = "Resources:\n R:\n Type: T\n";
4681 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4682 let span = model.resource_span("", "Outputs/DoesNotExist/Value");
4683 assert_eq!(span, UNKNOWN_SPAN, "unresolvable empty-id path must be UNKNOWN, got {:?}", span);
4684 }
4685
4686 #[test]
4687 fn resource_span_empty_path_returns_resource_span() {
4688 let input = "Resources:\n R:\n Type: T\n";
4689 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4690 let span = model.resource_span("R", "");
4691 assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span, got {:?}", span);
4692 }
4693
4694 #[test]
4695 fn resolve_nonexistent_resource_returns_none() {
4696 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"x"}}}}"#;
4697 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4698 assert!(model.resolve("NoSuchResource", "Properties.V").is_none(), "nonexistent resource should return None");
4699 assert!(model.resolve_deep("NoSuchResource", "Properties.V").is_none());
4700 }
4701
4702 #[test]
4703 fn parse_config_with_parameter_overrides() {
4704 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
4705 let config = ParseConfig {
4706 parameters: [("Env".to_string(), "staging".to_string())].into_iter().collect(),
4707 pseudo_parameters: PseudoParameterOverrides::default(),
4708 };
4709 let result = SemanticModel::parse(input.as_bytes(), config).unwrap();
4710 match result.model.resolve("R", "Properties.V") {
4711 Some(ResolvedValue::Concrete { value: v }) => {
4712 assert_eq!(v.as_str().unwrap(), "staging")
4713 }
4714 other => panic!("Expected Concrete(\"staging\"), got {:?}", other),
4715 }
4716 }
4717
4718 #[test]
4719 fn parse_config_with_pseudo_parameter_overrides() {
4720 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
4721 let config = ParseConfig {
4722 parameters: HashMap::new(),
4723 pseudo_parameters: PseudoParameterOverrides { region: Some("eu-west-1".to_string()), ..Default::default() },
4724 };
4725 let result = SemanticModel::parse(input.as_bytes(), config).unwrap();
4726 match result.model.resolve("R", "Properties.V") {
4727 Some(ResolvedValue::Concrete { value: v }) => {
4728 assert_eq!(v.as_str().unwrap(), "eu-west-1")
4729 }
4730 other => panic!("Expected Concrete(\"eu-west-1\"), got {:?}", other),
4731 }
4732 }
4733
4734 #[test]
4735 fn pseudo_parameter_overrides_defaults() {
4736 let overrides = PseudoParameterOverrides::default();
4737 assert_eq!(overrides.region(), DEFAULT_REGION);
4738 assert_eq!(overrides.get("AWS::AccountId").unwrap(), DEFAULT_ACCOUNT_ID);
4739 assert_eq!(overrides.get("AWS::Partition").unwrap(), DEFAULT_PARTITION);
4740 assert_eq!(overrides.get("AWS::StackName").unwrap(), DEFAULT_STACK_NAME);
4741 assert_eq!(overrides.get("AWS::URLSuffix").unwrap(), DEFAULT_URL_SUFFIX);
4742 assert!(overrides.get("AWS::StackId").unwrap().contains("arn:"));
4743 assert!(overrides.get("AWS::NotificationARNs").unwrap().contains("sns"));
4744 assert_eq!(overrides.get("Unknown"), None, "unknown pseudo-param should return None");
4745 }
4746
4747 #[test]
4748 fn pseudo_parameter_overrides_china_region() {
4749 let overrides = PseudoParameterOverrides { region: Some("cn-north-1".to_string()), ..Default::default() };
4750 assert_eq!(overrides.get("AWS::Partition").unwrap(), "aws-cn");
4751 assert_eq!(overrides.get("AWS::URLSuffix").unwrap(), "amazonaws.com.cn");
4752 }
4753
4754 #[test]
4761 fn fixed_value_returns_none_for_unset_pseudo_parameters() {
4762 let overrides = PseudoParameterOverrides::default();
4763
4764 for name in [
4765 "AWS::AccountId",
4766 "AWS::NotificationARNs",
4767 "AWS::Partition",
4768 "AWS::Region",
4769 "AWS::StackId",
4770 "AWS::StackName",
4771 "AWS::URLSuffix",
4772 ] {
4773 assert_eq!(
4774 overrides.fixed_value(name),
4775 None,
4776 "{name} must be a free variable when the user has not explicitly pinned it"
4777 );
4778 }
4779 }
4780
4781 #[test]
4782 fn parameter_name_at_names_the_parameter_behind_a_value() {
4783 let input = r#"{"Parameters":{"BucketName":{"Type":"String"}},
4784 "Resources":{"R":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":{"Ref":"BucketName"}}}}}"#;
4785 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4786 assert_eq!(model.parameter_name_at("R", "Properties.BucketName"), Some("BucketName"));
4787 assert!(model.is_from_parameter("R", "Properties.BucketName"));
4788 }
4789
4790 #[test]
4791 fn parameter_name_at_is_absent_for_a_value_no_parameter_produced() {
4792 let input = r#"{"Resources":{"R":{"Type":"AWS::S3::Bucket",
4795 "Properties":{"BucketName":{"Fn::ImportValue":"SharedName"}}}}}"#;
4796 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4797 assert_eq!(model.parameter_name_at("R", "Properties.BucketName"), None);
4798 assert!(!model.is_from_parameter("R", "Properties.BucketName"));
4799 }
4800
4801 #[test]
4802 fn fixed_value_returns_user_supplied_overrides() {
4803 let overrides = PseudoParameterOverrides {
4804 account_id: Some("999999999999".to_string()),
4805 partition: Some("aws-cn".to_string()),
4806 region: Some("cn-north-1".to_string()),
4807 stack_name: Some("MyStack".to_string()),
4808 ..Default::default()
4809 };
4810
4811 assert_eq!(overrides.fixed_value("AWS::AccountId"), Some("999999999999".to_string()));
4812 assert_eq!(overrides.fixed_value("AWS::Partition"), Some("aws-cn".to_string()));
4813 assert_eq!(overrides.fixed_value("AWS::Region"), Some("cn-north-1".to_string()));
4814 assert_eq!(overrides.fixed_value("AWS::StackName"), Some("MyStack".to_string()));
4815 assert_eq!(overrides.fixed_value("AWS::URLSuffix"), None, "URLSuffix not set; must remain a free variable");
4816 assert_eq!(overrides.fixed_value("Unknown"), None);
4817 }
4818
4819 #[test]
4820 fn parameter_name_from_source_reads_only_a_parameter_entry() {
4821 assert_eq!(parameter_name_from_source("Parameters/InstanceType/Default"), Some("InstanceType"));
4822 assert_eq!(parameter_name_from_source("Parameters/InstanceType/AllowedValues"), Some("InstanceType"));
4823 assert_eq!(parameter_name_from_source("Parameters/InstanceType"), Some("InstanceType"));
4824 assert_eq!(parameter_name_from_source("Intrinsic/Ref"), None);
4825 assert_eq!(parameter_name_from_source("Parameters/"), None);
4826 assert_eq!(parameter_name_from_source("ParametersLookAlike/Name"), None);
4827 }
4828
4829 #[test]
4836 fn fixed_value_does_not_propagate_region_to_partition() {
4837 let overrides = PseudoParameterOverrides { region: Some("cn-north-1".to_string()), ..Default::default() };
4838
4839 assert_eq!(overrides.fixed_value("AWS::Region"), Some("cn-north-1".to_string()));
4840 assert_eq!(
4841 overrides.fixed_value("AWS::Partition"),
4842 None,
4843 "region override must not propagate to partition - partition stays free unless the caller pins it"
4844 );
4845 }
4846
4847 #[test]
4848 fn model_rejects_oversized_template() {
4849 let huge = vec![b' '; 11 * 1024 * 1024];
4850 let result = SemanticModel::from_bytes(&huge);
4851 match result {
4852 Err(e) => assert!(e.message.contains("maximum size")),
4853 Ok(_) => panic!("Expected error for oversized template"),
4854 }
4855 }
4856
4857 #[test]
4858 fn follow_ref_returns_none_for_non_reference() {
4859 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
4860 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4861 assert_eq!(model.follow_ref("R", "Properties.V"), None, "non-reference value should return None");
4862 }
4863
4864 #[test]
4865 fn resolve_deep_memoization_consistency() {
4866 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Config":{"A":"1","B":"2"}}}}}"#;
4867 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4868 let r1 = model.resolve_deep("R", "Properties.Config.A");
4869 let r2 = model.resolve_deep("R", "Properties.Config.A");
4870 match (&r1, &r2) {
4871 (Some(ResolvedValue::Concrete { value: a }), Some(ResolvedValue::Concrete { value: b })) => {
4872 assert_eq!(a, b);
4873 }
4874 _ => panic!("Expected matching Concrete values"),
4875 }
4876 }
4877
4878 #[test]
4879 fn model_with_mappings_and_findinmap() {
4880 let input = r#"
4881Mappings:
4882 RegionMap:
4883 us-east-1:
4884 AMI: ami-12345
4885 us-west-2:
4886 AMI: ami-67890
4887Resources:
4888 R:
4889 Type: T
4890 Properties:
4891 ImageId: !FindInMap [RegionMap, us-east-1, AMI]
4892"#;
4893 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4894 match model.resolve("R", "Properties.ImageId") {
4895 Some(ResolvedValue::Concrete { value: v }) => {
4896 assert_eq!(v.as_str().unwrap(), "ami-12345")
4897 }
4898 other => panic!("Expected Concrete, got {:?}", other),
4899 }
4900 }
4901
4902 #[test]
4903 fn model_invalid_ref_tracked() {
4904 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"NonExistent"}}}}}"#;
4905 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
4906 let r = model.resource("R").unwrap();
4907 assert!(!r.diagnostics.invalid_refs.is_empty());
4908 assert!(r.diagnostics.invalid_refs.iter().any(|s| s.value == "NonExistent"));
4909 }
4910
4911 #[test]
4912 fn collapse_arn_paths_folds_list_siblings_to_lowest_index() {
4913 let input = vec![
4914 "Properties.KeyPolicy.Statement.2.Principal.AWS.1.Fn::Sub".to_string(),
4915 "Properties.KeyPolicy.Statement.2.Principal.AWS.0.Fn::Sub".to_string(),
4916 ];
4917 let out = collapse_list_sibling_arn_paths(input);
4918 assert_eq!(out, vec!["Properties.KeyPolicy.Statement.2.Principal.AWS.0.Fn::Sub".to_string()]);
4919 }
4920
4921 #[test]
4922 fn collapse_arn_paths_keeps_distinct_parent_lists() {
4923 let input = vec![
4926 "Properties.PolicyDocument.Statement.0.Resource.Fn::Sub".to_string(),
4927 "Properties.PolicyDocument.Statement.1.Resource.Fn::Sub".to_string(),
4928 ];
4929 let mut out = collapse_list_sibling_arn_paths(input.clone());
4930 out.sort();
4931 assert_eq!(out, input);
4932 }
4933
4934 #[test]
4935 fn collapse_arn_paths_leaves_scalar_paths_untouched() {
4936 let input = vec![
4937 "Properties.KeyPolicy.Statement.0.Principal.AWS.Fn::Sub".to_string(),
4938 "Properties.KeyPolicy.Statement.1.Principal.AWS.Fn::Sub".to_string(),
4939 ];
4940 let mut out = collapse_list_sibling_arn_paths(input.clone());
4941 out.sort();
4942 assert_eq!(out, input);
4943 }
4944
4945 fn output_string_type_diagnostics(template: &str) -> usize {
4947 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
4948 model.diagnostics.iter().filter(|d| d.rule_id == "F6101").count()
4949 }
4950
4951 #[test]
4952 fn output_literal_list_value_flagged() {
4953 let template = "Resources:\n R:\n Type: T\nOutputs:\n O:\n Value:\n - a\n - b\n";
4954 assert_eq!(output_string_type_diagnostics(template), 1);
4955 }
4956
4957 #[test]
4958 fn output_literal_object_value_flagged() {
4959 let template = "Resources:\n R:\n Type: T\nOutputs:\n O:\n Value:\n Key: v\n";
4960 assert_eq!(output_string_type_diagnostics(template), 1);
4961 }
4962
4963 #[test]
4964 fn output_list_returning_functions_flagged() {
4965 for value in ["!GetAZs \"\"", "!Split [\",\", \"a,b\"]", "!Cidr [\"10.0.0.0/16\", 1, 8]"] {
4966 let template = format!("Resources:\n R:\n Type: T\nOutputs:\n O:\n Value: {}\n", value);
4967 assert_eq!(output_string_type_diagnostics(&template), 1, "expected F6101 for output value {}", value);
4968 }
4969 }
4970
4971 #[test]
4972 fn output_empty_container_value_not_flagged() {
4973 for value in ["[]", "{}"] {
4976 let template = format!("Resources:\n R:\n Type: T\nOutputs:\n O:\n Value: {}\n", value);
4977 assert_eq!(output_string_type_diagnostics(&template), 0, "empty container {} must not be flagged", value);
4978 }
4979 }
4980
4981 #[test]
4982 fn output_string_producing_values_not_flagged() {
4983 for value in ["hello", "42", "true", "!Ref R", "!Sub \"${R}\"", "!Join [\",\", [\"a\"]]", "!ImportValue X"] {
4986 let template = format!("Resources:\n R:\n Type: T\nOutputs:\n O:\n Value: {}\n", value);
4987 assert_eq!(output_string_type_diagnostics(&template), 0, "string-valued output {} must not fire", value);
4988 }
4989 }
4990
4991 #[test]
4992 fn output_find_in_map_list_value_not_flagged() {
4993 let template = "Mappings:\n M:\n k:\n l:\n - a\n - b\nResources:\n R:\n Type: T\nOutputs:\n O:\n Value: !FindInMap [M, k, l]\n";
4999 assert_eq!(output_string_type_diagnostics(template), 0);
5000 }
5001
5002 #[test]
5003 fn output_fn_if_flags_each_list_branch() {
5004 let template = "Conditions:\n C:\n Fn::Equals: [\"a\", \"a\"]\nResources:\n R:\n Type: T\nOutputs:\n O:\n Value:\n Fn::If: [C, [\"a\"], [\"b\"]]\n";
5007 assert_eq!(output_string_type_diagnostics(template), 2);
5008 }
5009
5010 #[test]
5011 fn output_fn_if_string_branches_not_flagged() {
5012 let template = "Conditions:\n C:\n Fn::Equals: [\"a\", \"a\"]\nResources:\n R:\n Type: T\nOutputs:\n O:\n Value:\n Fn::If: [C, \"yes\", \"no\"]\n";
5013 assert_eq!(output_string_type_diagnostics(template), 0);
5014 }
5015
5016 #[test]
5017 fn join_with_enum_list_exact_4096_not_curtailed() {
5018 use crate::consts::MAX_ENUM_EXPANSION;
5019 use crate::resolver::join_with_enum_list;
5020 let variants_a: Vec<ResolvedValue> = (0..64)
5023 .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("a{i}")).into() })
5024 .collect();
5025 let variants_b: Vec<ResolvedValue> = (0..64)
5026 .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("b{i}")).into() })
5027 .collect();
5028 let items = vec![ResolvedValue::Enum { variants: variants_a }, ResolvedValue::Enum { variants: variants_b }];
5029 let (result, curtailed) = join_with_enum_list(",", &items);
5030 assert!(!curtailed, "exactly {MAX_ENUM_EXPANSION} combinations must not signal curtailment");
5031 if let ResolvedValue::Enum { variants } = &result {
5032 assert_eq!(variants.len(), MAX_ENUM_EXPANSION);
5033 } else {
5034 panic!("expected Enum result");
5035 }
5036 }
5037
5038 #[test]
5039 fn join_with_enum_list_one_over_curtailed() {
5040 use crate::consts::MAX_ENUM_EXPANSION;
5041 use crate::resolver::join_with_enum_list;
5042 let variants_a: Vec<ResolvedValue> = (0..65)
5044 .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("a{i}")).into() })
5045 .collect();
5046 let variants_b: Vec<ResolvedValue> = (0..64)
5047 .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("b{i}")).into() })
5048 .collect();
5049 let items = vec![ResolvedValue::Enum { variants: variants_a }, ResolvedValue::Enum { variants: variants_b }];
5050 let (result, curtailed) = join_with_enum_list(",", &items);
5051 assert!(curtailed, "exceeding {MAX_ENUM_EXPANSION} combinations must signal curtailment");
5052 if let ResolvedValue::Enum { variants } = &result {
5053 assert_eq!(variants.len(), MAX_ENUM_EXPANSION);
5054 } else {
5055 panic!("expected Enum result");
5056 }
5057 }
5058
5059 #[test]
5060 fn primary_id_same_parameter_across_ref_sub_join_collides() {
5061 let template = r#"
5062Parameters:
5063 Name: {Type: String}
5064Resources:
5065 A:
5066 Type: AWS::S3::Bucket
5067 Properties: {BucketName: !Ref Name}
5068 B:
5069 Type: AWS::S3::Bucket
5070 Properties: {BucketName: !Sub '${Name}'}
5071 C:
5072 Type: AWS::S3::Bucket
5073 Properties: {BucketName: !Sub ['${V}', {V: !Ref Name}]}
5074 D:
5075 Type: AWS::S3::Bucket
5076 Properties: {BucketName: !Join ['', [!Ref Name]]}
5077"#;
5078 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5079 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5080 assert_eq!(conflicts.len(), 1, "all four forms must collide into one group");
5081 let (_, resources) = conflicts.into_iter().next().unwrap();
5082 assert_eq!(resources.len(), 4, "all four resources must be in the group");
5083 }
5084
5085 #[test]
5086 fn primary_id_different_parameters_with_equal_defaults_stay_distinct() {
5087 let template = r#"
5088Parameters:
5089 A: {Type: String, Default: shared}
5090 B: {Type: String, Default: shared}
5091Resources:
5092 One:
5093 Type: AWS::S3::Bucket
5094 Properties: {BucketName: !Ref A}
5095 Two:
5096 Type: AWS::S3::Bucket
5097 Properties: {BucketName: !Ref B}
5098"#;
5099 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5100 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5101 assert!(conflicts.is_empty(), "different overridable parameters must not collide: {:?}", conflicts);
5102 }
5103
5104 #[test]
5105 fn primary_id_comparison_identity_is_separate_from_display_value() {
5106 let template = r#"
5107Parameters:
5108 Name: {Type: String}
5109Resources:
5110 DynamicOne:
5111 Type: AWS::S3::Bucket
5112 Properties: {BucketName: !Ref Name}
5113 DynamicTwo:
5114 Type: AWS::S3::Bucket
5115 Properties: {BucketName: !Ref Name}
5116 LiteralOne:
5117 Type: AWS::S3::Bucket
5118 Properties: {BucketName: 'Ref("Name")'}
5119 LiteralTwo:
5120 Type: AWS::S3::Bucket
5121 Properties: {BucketName: 'Ref("Name")'}
5122"#;
5123 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5124 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5125 assert_eq!(conflicts.len(), 2, "equal display text must not merge distinct identities");
5126 assert!(
5127 conflicts
5128 .iter()
5129 .all(|(tuple, resources)| { tuple == &["Ref(\"Name\")".to_string()] && resources.len() == 2 })
5130 );
5131 }
5132
5133 #[test]
5134 fn primary_id_mutually_exclusive_conditions_do_not_collide() {
5135 let template = r#"
5136Parameters:
5137 Env: {Type: String}
5138 Name: {Type: String}
5139Conditions:
5140 IsProd: !Equals [!Ref Env, prod]
5141 IsNotProd: !Not [!Condition IsProd]
5142Resources:
5143 ProdBucket:
5144 Type: AWS::S3::Bucket
5145 Condition: IsProd
5146 Properties: {BucketName: !Ref Name}
5147 DevBucket:
5148 Type: AWS::S3::Bucket
5149 Condition: IsNotProd
5150 Properties: {BucketName: !Ref Name}
5151"#;
5152 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5153 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5154 assert!(conflicts.is_empty(), "mutually exclusive conditions must not collide");
5155 }
5156
5157 #[test]
5158 fn primary_id_no_internal_marker_leakage_in_tuple_values() {
5159 let template = r#"
5160Parameters:
5161 Name: {Type: String}
5162Resources:
5163 A:
5164 Type: AWS::S3::Bucket
5165 Properties: {BucketName: !Ref Name}
5166 B:
5167 Type: AWS::S3::Bucket
5168 Properties: {BucketName: !Ref Name}
5169"#;
5170 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5171 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5172 assert_eq!(conflicts.len(), 1);
5173 let (tuple, _) = conflicts.into_iter().next().unwrap();
5174 let value = &tuple[0];
5175 assert!(!value.contains("$dyn:"), "tuple value must not contain $dyn: marker");
5176 assert!(!value.contains("expr:"), "tuple value must not contain expr: prefix");
5177 }
5178
5179 fn primary_identifier_conflicts_all_pairs_reference(
5183 model: &SemanticModel,
5184 resource_type: &str,
5185 identifier_properties: &[String],
5186 ) -> Vec<(Vec<String>, BTreeSet<String>)> {
5187 let mut per_resource = Vec::new();
5188 for resource_id in model.resources_of_type(resource_type) {
5189 let scenarios = model.primary_identifier_scenarios(resource_id, identifier_properties);
5190 if !scenarios.is_empty() {
5191 per_resource.push((resource_id, scenarios));
5192 }
5193 }
5194 let mut conflicts: BTreeMap<Vec<String>, (Vec<String>, BTreeSet<String>)> = BTreeMap::new();
5195 for left_index in 0..per_resource.len() {
5196 for right_index in (left_index + 1)..per_resource.len() {
5197 let (left_resource, left_scenarios) = &per_resource[left_index];
5198 let (right_resource, right_scenarios) = &per_resource[right_index];
5199 for left in left_scenarios {
5200 for right in right_scenarios {
5201 if left.identity_tuple != right.identity_tuple {
5202 continue;
5203 }
5204 let mut assumptions = left.assumptions.clone();
5205 assumptions.extend(right.assumptions.iter().cloned());
5206 if assumptions.is_empty() || model.conditions.is_satisfiable(&assumptions) {
5207 let (_, resources) = conflicts
5208 .entry(left.identity_tuple.clone())
5209 .or_insert_with(|| (left.display_tuple.clone(), BTreeSet::new()));
5210 resources.insert((*left_resource).clone());
5211 resources.insert((*right_resource).clone());
5212 }
5213 }
5214 }
5215 }
5216 }
5217 conflicts.into_values().collect()
5218 }
5219
5220 #[test]
5221 fn primary_id_unconditional_resource_collides_with_every_conditional_duplicate() {
5222 let template = r#"
5223Parameters:
5224 Env: {Type: String}
5225Conditions:
5226 IsAlpha: !Equals [!Ref Env, alpha]
5227 IsBeta: !Equals [!Ref Env, beta]
5228Resources:
5229 Always:
5230 Type: AWS::S3::Bucket
5231 Properties: {BucketName: shared}
5232 WhenAlpha:
5233 Type: AWS::S3::Bucket
5234 Condition: IsAlpha
5235 Properties: {BucketName: shared}
5236 WhenBeta:
5237 Type: AWS::S3::Bucket
5238 Condition: IsBeta
5239 Properties: {BucketName: shared}
5240"#;
5241 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5242 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5243 assert_eq!(conflicts.len(), 1, "the single shared name is one collision group: {conflicts:?}");
5244 let (display, resources) = conflicts.into_iter().next().unwrap();
5245 assert_eq!(display, vec!["shared".to_string()]);
5246 assert_eq!(
5247 resources,
5248 BTreeSet::from(["Always".to_string(), "WhenAlpha".to_string(), "WhenBeta".to_string()]),
5249 "the unconditional resource collides with each conditional duplicate, joining all three even though the two conditions are themselves mutually exclusive"
5250 );
5251 }
5252
5253 #[test]
5254 fn primary_id_mutually_exclusive_conditions_across_identities_never_collide() {
5255 let template = r#"
5256Parameters:
5257 Env: {Type: String}
5258 Left: {Type: String}
5259 Right: {Type: String}
5260Conditions:
5261 IsProd: !Equals [!Ref Env, prod]
5262 IsNotProd: !Not [!Condition IsProd]
5263Resources:
5264 LeftProd:
5265 Type: AWS::S3::Bucket
5266 Condition: IsProd
5267 Properties: {BucketName: !Ref Left}
5268 LeftDev:
5269 Type: AWS::S3::Bucket
5270 Condition: IsNotProd
5271 Properties: {BucketName: !Ref Left}
5272 RightProd:
5273 Type: AWS::S3::Bucket
5274 Condition: IsProd
5275 Properties: {BucketName: !Ref Right}
5276 RightDev:
5277 Type: AWS::S3::Bucket
5278 Condition: IsNotProd
5279 Properties: {BucketName: !Ref Right}
5280"#;
5281 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5282 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5283 assert!(
5284 conflicts.is_empty(),
5285 "each identity's two resources are guarded by mutually exclusive conditions, so neither identity group yields a collision: {conflicts:?}"
5286 );
5287 }
5288
5289 #[test]
5290 fn primary_id_duplicate_scenarios_of_one_resource_do_not_self_collide() {
5291 let template = r#"
5292Parameters:
5293 Env: {Type: String}
5294Conditions:
5295 IsProd: !Equals [!Ref Env, prod]
5296Resources:
5297 Lonely:
5298 Type: AWS::S3::Bucket
5299 Properties:
5300 BucketName: !If [IsProd, shared, shared]
5301"#;
5302 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5303 let scenarios = model.primary_identifier_scenarios("Lonely", &["BucketName".to_string()]);
5304 assert!(
5305 scenarios.len() >= 2,
5306 "the Fn::If must expand into multiple scenarios so self-comparison is actually exercised: {}",
5307 scenarios.len()
5308 );
5309 let identities: BTreeSet<Vec<String>> =
5310 scenarios.iter().map(|scenario| scenario.identity_tuple.clone()).collect();
5311 assert_eq!(identities.len(), 1, "the branches must share one comparison identity so they land in one group");
5312
5313 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5314 assert!(
5315 conflicts.is_empty(),
5316 "a single resource whose branches produce the same identifier must not be reported as colliding with itself: {conflicts:?}"
5317 );
5318 }
5319
5320 #[test]
5321 fn primary_id_overlapping_conditions_collide() {
5322 let template = r#"
5323Parameters:
5324 Env: {Type: String}
5325 Name: {Type: String}
5326Conditions:
5327 NotAlpha: !Not [!Equals [!Ref Env, alpha]]
5328 NotBeta: !Not [!Equals [!Ref Env, beta]]
5329Resources:
5330 One:
5331 Type: AWS::S3::Bucket
5332 Condition: NotAlpha
5333 Properties: {BucketName: !Ref Name}
5334 Two:
5335 Type: AWS::S3::Bucket
5336 Condition: NotBeta
5337 Properties: {BucketName: !Ref Name}
5338"#;
5339 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5340 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5341 assert_eq!(
5342 conflicts.len(),
5343 1,
5344 "the two resources share one identity and their conditions can both hold: {conflicts:?}"
5345 );
5346 let (_, resources) = conflicts.into_iter().next().unwrap();
5347 assert_eq!(resources, BTreeSet::from(["One".to_string(), "Two".to_string()]));
5348 }
5349
5350 #[test]
5351 fn primary_id_display_tuple_comes_from_first_compatible_pair_not_first_candidate() {
5352 let template = r#"
5353Parameters:
5354 Env: {Type: String}
5355Conditions:
5356 IsProd: !Equals [!Ref Env, prod]
5357 IsDev: !Not [!Condition IsProd]
5358Resources:
5359 ProdKeyed:
5360 Type: AWS::S3::Bucket
5361 Condition: IsProd
5362 Properties:
5363 BucketName: {alpha: one, beta: two}
5364 DevKeyedFirst:
5365 Type: AWS::S3::Bucket
5366 Condition: IsDev
5367 Properties:
5368 BucketName: {beta: two, alpha: one}
5369 DevKeyedSecond:
5370 Type: AWS::S3::Bucket
5371 Condition: IsDev
5372 Properties:
5373 BucketName: {beta: two, alpha: one}
5374"#;
5375 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5376 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
5377 assert_eq!(conflicts.len(), 1, "all three author the same key-sorted identity: {conflicts:?}");
5378 let (display, resources) = conflicts.into_iter().next().unwrap();
5379 assert_eq!(
5380 resources,
5381 BTreeSet::from(["DevKeyedFirst".to_string(), "DevKeyedSecond".to_string()]),
5382 "the earlier-declared prod resource is mutually exclusive with both dev resources, so it never forms a compatible pair and is absent from the group"
5383 );
5384 assert!(
5385 display[0].starts_with("{\"beta\""),
5386 "display must come from the left scenario of the first compatible pair (a dev resource, authored beta-first), not the earlier incompatible prod candidate authored alpha-first: {display:?}"
5387 );
5388 }
5389
5390 #[test]
5391 fn primary_id_grouped_traversal_matches_all_pairs_reference_and_sat_budget() {
5392 let template = r#"
5393Parameters:
5394 Env: {Type: String}
5395 Shared: {Type: String}
5396Conditions:
5397 IsProd: !Equals [!Ref Env, prod]
5398 IsStaging: !Equals [!Ref Env, staging]
5399 IsProdAlias: !Equals [!Ref Env, prod]
5400Resources:
5401 AlphaAlways:
5402 Type: AWS::S3::Bucket
5403 Properties: {BucketName: alpha}
5404 AlphaProd:
5405 Type: AWS::S3::Bucket
5406 Condition: IsProd
5407 Properties: {BucketName: alpha}
5408 AlphaStaging:
5409 Type: AWS::S3::Bucket
5410 Condition: IsStaging
5411 Properties: {BucketName: alpha}
5412 BetaProd:
5413 Type: AWS::S3::Bucket
5414 Condition: IsProd
5415 Properties: {BucketName: beta}
5416 BetaProdAlias:
5417 Type: AWS::S3::Bucket
5418 Condition: IsProdAlias
5419 Properties: {BucketName: beta}
5420 SharedOne:
5421 Type: AWS::S3::Bucket
5422 Properties: {BucketName: !Ref Shared}
5423 SharedTwo:
5424 Type: AWS::S3::Bucket
5425 Properties: {BucketName: !Ref Shared}
5426 Unique:
5427 Type: AWS::S3::Bucket
5428 Properties: {BucketName: unique}
5429"#;
5430 let identifier_properties = ["BucketName".to_string()];
5431
5432 let reference_model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5433 let reference = primary_identifier_conflicts_all_pairs_reference(
5434 &reference_model,
5435 "AWS::S3::Bucket",
5436 &identifier_properties,
5437 );
5438 let reference_budget = reference_model.conditions.sat_iterations_used();
5439
5440 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
5441 let grouped = model.primary_identifier_conflicts("AWS::S3::Bucket", &identifier_properties);
5442 let grouped_budget = model.conditions.sat_iterations_used();
5443
5444 assert_eq!(
5445 grouped, reference,
5446 "grouped traversal must produce the identical conflicts as the all-pairs reference"
5447 );
5448 assert!(
5449 reference_budget > 0,
5450 "the fixture must drive the satisfiability solver so the budget comparison is meaningful"
5451 );
5452 assert_eq!(
5453 grouped_budget, reference_budget,
5454 "grouped traversal must issue satisfiability queries in the same order and count, spending the shared budget identically across multiple identities"
5455 );
5456 }
5457
5458 #[test]
5459 fn output_invalid_ref_has_canonical_path() {
5460 let template = "Resources:\n R:\n Type: T\nOutputs:\n Bad:\n Value: !Ref Missing\n";
5461 let result = SemanticModel::parse(template.as_bytes(), ParseConfig::default()).unwrap();
5462 let ref_defect = result
5463 .model
5464 .diagnostics
5465 .iter()
5466 .find(|d| d.rule_id == "F6101" && d.message.contains("Missing"))
5467 .expect("F6101 for missing ref");
5468 assert_eq!(
5469 ref_defect.property_path.as_deref(),
5470 Some("Outputs.Bad.Value.Ref"),
5471 "parse-time output ref diagnostic must have dot-separated path"
5472 );
5473 assert_ne!(ref_defect.span, crate::UNKNOWN_SPAN, "span must be resolved");
5474 }
5475}