1use crate::budget::{BudgetKind, BudgetTracker};
2use crate::conditions::{ConditionModel, Satisfiability};
3use crate::consts::*;
4use crate::defect::{DefectPhase, ParseDefect};
5use crate::graph::ReferenceGraph;
6use crate::ir::*;
7use crate::is_custom_resource_type;
8use crate::json_value::JsonValue;
9use crate::regions::*;
10use crate::resolved_value::*;
11use crate::resolver::*;
12use crate::sam;
13use crate::span::SpanProvider;
14use log::{debug, info, warn};
15use serde::Serialize;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
17use std::sync::Arc;
18use std::sync::Mutex;
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20
21#[derive(Debug, Clone, Serialize, Default)]
23#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
24#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
25#[serde(rename_all = "camelCase")]
26pub struct PathValuePair {
27 pub path: String,
29 pub value: String,
30}
31
32#[derive(Debug, Clone, Serialize, Default)]
34#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
35#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
36#[serde(rename_all = "camelCase")]
37pub struct ConditionalNullEntry {
38 pub path: String,
40 pub condition: String,
42 pub null_in_true_branch: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Default)]
48#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
49#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
50#[serde(rename_all = "camelCase")]
51pub struct ResourceDiagnostics {
52 pub find_in_map_refs: Vec<String>,
54 pub simple_subs: Vec<PathValuePair>,
56 pub redundant_subs: Vec<String>,
58 pub empty_joins: Vec<String>,
60 pub condition_refs: Vec<String>,
62 pub hardcoded_partition_arns: Vec<String>,
64 pub conditionally_null_props: Vec<ConditionalNullEntry>,
65 pub foreach_expansions: Vec<ForEachExpansion>,
66 pub unsubstituted_variables: Vec<PathValuePair>,
68 pub unused_sub_keys: Vec<PathValuePair>,
70 pub raw_pseudo_params: Vec<PathValuePair>,
72 pub secretsmanager_ref_paths: Vec<String>,
74 pub invalid_refs: Vec<PathValuePair>,
76}
77
78#[derive(Debug, Clone, Serialize)]
80#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
81#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
82#[serde(rename_all = "camelCase")]
83pub struct ResolvedResource {
84 pub logical_id: String,
85 pub resource_type: String,
86 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
87 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
88 pub condition: Option<String>,
89 pub depends_on: Vec<String>,
90 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
91 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
92 pub deletion_policy: Option<ResolvedValue>,
93 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
94 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
95 pub update_replace_policy: Option<ResolvedValue>,
96 #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
97 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
98 pub update_policy: Option<JsonValue>,
99 #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
100 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
101 pub creation_policy: Option<JsonValue>,
102 #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
103 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
104 pub metadata: Option<JsonValue>,
105 #[cfg_attr(feature = "wasm-bindings", tsify(type = "Record<string, ResolvedValue>"))]
106 pub properties: HashMap<String, ResolvedValue>,
107 pub properties_dynamic: bool,
111 pub diagnostics: ResourceDiagnostics,
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct LifecycleAttributeStatus {
118 pub may_be_present: bool,
120 pub invalid_value: Option<String>,
122}
123
124struct PrimaryIdentifierScenario {
125 identity_tuple: Vec<String>,
126 display_tuple: Vec<String>,
127 assumptions: Vec<(String, bool)>,
128}
129
130#[derive(Debug, Clone, Serialize)]
132#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
133#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
134#[serde(rename_all = "camelCase")]
135pub struct ForEachExpansion {
136 pub property_path: String,
138 pub identifier: String,
140 pub collection_source: String,
142}
143
144#[derive(Debug, Clone, Serialize)]
146#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
147#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
148#[serde(rename_all = "camelCase")]
149pub struct ResolvedOutput {
150 pub value: ResolvedValue,
151 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
152 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
153 pub description: Option<String>,
154 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
155 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
156 pub condition: Option<String>,
157 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
158 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
159 pub export_name: Option<ResolvedValue>,
160}
161
162#[derive(Debug, Clone, Serialize)]
163#[serde(rename_all = "camelCase")]
164pub struct TemplateRule {
165 pub name: String,
166 pub condition: Option<serde_json::Value>,
167 #[serde(skip)]
168 pub condition_node: crate::ir::NodeRef,
169 pub assertions: Vec<RuleAssertion>,
170}
171
172#[derive(Debug, Clone, Serialize)]
173#[serde(rename_all = "camelCase")]
174pub struct RuleAssertion {
175 pub assert: serde_json::Value,
176 #[serde(skip)]
177 pub assert_node: crate::ir::NodeRef,
178 pub description: Option<String>,
179}
180
181pub struct SemanticModel {
182 pub arena: Arena,
183 pub span_index: SourceSpanIndex,
184 pub format_version: Option<String>,
185 pub description: Option<String>,
186 pub transforms: Vec<String>,
187 pub raw_top_level_keys: Vec<String>,
188 pub template_metadata: Option<serde_json::Value>,
189 pub rules: Option<serde_json::Value>,
190 pub parsed_rules: Vec<TemplateRule>,
191 pub parameters: HashMap<String, ParameterInfo>,
192 pub mappings: MappingData,
193 pub conditions: ConditionModel,
194 pub resources: HashMap<String, ResolvedResource>,
195 pub outputs: HashMap<String, ResolvedOutput>,
196 pub graph: ReferenceGraph,
197 pub resources_by_type: HashMap<String, Vec<String>>,
198 pub diagnostics: Vec<ParseDefect>,
199 pub output_empty_joins: Vec<String>,
200 pub sam_globals: HashMap<String, HashMap<String, serde_json::Value>>,
201 pub sam_implicit_resources: HashSet<String>,
202 pub globals_param_refs: Vec<String>,
203 pub is_cdk: bool,
204 pub fn_if_conditions: Vec<String>,
205 pub find_in_map_names: HashSet<String>,
208 pub params_referenced_in_definitions: HashSet<String>,
211 pub has_dynamic_findinmap_name: bool,
215 pub resolution_sources: HashMap<(String, String), String>,
216 value_nodes: HashMap<(String, String), NodeRef>,
219 lifecycle_attribute_nodes: HashMap<(String, String), NodeRef>,
222 lifecycle_attribute_status_cache: Mutex<HashMap<(String, String), LifecycleAttributeStatus>>,
223 invalid_resource_conditions: HashSet<String>,
226 invalid_inline_conditions: HashSet<String>,
230 resolve_memo: Mutex<HashMap<(String, String), Option<ResolvedValue>>>,
231 raw_scenario_memo: Mutex<HashMap<(String, String), Vec<(ResolvedValue, HashMap<String, bool>)>>>,
232 properties_scenario_cache: Mutex<HashMap<String, Vec<(ResolvedValue, HashMap<String, bool>)>>>,
233 scenario_memo: Mutex<HashMap<(String, String), Vec<(serde_json::Value, HashMap<String, bool>)>>>,
234 lifecycle_policy_scenario_cache: Mutex<HashMap<(String, String), Vec<(serde_json::Value, HashMap<String, bool>)>>>,
235 scenario_combinations_used: AtomicU64,
240 budget_tracker: Arc<BudgetTracker>,
243 scenario_expansion_lock: Mutex<()>,
248 scenario_expansion_curtailed: AtomicBool,
252}
253
254#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
257#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
258#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
259#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
260#[serde(rename_all = "camelCase")]
261pub struct PseudoParameterOverrides {
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
264 pub account_id: Option<String>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
267 pub notification_arns: Option<String>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
270 pub partition: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
273 pub region: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
276 pub stack_id: Option<String>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
279 pub stack_name: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
282 pub url_suffix: Option<String>,
283}
284
285fn is_account_id_shaped(value: &str) -> bool {
286 value.len() == 12 && value.bytes().all(|b| b.is_ascii_digit())
287}
288
289fn is_partition_shaped(value: &str) -> bool {
290 value.starts_with("aws")
291}
292
293impl PseudoParameterOverrides {
294 pub fn region(&self) -> &str {
295 self.region.as_deref().unwrap_or(DEFAULT_REGION)
296 }
297
298 pub fn get(&self, name: &str) -> Option<String> {
299 match name {
300 PSEUDO_ACCOUNT_ID => Some(self.account_id.clone().unwrap_or_else(|| DEFAULT_ACCOUNT_ID.into())),
301 PSEUDO_NOTIFICATION_ARNS => Some(self.notification_arns.clone().unwrap_or_else(|| {
302 let r = self.region();
303 let p = self.partition.as_deref().unwrap_or_else(|| partition_for_region(r));
304 format!("arn:{}:sns:{}:{}:notification", p, r, DEFAULT_ACCOUNT_ID)
305 })),
306 PSEUDO_PARTITION => {
307 Some(self.partition.clone().unwrap_or_else(|| partition_for_region(self.region()).into()))
308 }
309 PSEUDO_REGION => Some(self.region().to_string()),
310 PSEUDO_STACK_ID => Some(self.stack_id.clone().unwrap_or_else(|| {
311 let r = self.region();
312 let p = self.partition.as_deref().unwrap_or_else(|| partition_for_region(r));
313 format!(
314 "arn:{}:cloudformation:{}:{}:stack/{}/51af3dc0-da77-11e4-872e-1234567db123",
315 p, r, DEFAULT_ACCOUNT_ID, DEFAULT_STACK_NAME
316 )
317 })),
318 PSEUDO_STACK_NAME => Some(self.stack_name.clone().unwrap_or_else(|| DEFAULT_STACK_NAME.into())),
319 PSEUDO_URL_SUFFIX => {
320 Some(self.url_suffix.clone().unwrap_or_else(|| url_suffix_for_region(self.region()).into()))
321 }
322 _ => None,
323 }
324 }
325
326 #[must_use]
327 pub fn invalid_overrides(&self) -> Vec<String> {
328 let mut problems = Vec::new();
329 if let Some(account_id) = self.account_id.as_deref()
330 && !is_account_id_shaped(account_id)
331 {
332 problems.push(format!("{PSEUDO_ACCOUNT_ID} override '{account_id}' is not a 12-digit AWS account ID"));
333 }
334 if let Some(partition) = self.partition.as_deref()
335 && !is_partition_shaped(partition)
336 {
337 problems.push(format!(
338 "{PSEUDO_PARTITION} override '{partition}' is not a valid partition \
339 (expected an 'aws'-family value such as aws, aws-cn, or aws-us-gov)"
340 ));
341 }
342 problems
343 }
344
345 pub fn fixed_value(&self, name: &str) -> Option<String> {
358 match name {
359 PSEUDO_ACCOUNT_ID => self.account_id.clone(),
360 PSEUDO_NOTIFICATION_ARNS => self.notification_arns.clone(),
361 PSEUDO_PARTITION => self.partition.clone(),
362 PSEUDO_REGION => self.region.clone(),
363 PSEUDO_STACK_ID => self.stack_id.clone(),
364 PSEUDO_STACK_NAME => self.stack_name.clone(),
365 PSEUDO_URL_SUFFIX => self.url_suffix.clone(),
366 _ => None,
367 }
368 }
369}
370
371#[derive(Default)]
372pub struct ParseConfig {
373 pub parameters: HashMap<String, String>,
374 pub pseudo_parameters: PseudoParameterOverrides,
375}
376
377#[must_use]
378pub struct ParseResult {
379 pub model: SemanticModel,
380 pub model_build_ms: f64,
382}
383
384impl SemanticModel {
385 pub fn from_bytes(bytes: &[u8]) -> Result<Self, ParseError> {
386 Self::parse(bytes, Default::default()).map(|r| r.model)
387 }
388
389 pub fn parse(bytes: &[u8], config: ParseConfig) -> Result<ParseResult, ParseError> {
390 if bytes.len() > MAX_TEMPLATE_SIZE_BYTES {
391 return Err(ParseError {
392 message: format!("Template exceeds maximum size of {}MB", MAX_TEMPLATE_SIZE_BYTES / (1024 * 1024)),
393 line: None,
394 column: None,
395 });
396 }
397
398 let total_start = web_time::Instant::now();
399
400 info!("Phase 1: Parsing IR ({} bytes)", bytes.len());
401 let mut ir = crate::parser::parse(bytes)?;
402 let foreach_diagnostics = crate::transform_expansion::expand_language_extensions(&mut ir);
403 let condition_shape_diagnostics =
404 crate::parser::condition_shape::validate_condition_bodies(&ir.arena, ir.conditions, &ir.span_index);
405 let (parameters, parameter_diagnostics) = extract_parameters(&ir);
406 let params_referenced_in_definitions = collect_parameter_definition_refs(&ir, ¶meters);
411 let (mappings, mapping_diagnostics) = extract_mappings(&ir);
412
413 let budget_tracker = Arc::new(BudgetTracker::new());
416
417 let mut conditions = ConditionModel::from_ir_with_tracker(
418 &ir,
419 ¶meters,
420 &config.pseudo_parameters,
421 &mappings,
422 Arc::clone(&budget_tracker),
423 );
424
425 let resource_ids: Vec<String> = if ir.resources != NULL_REF {
426 ir.arena
427 .as_map(ir.resources)
428 .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
429 .unwrap_or_default()
430 } else {
431 vec![]
432 };
433 let output_count =
434 if ir.outputs != NULL_REF { ir.arena.as_map(ir.outputs).map(|e| e.len()).unwrap_or(0) } else { 0 };
435 info!("Phase 2: Resolving {} resources, {} outputs", resource_ids.len(), output_count);
436
437 let mut resolver = Resolver::new(
438 &ir.arena,
439 ¶meters,
440 &mappings,
441 resource_ids.iter().cloned().collect(),
442 &config.parameters,
443 &config.pseudo_parameters,
444 );
445 let mut resources = HashMap::new();
446 let mut lifecycle_attribute_nodes = HashMap::new();
447 let mut invalid_resource_conditions = HashSet::new();
448 if ir.resources != NULL_REF
449 && let Some(entries) = ir.arena.as_map(ir.resources)
450 {
451 for (rname, rnode) in entries {
454 if let Some(props) = ir.arena.as_map(*rnode) {
455 for (key, val) in props {
456 if key == KEY_PROPERTIES
457 && let Some(prop_entries) = ir.arena.as_map(*val)
458 && let Some((_, subs_ref)) =
459 prop_entries.iter().find(|(k, _)| k == "DefinitionSubstitutions")
460 && let Some(subs) = ir.arena.as_map(*subs_ref)
461 {
462 resolver
463 .def_subs_resources
464 .entry(rname.clone())
465 .or_default()
466 .extend(subs.iter().map(|(k, _)| k.clone()));
467 }
468 }
469 }
470 }
471 for (name, node_ref) in entries.iter().cloned() {
472 if let Some(resource_entries) = ir.arena.as_map(node_ref) {
473 for attribute in
474 [KEY_CREATION_POLICY, KEY_UPDATE_POLICY, KEY_DELETION_POLICY, KEY_UPDATE_REPLACE_POLICY]
475 {
476 if let Some((_, value_ref)) = resource_entries.iter().find(|(key, _)| key == attribute) {
477 lifecycle_attribute_nodes.insert((name.clone(), attribute.to_string()), *value_ref);
478 }
479 }
480 }
481 if invalid_resource_condition_ref(&ir.arena, node_ref).is_some() {
482 invalid_resource_conditions.insert(name.clone());
483 }
484 if name != FN_TRANSFORM && !name.starts_with(FN_FOR_EACH_KEY_PREFIX) {
488 let has_sam_transform = ir.transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
489 let shape_defects =
490 validate_resource_shape(&ir.arena, &name, node_ref, has_sam_transform, &ir.span_index);
491 resolver.diagnostics.extend(shape_defects);
492 }
493 resolver.set_current_resource(&name);
494 let resolved = resolve_resource(&ir.arena, &name, node_ref, &mut resolver);
495 resources.insert(name.clone(), resolved);
496 }
497 }
498 for (name, res) in &resources {
499 debug!(
500 "Resolved '{}' ({}): {} properties, {} edges, condition={:?}",
501 name,
502 res.resource_type,
503 res.properties.len(),
504 resolver.edges.iter().filter(|e| e.source_resource == *name).count(),
505 res.condition
506 );
507 }
508
509 let mut outputs = HashMap::new();
510 if ir.outputs != NULL_REF
511 && let Some(entries) = ir.arena.as_map(ir.outputs)
512 {
513 for (name, node_ref) in entries.iter().cloned() {
514 resolver.set_current_resource(&format!("{}{}", OUTPUT_PSEUDO_RESOURCE_PREFIX, name));
515 resolver.set_current_path(&format!("Outputs/{}/Value", name));
516 let resolved = resolve_output(&ir.arena, node_ref, &mut resolver);
517 outputs.insert(name.clone(), resolved);
518 }
519 }
520
521 if ir.rules != NULL_REF
526 && let Some(rule_entries) = ir.arena.as_map(ir.rules)
527 {
528 for (rule_name, rule_node) in rule_entries.iter().cloned() {
529 resolver.set_current_resource(&format!("{}{}", RULE_PSEUDO_RESOURCE_PREFIX, rule_name));
530 let cond_ref = ir.arena.map_get(rule_node, KEY_RULE_CONDITION);
531 if let Some(cond_ref) = cond_ref {
532 resolver.set_current_path(&format!("Rules/{}/{}", rule_name, KEY_RULE_CONDITION));
533 resolver.resolve_node(cond_ref);
534 }
535 let assertions_ref = ir.arena.map_get(rule_node, KEY_ASSERTIONS);
536 if let Some(assertions_ref) = assertions_ref
537 && let Some(assertion_items) = ir.arena.as_list(assertions_ref)
538 {
539 for (idx, item_ref) in assertion_items.to_vec().iter().enumerate() {
540 if let Some(assert_ref) = ir.arena.map_get(*item_ref, KEY_ASSERT) {
541 resolver.set_current_path(&format!(
542 "Rules/{}/{}/{}/{}",
543 rule_name, KEY_ASSERTIONS, idx, KEY_ASSERT
544 ));
545 resolver.resolve_node(assert_ref);
546 }
547 }
548 }
549 }
550 }
551
552 info!("Phase 3: Building reference graph from {} resolver edges", resolver.edges.len());
553
554 let invalid_inline_conditions = resolver.inline_conditions.iter().map(|(name, _)| name.clone()).collect();
558 conditions.register_inline_batch(resolver.inline_conditions.drain(..));
559
560 let mut find_in_map_names: std::collections::HashSet<String> = std::collections::HashSet::new();
566 let mut has_dynamic_findinmap_name = false;
567 for idx in 0..ir.arena.len() {
568 if let Node::Intrinsic(IntrinsicFn::FindInMap(map_name_ref, _, _, _)) = ir.arena.node(idx as NodeRef) {
569 match ir.arena.as_str(*map_name_ref) {
570 Some(name) => {
571 find_in_map_names.insert(name.to_string());
572 }
573 None => has_dynamic_findinmap_name = true,
574 }
575 }
576 }
577
578 let resolution_sources = resolver.resolution_sources();
579 let value_nodes = resolver.value_nodes();
580 let resolver_depth_exceeded = resolver.depth_exceeded;
581 let resolver_enum_expansion_exceeded = resolver.enum_expansion_exceeded;
582 let mut all_edges = resolver.edges;
583 for (id, res) in &resources {
584 for dep in &res.depends_on {
585 all_edges.push(ResolverEdge {
586 source_resource: id.clone(),
587 source_path: KEY_DEPENDS_ON.to_string(),
588 target: dep.clone(),
589 kind: RefKind::DependsOn,
590 span: UNKNOWN_SPAN,
591 condition_context: None,
592 });
593 }
594 }
595 let graph = ReferenceGraph::build(all_edges, &resource_ids);
596
597 let mut resources_by_type: HashMap<String, Vec<String>> = HashMap::new();
598 for (id, res) in &resources {
599 resources_by_type.entry(res.resource_type.clone()).or_default().push(id.clone());
600 }
601 for ids in resources_by_type.values_mut() {
607 ids.sort_by_key(|id| {
608 let key = format!("Resources/{}", id);
609 ir.span_index.get(key.as_str()).map(|s| (s.start_line, s.start_column)).unwrap_or((u32::MAX, u32::MAX))
610 });
611 }
612
613 let mut diagnostics = ir.diagnostics;
614 diagnostics.extend(foreach_diagnostics);
615 diagnostics.extend(condition_shape_diagnostics);
616 diagnostics.extend(mapping_diagnostics);
617 diagnostics.extend(parameter_diagnostics);
618
619 diagnostics.extend(crate::nesting::validate_intrinsic_nesting(&ir.arena));
620 diagnostics.extend(crate::intrinsic_arg_shapes::validate_intrinsic_arg_shapes(&ir.arena, &ir.transforms));
621 diagnostics.extend(crate::lang_ext_shapes::validate_lang_ext_parameter_shapes(&ir.arena, &ir.transforms));
622 diagnostics.extend(crate::language_extensions::validate_language_extensions(&ir.arena, &ir.transforms));
623 let lifecycle_resource_conditions: HashMap<String, String> = resources
624 .iter()
625 .filter_map(|(resource_id, resource)| {
626 resource.condition.as_ref().map(|condition| (resource_id.clone(), condition.clone()))
627 })
628 .collect();
629 diagnostics.extend(crate::language_extensions::validate_lifecycle_intrinsics(
630 &ir.arena,
631 &conditions,
632 &lifecycle_attribute_nodes,
633 &lifecycle_resource_conditions,
634 ));
635 diagnostics.extend(crate::dynamic_ref::validate_dynamic_references(&ir.arena, ir.resources));
636
637 let mut fn_if_conditions: Vec<String> = Vec::new();
638 for idx in 0..ir.arena.len() {
639 match ir.arena.node(idx as NodeRef) {
640 Node::Intrinsic(IntrinsicFn::If(cond_name, _, _)) => {
641 fn_if_conditions.push(cond_name.clone());
642 let in_conditions_body =
647 ir.arena.get(idx as NodeRef).path.split('/').next() == Some(SECTION_CONDITIONS);
648 if !in_conditions_body && !conditions.conditions.contains_key(cond_name) {
649 let condition_path = format!("{}/{}/0", ir.arena.get(idx as NodeRef).path, FN_IF);
656 diagnostics.push(crate::make_parse_defect_at(
657 "E1028",
658 format!("Fn::If condition '{}' does not exist in Conditions section", cond_name),
659 ir.span_index
660 .get(&condition_path)
661 .copied()
662 .unwrap_or_else(|| ir.arena.span(idx as NodeRef)),
663 &condition_path,
664 ));
665 }
666 }
667 Node::Map(entries) if entries.len() == 1 && entries[0].0 == FN_IF => {
674 if let Some(first) = ir.arena.as_list(entries[0].1).and_then(|items| items.first())
675 && let Some(cond_name) = ir.arena.as_str(*first)
676 {
677 fn_if_conditions.push(cond_name.to_string());
678 let in_conditions_body =
684 ir.arena.get(idx as NodeRef).path.split('/').next() == Some(SECTION_CONDITIONS);
685 if !in_conditions_body && !conditions.conditions.contains_key(cond_name) {
686 diagnostics.push(crate::make_parse_defect_at(
687 "E1028",
688 format!("Fn::If condition '{}' does not exist in Conditions section", cond_name),
689 ir.arena.span(*first),
690 &ir.arena.get(*first).path,
691 ));
692 }
693 }
694 }
695 _ => {}
696 }
697 }
698 fn_if_conditions.sort();
699 fn_if_conditions.dedup();
700 let mut output_empty_joins: Vec<String> = Vec::new();
701 for (key, joins) in &resolver.empty_joins {
702 if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
703 output_empty_joins.extend(joins.iter().cloned());
704 }
705 }
706
707 {
714 let mut output_raw_pseudo: Vec<(String, String)> = Vec::new();
715 for (key, entries) in &resolver.raw_pseudo_params {
716 if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
717 for (path, value) in entries {
718 output_raw_pseudo.push((path.clone(), value.clone()));
719 }
720 }
721 }
722 output_raw_pseudo.sort();
723 for (path, value) in output_raw_pseudo {
724 diagnostics.push(crate::make_parse_defect(
725 "W1054",
726 format!(
727 "Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead",
728 value, value
729 ),
730 ir.span_index.get(&path).copied().unwrap_or(UNKNOWN_SPAN),
731 ));
732 }
733 }
734
735 {
740 let mut param_names: Vec<&String> = parameters.keys().collect();
741 param_names.sort();
742 for pname in param_names {
743 if let Some(default) = ¶meters[pname].default
744 && PSEUDO_PARAMETERS.contains(&default.as_str())
745 {
746 diagnostics.push(crate::make_parse_defect(
747 "W1054",
748 format!("Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead", default, default),
749 ir.span_index.get(&format!("Parameters/{}/Default", pname)).copied().unwrap_or(UNKNOWN_SPAN),
750 ));
751 }
752 }
753 }
754
755 for idx in 0..ir.arena.len() {
761 let spanned = ir.arena.get(idx as NodeRef);
762 let Node::String(s) = &spanned.node else {
763 continue;
764 };
765 let section = spanned.path.split('/').next().unwrap_or("");
766 let is_ref_target = spanned.path.rsplit('/').next() == Some(FN_REF);
771 if (section == SECTION_MAPPINGS || section == SECTION_CONDITIONS)
772 && !is_ref_target
773 && PSEUDO_PARAMETERS.contains(&s.as_str())
774 {
775 diagnostics.push(crate::make_parse_defect(
776 "W1054",
777 format!(
778 "Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead",
779 s, s
780 ),
781 spanned.span,
782 ));
783 }
784 }
785
786 {
791 let mut output_unsubstituted: Vec<(String, String)> = Vec::new();
792 for (key, entries) in &resolver.unsubstituted_variables {
793 if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
794 for (path, variable) in entries {
795 output_unsubstituted.push((path.clone(), variable.clone()));
796 }
797 }
798 }
799 output_unsubstituted.sort();
800 for (path, variable) in output_unsubstituted {
801 diagnostics.push(crate::make_parse_defect(
802 "E1029",
803 format!("Found an embedded parameter '{}' outside of an 'Fn::Sub' at {}", variable, path),
804 ir.span_index.get(&path).copied().unwrap_or(UNKNOWN_SPAN),
805 ));
806 }
807 }
808
809 diagnostics.extend(resolver.diagnostics);
810 let is_sam = ir.transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
816 for d in graph.cycle_diagnostics(&ir.span_index) {
817 if is_sam && sam::cycle_involves_sam_diagnostic(&d, &resources) {
818 continue;
819 }
820 diagnostics.push(d);
821 }
822 for (cond_name, always_val) in conditions.tautological_equals() {
823 let result_str = if always_val { "True" } else { "False" };
824 diagnostics.push(crate::make_parse_defect_at(
825 "W8003",
826 format!("Fn::Equals in condition '{}' will always return {}", cond_name, result_str),
827 ir.span_index.get(&format!("Conditions/{}", cond_name)).copied().unwrap_or(UNKNOWN_SPAN),
828 &format!("Conditions/{}", cond_name),
829 ));
830 }
831 {
838 let mut resource_ids_sorted: Vec<&String> = resources.keys().collect();
839 resource_ids_sorted.sort();
840 for rid in resource_ids_sorted {
841 if let Some(cond) = &resources[rid].condition
842 && !conditions.conditions.contains_key(cond)
843 {
844 diagnostics.push(crate::make_parse_defect_for_resource(
845 "E8002",
846 format!("Condition '{}' referenced by resource '{}' is not defined", cond, rid),
847 ir.span_index.get(&format!("Resources/{}", rid)).copied().unwrap_or(UNKNOWN_SPAN),
848 rid,
849 ));
850 }
851 }
852 let mut output_names_sorted: Vec<&String> = outputs.keys().collect();
853 output_names_sorted.sort();
854 for oname in output_names_sorted {
855 if let Some(cond) = &outputs[oname].condition
856 && !conditions.conditions.contains_key(cond)
857 {
858 diagnostics.push(crate::make_parse_defect_for_resource(
859 "E6005",
860 format!("Condition '{}' referenced by output '{}' is not defined", cond, oname),
861 ir.span_index.get(&format!("Outputs/{}", oname)).copied().unwrap_or(UNKNOWN_SPAN),
862 oname,
863 ));
864 }
865 }
866 }
867 for (owner, undefined_ref) in conditions.undefined_condition_refs() {
868 if owner.starts_with("__") || undefined_ref.starts_with("__") {
871 continue;
872 }
873 diagnostics.push(crate::make_parse_defect(
874 "E8007",
875 format!("Condition '{}' references undefined condition '{}'", owner, undefined_ref),
876 ir.span_index.get(&format!("Conditions/{}", owner)).copied().unwrap_or(UNKNOWN_SPAN),
877 ));
878 }
879 for cycle in conditions.detect_cycles() {
880 let cycle_desc = cycle.join(" -> ");
881 let first = &cycle[0];
882 diagnostics.push(crate::make_parse_defect(
883 "E9106",
884 format!("Circular dependency in conditions: {}", cycle_desc),
885 ir.span_index.get(&format!("Conditions/{}", first)).copied().unwrap_or(UNKNOWN_SPAN),
886 ));
887 }
888 for (first, other) in conditions.detect_equivalent_conditions() {
889 diagnostics.push(crate::make_parse_defect(
890 "W9053",
891 format!("Condition '{}' is equivalent to condition '{}' - consider consolidating", other, first),
892 ir.span_index.get(&format!("Conditions/{}", other)).copied().unwrap_or(UNKNOWN_SPAN),
893 ));
894 }
895 let model_build_ms = total_start.elapsed().as_secs_f64() * 1000.0;
901
902 if !diagnostics.is_empty() {
903 warn!("{} parse-time diagnostics", diagnostics.len());
904 }
905 let total_props: usize = resources.values().map(|r| r.properties.len()).sum();
906 info!(
907 "Semantic model: {} resources ({} types, {} total properties), {} outputs, {} edges, {} conditions, {} diagnostics",
908 resources.len(),
909 resources_by_type.len(),
910 total_props,
911 outputs.len(),
912 graph.edges.len(),
913 conditions.conditions.len(),
914 diagnostics.len()
915 );
916
917 let template_metadata =
918 if ir.template_metadata != NULL_REF { Some(node_to_json(&ir.arena, ir.template_metadata)) } else { None };
919 let rules = if ir.rules != NULL_REF { Some(node_to_json(&ir.arena, ir.rules)) } else { None };
920 let parsed_rules = parse_rules(&rules, &ir.arena, ir.rules);
921 let rule_implications: Vec<(String, crate::ir::NodeRef, Vec<crate::ir::NodeRef>)> = parsed_rules
922 .iter()
923 .map(|r| {
924 let assertion_nodes: Vec<crate::ir::NodeRef> = r.assertions.iter().map(|a| a.assert_node).collect();
925 (r.name.clone(), r.condition_node, assertion_nodes)
926 })
927 .collect();
928 if !rule_implications.is_empty() {
929 conditions.register_rule_implications(&ir.arena, &rule_implications);
930 }
931 let rule_diagnostics = crate::rules::validate_rules(&rules, &ir.arena, ir.rules);
932 diagnostics.extend(rule_diagnostics);
933 let sam_globals = sam::extract_sam_globals(&ir.arena, ir.globals);
934 if !sam_globals.is_empty() {
935 sam::apply_sam_globals(&mut resources, &sam_globals, &mut ir.span_index);
936 }
937 let sam_implicit_resources =
938 if is_sam { sam::collect_sam_implicit_resources(&resources) } else { HashSet::new() };
939 let globals_param_refs =
940 if is_sam { sam::collect_globals_param_refs(&ir.arena, ir.globals) } else { Vec::new() };
941 if is_sam {
942 let parameter_names: HashSet<String> = parameters.keys().cloned().collect();
943 diagnostics.extend(sam::collect_transform_errors(
944 &ir.arena,
945 ir.resources,
946 ir.globals,
947 &resources,
948 ¶meter_names,
949 &ir.span_index,
950 ));
951 }
952 let is_cdk = resources_by_type.contains_key(CDK_METADATA_TYPE);
953
954 if resolver_depth_exceeded {
955 budget_tracker.record(BudgetKind::ResolverDepth);
956 }
957 if resolver_enum_expansion_exceeded {
958 budget_tracker.record(BudgetKind::EnumExpansion);
959 }
960 {
967 let has_module_or_foreach = resources.values().any(|r| r.resource_type.ends_with("::MODULE"))
968 || resources.keys().any(|k| k.contains("Fn::ForEach"));
969 if !has_module_or_foreach {
970 let mut output_invalid_refs: Vec<(String, String, String)> = Vec::new();
971 for (key, entries) in &resolver.invalid_refs {
972 if let Some(output_name) = key.strip_prefix(OUTPUT_PSEUDO_RESOURCE_PREFIX) {
973 for (path, target) in entries {
974 if sam_implicit_resources.contains(target) {
975 continue;
976 }
977 output_invalid_refs.push((output_name.to_string(), path.clone(), target.clone()));
978 }
979 }
980 }
981 output_invalid_refs.sort();
982 for (output_name, path, target) in &output_invalid_refs {
983 let mut defect =
984 ParseDefect::new(
985 "F6101",
986 format!(
987 "Ref '{}' does not reference a valid resource, parameter, or pseudo-parameter",
988 target,
989 ),
990 )
991 .location(ir.span_index.get(path).copied().unwrap_or(UNKNOWN_SPAN))
992 .phase(DefectPhase::Parse);
993 defect = defect.property_path(format!("Outputs.{}.Value.Ref", output_name));
994 diagnostics.push(defect);
995 }
996 }
997 }
998
999 Ok(ParseResult {
1000 model: SemanticModel {
1001 arena: ir.arena,
1002 span_index: ir.span_index,
1003 format_version: ir.format_version,
1004 description: ir.description,
1005 transforms: ir.transforms,
1006 raw_top_level_keys: ir.raw_top_level_keys,
1007 template_metadata,
1008 rules,
1009 parsed_rules,
1010 parameters,
1011 mappings,
1012 conditions,
1013 resources,
1014 outputs,
1015 graph,
1016 resources_by_type,
1017 diagnostics,
1018 output_empty_joins,
1019 sam_globals,
1020 sam_implicit_resources,
1021 globals_param_refs,
1022 is_cdk,
1023 fn_if_conditions,
1024 find_in_map_names,
1025 params_referenced_in_definitions,
1026 has_dynamic_findinmap_name,
1027 resolution_sources,
1028 value_nodes,
1029 lifecycle_attribute_nodes,
1030 lifecycle_attribute_status_cache: Mutex::new(HashMap::new()),
1031 invalid_resource_conditions,
1032 invalid_inline_conditions,
1033 resolve_memo: Mutex::new(HashMap::new()),
1034 raw_scenario_memo: Mutex::new(HashMap::new()),
1035 properties_scenario_cache: Mutex::new(HashMap::new()),
1036 scenario_memo: Mutex::new(HashMap::new()),
1037 lifecycle_policy_scenario_cache: Mutex::new(HashMap::new()),
1038 scenario_combinations_used: AtomicU64::new(0),
1039 budget_tracker,
1040 scenario_expansion_lock: Mutex::new(()),
1041 scenario_expansion_curtailed: AtomicBool::new(false),
1042 },
1043 model_build_ms,
1044 })
1045 }
1046
1047 pub fn resource(&self, id: &str) -> Option<&ResolvedResource> {
1048 self.resources.get(id)
1049 }
1050
1051 #[must_use]
1057 pub fn lifecycle_attribute_status(&self, resource_id: &str, attribute: &str) -> LifecycleAttributeStatus {
1058 if !matches!(
1059 attribute,
1060 KEY_CREATION_POLICY | KEY_UPDATE_POLICY | KEY_DELETION_POLICY | KEY_UPDATE_REPLACE_POLICY
1061 ) {
1062 return LifecycleAttributeStatus::default();
1063 }
1064 let cache_key = (resource_id.to_string(), attribute.to_string());
1065 let mut cache = self.lifecycle_attribute_status_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1066 if let Some(status) = cache.get(&cache_key) {
1067 return status.clone();
1068 }
1069
1070 let mut status = LifecycleAttributeStatus::default();
1071 if let Some(node) = self.lifecycle_attribute_nodes.get(&cache_key) {
1072 let mut assumptions = self
1073 .resources
1074 .get(resource_id)
1075 .and_then(|resource| resource.condition.as_ref())
1076 .map(|condition| vec![(condition.clone(), true)])
1077 .unwrap_or_default();
1078 collect_lifecycle_attribute_status(
1079 &self.arena,
1080 *node,
1081 &self.conditions,
1082 attribute == KEY_UPDATE_POLICY,
1083 &mut assumptions,
1084 &mut status,
1085 );
1086 }
1087 cache.insert(cache_key, status.clone());
1088 status
1089 }
1090
1091 #[must_use]
1095 pub fn lifecycle_policy_scenarios(
1096 &self,
1097 resource_id: &str,
1098 attribute: &str,
1099 ) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
1100 let cache_key = (resource_id.to_string(), attribute.to_string());
1101 let mut cache = self.lifecycle_policy_scenario_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1102 if let Some(scenarios) = cache.get(&cache_key) {
1103 return scenarios.clone();
1104 }
1105
1106 let scenarios = if !self.resource_condition_is_valid(resource_id) {
1107 Vec::new()
1108 } else {
1109 let resolved = match attribute {
1110 KEY_DELETION_POLICY => self.resources.get(resource_id).and_then(|r| r.deletion_policy.as_ref()),
1111 KEY_UPDATE_REPLACE_POLICY => {
1112 self.resources.get(resource_id).and_then(|r| r.update_replace_policy.as_ref())
1113 }
1114 _ => None,
1115 };
1116 match resolved {
1117 None => Vec::new(),
1118 Some(value) => {
1119 let initial_assumptions: HashMap<String, bool> = self
1120 .resources
1121 .get(resource_id)
1122 .and_then(|resource| resource.condition.as_ref())
1123 .map(|condition| [(condition.clone(), true)].into_iter().collect())
1124 .unwrap_or_default();
1125 let assumptions: Vec<_> =
1126 initial_assumptions.iter().map(|(name, value)| (name.clone(), *value)).collect();
1127 if !assumptions.is_empty() && !self.conditions.is_satisfiable(&assumptions) {
1128 Vec::new()
1129 } else {
1130 self.collect_scenarios_with_budget_from(
1131 value,
1132 &initial_assumptions,
1133 MAX_SCENARIO_COMBINATIONS,
1134 MAX_TOTAL_SCENARIO_COMBINATIONS,
1135 )
1136 .0
1137 .into_iter()
1138 .filter_map(|(value, conditions)| {
1139 let assumptions: Vec<_> =
1140 conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
1141 if !assumptions.is_empty() && !self.conditions.is_satisfiable(&assumptions) {
1142 return None;
1143 }
1144 let value = match value {
1145 ResolvedValue::Reference { .. }
1146 | ResolvedValue::Dynamic { .. }
1147 | ResolvedValue::TypedDynamic { .. } => return None,
1148 other => crate::serialization::resolved_value_to_json(&other),
1149 };
1150 Some((value, conditions))
1151 })
1152 .collect()
1153 }
1154 }
1155 }
1156 };
1157 cache.insert(cache_key, scenarios.clone());
1158 scenarios
1159 }
1160
1161 pub fn resources_of_type(&self, type_name: &str) -> &[String] {
1162 self.resources_by_type.get(type_name).map(|v| v.as_slice()).unwrap_or(&[])
1163 }
1164
1165 #[must_use]
1166 pub fn resource_condition_is_valid(&self, resource_id: &str) -> bool {
1167 !self.invalid_resource_conditions.contains(resource_id)
1168 }
1169
1170 #[must_use]
1174 pub fn primary_identifier_conflicts(
1175 &self,
1176 resource_type: &str,
1177 identifier_properties: &[String],
1178 ) -> Vec<(Vec<String>, BTreeSet<String>)> {
1179 let mut per_resource = Vec::new();
1180 for resource_id in self.resources_of_type(resource_type) {
1181 let scenarios = self.primary_identifier_scenarios(resource_id, identifier_properties);
1182 if !scenarios.is_empty() {
1183 per_resource.push((resource_id, scenarios));
1184 }
1185 }
1186
1187 let mut conflicts: BTreeMap<Vec<String>, (Vec<String>, BTreeSet<String>)> = BTreeMap::new();
1188 for left_index in 0..per_resource.len() {
1189 for right_index in (left_index + 1)..per_resource.len() {
1190 let (left_resource, left_scenarios) = &per_resource[left_index];
1191 let (right_resource, right_scenarios) = &per_resource[right_index];
1192 for left in left_scenarios {
1193 for right in right_scenarios {
1194 if left.identity_tuple != right.identity_tuple {
1195 continue;
1196 }
1197 let mut assumptions = left.assumptions.clone();
1198 assumptions.extend(right.assumptions.iter().cloned());
1199 if assumptions.is_empty() || self.conditions.is_satisfiable(&assumptions) {
1200 let (_, resources) = conflicts
1201 .entry(left.identity_tuple.clone())
1202 .or_insert_with(|| (left.display_tuple.clone(), BTreeSet::new()));
1203 resources.insert((*left_resource).clone());
1204 resources.insert((*right_resource).clone());
1205 }
1206 }
1207 }
1208 }
1209 }
1210 conflicts.into_values().collect()
1211 }
1212
1213 fn primary_identifier_scenarios(
1214 &self,
1215 resource_id: &str,
1216 identifier_properties: &[String],
1217 ) -> Vec<PrimaryIdentifierScenario> {
1218 if !self.resource_condition_is_valid(resource_id) {
1219 return Vec::new();
1220 }
1221 let base_assumptions = self
1222 .resources
1223 .get(resource_id)
1224 .and_then(|resource| resource.condition.as_deref())
1225 .map(|condition| vec![(condition.to_string(), true)])
1226 .unwrap_or_default();
1227 let mut scenarios = vec![PrimaryIdentifierScenario {
1228 identity_tuple: Vec::with_capacity(identifier_properties.len()),
1229 display_tuple: Vec::with_capacity(identifier_properties.len()),
1230 assumptions: base_assumptions,
1231 }];
1232
1233 for property in identifier_properties {
1234 let path = format!("Properties.{property}");
1235 let property_scenarios = self.resolve_scenarios_json(resource_id, &path);
1236 let expression_identity =
1237 self.value_identity(resource_id, &path).filter(|identity| identity.starts_with("expr:"));
1238 let effective_scenarios: Vec<(String, String, HashMap<String, bool>)> = if property_scenarios.is_empty()
1239 || (expression_identity.is_some()
1240 && property_scenarios.iter().all(|(_, conditions)| conditions.is_empty()))
1241 {
1242 expression_identity
1243 .map(|identity| {
1244 let display = identity.strip_prefix("expr:").unwrap_or(&identity).to_string();
1245 vec![(identity, display, HashMap::new())]
1246 })
1247 .unwrap_or_default()
1248 } else {
1249 property_scenarios
1250 .into_iter()
1251 .filter_map(|(value, conditions)| {
1252 if value.is_null() {
1253 return None;
1254 }
1255 let display = value.as_str().map(ToOwned::to_owned).unwrap_or_else(|| value.to_string());
1256 let identity = format!("value:{}", crate::value_identity::concrete_value_fingerprint(&value));
1257 Some((identity, display, conditions))
1258 })
1259 .collect()
1260 };
1261 let mut next = Vec::new();
1262 for existing in &scenarios {
1263 for (identity, display, conditions) in &effective_scenarios {
1264 let mut assumptions = existing.assumptions.clone();
1265 let mut consistent = true;
1266 for (condition, truth) in conditions {
1267 if let Some((_, prior)) = assumptions.iter().find(|(name, _)| name == condition) {
1268 if prior != truth {
1269 consistent = false;
1270 break;
1271 }
1272 } else {
1273 assumptions.push((condition.clone(), *truth));
1274 }
1275 }
1276 if consistent {
1277 let mut identity_tuple = existing.identity_tuple.clone();
1278 identity_tuple.push(identity.clone());
1279 let mut display_tuple = existing.display_tuple.clone();
1280 display_tuple.push(display.clone());
1281 next.push(PrimaryIdentifierScenario { identity_tuple, display_tuple, assumptions });
1282 }
1283 }
1284 }
1285 scenarios = next;
1286 if scenarios.is_empty() {
1287 break;
1288 }
1289 }
1290
1291 scenarios.retain(|scenario| {
1292 scenario.identity_tuple.len() == identifier_properties.len()
1293 && (scenario.assumptions.is_empty() || self.conditions.is_satisfiable(&scenario.assumptions))
1294 });
1295 scenarios
1296 }
1297
1298 #[must_use]
1299 pub fn condition_is_valid_for_reachability(&self, condition: &str) -> bool {
1300 !self.invalid_inline_conditions.contains(condition)
1301 }
1302
1303 #[must_use]
1304 pub fn resolve(&self, resource_id: &str, path: &str) -> Option<&ResolvedValue> {
1305 let resource = self.resources.get(resource_id)?;
1306 resource.properties.get(path.strip_prefix("Properties.").unwrap_or(path))
1307 }
1308
1309 #[must_use]
1310 pub fn resolve_deep(&self, resource_id: &str, path: &str) -> Option<ResolvedValue> {
1311 let memo_key = (resource_id.to_string(), path.to_string());
1312 if let Some(memoized) = self.resolve_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&memo_key)
1313 {
1314 return memoized.clone();
1315 }
1316 let resource = self.resources.get(resource_id)?;
1317 let prop_path = path.strip_prefix("Properties.").unwrap_or(path);
1318 let mut segments = prop_path.splitn(2, '.');
1319 let top_key = segments.next()?;
1320 let resolved = resource.properties.get(top_key)?;
1321 let result = match segments.next() {
1322 Some(r) if !r.is_empty() => resolved_value_at_path(resolved, r),
1323 _ => Some(resolved.clone()),
1324 };
1325 self.resolve_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(memo_key, result.clone());
1326 result
1327 }
1328
1329 #[must_use]
1330 pub fn is_from_parameter(&self, resource_id: &str, path: &str) -> bool {
1331 self.parameter_name_at(resource_id, path).is_some()
1332 }
1333
1334 pub(crate) fn authored_sub_template_at(&self, resource_id: &str, path: &str) -> Option<&str> {
1335 let resource_id = resource_id.to_string();
1336 let mut current = path;
1337 loop {
1338 if let Some(node) = self.value_nodes.get(&(resource_id.clone(), current.to_string()))
1339 && let Node::Intrinsic(IntrinsicFn::Sub(template, _)) = self.arena.node(*node)
1340 {
1341 return Some(template.as_str());
1342 }
1343 let (parent, _) = current.rsplit_once('.')?;
1344 current = parent;
1345 }
1346 }
1347
1348 #[must_use]
1354 pub fn parameter_name_at(&self, resource_id: &str, path: &str) -> Option<&str> {
1355 let source = self.resolution_sources.get(&(resource_id.to_string(), path.to_string()))?;
1356 parameter_name_from_source(source)
1357 }
1358
1359 #[must_use]
1369 pub fn value_identity(&self, resource_id: &str, path: &str) -> Option<String> {
1370 let resolved = self.resolve_deep(resource_id, path).or_else(|| self.resolve(resource_id, path).cloned())?;
1371 self.resolved_value_identity(resource_id, path, &resolved)
1372 }
1373
1374 pub(crate) fn resolved_value_identity(
1378 &self,
1379 resource_id: &str,
1380 path: &str,
1381 resolved: &ResolvedValue,
1382 ) -> Option<String> {
1383 let reference_prefix = format!("{path}.");
1384 let depends_on_reference = self
1385 .graph
1386 .outgoing(resource_id)
1387 .iter()
1388 .any(|edge| edge.source_path == path || edge.source_path.starts_with(&reference_prefix));
1389 let as_json = crate::serialization::resolved_value_to_json(resolved);
1390 if !depends_on_reference && !json_contains_markers(&as_json) {
1391 let fingerprint = crate::value_identity::concrete_value_fingerprint(&as_json);
1392 return Some(format!("value:{fingerprint}"));
1393 }
1394 let node = *self.value_nodes.get(&(resource_id.to_string(), path.to_string()))?;
1395 let (result, depth_exhausted) = crate::value_identity::expression_fingerprint_signaled(&self.arena, node);
1396 if depth_exhausted {
1397 self.budget_tracker.record(BudgetKind::ExpressionFingerprintDepth);
1398 }
1399 result.map(|fingerprint| format!("expr:{fingerprint}"))
1400 }
1401
1402 #[must_use]
1406 pub fn is_from_intrinsic(&self, resource_id: &str, path: &str) -> bool {
1407 if self.path_from_intrinsic(resource_id, path) {
1408 return true;
1409 }
1410 let properties_prefix = format!("{}.", KEY_PROPERTIES);
1416 if let Some(rest) = path.strip_prefix(&properties_prefix) {
1417 for branch in ["1", "2"] {
1418 let branch_path = format!("{}.{}.{}.{}", KEY_PROPERTIES, FN_IF, branch, rest);
1419 if self.path_from_intrinsic(resource_id, &branch_path) {
1420 return true;
1421 }
1422 }
1423 }
1424 false
1425 }
1426
1427 #[must_use]
1428 pub fn substituted_paths_under(&self, resource_id: &str, base_path: &str) -> HashSet<String> {
1429 let prefix = format!("{}.", base_path);
1430 let relative_path = |path: &str| {
1431 if path == base_path { Some(String::new()) } else { path.strip_prefix(&prefix).map(String::from) }
1432 };
1433 let mut paths: HashSet<String> =
1434 self.graph.outgoing(resource_id).into_iter().filter_map(|edge| relative_path(&edge.source_path)).collect();
1435 paths.extend(self.resolution_sources.iter().filter_map(|((source_resource, source_path), _)| {
1436 (source_resource == resource_id).then(|| relative_path(source_path)).flatten()
1437 }));
1438 paths
1439 }
1440
1441 fn path_from_intrinsic(&self, resource_id: &str, path: &str) -> bool {
1442 let edges = self.graph.outgoing(resource_id);
1443 let mut p = path.to_string();
1444 loop {
1445 if let Some(src) = self.resolution_sources.get(&(resource_id.to_string(), p.clone()))
1446 && src.starts_with("Intrinsic/")
1447 {
1448 return true;
1449 }
1450 if edges.iter().any(|e| e.source_path == p) {
1453 return true;
1454 }
1455 match p.rfind('.') {
1456 Some(i) => p.truncate(i),
1457 None => return false,
1458 }
1459 }
1460 }
1461
1462 #[must_use]
1467 pub fn scenario_budget_exhausted(&self) -> bool {
1468 self.scenario_combinations_used.load(Ordering::Relaxed) >= MAX_TOTAL_SCENARIO_COMBINATIONS
1469 }
1470
1471 pub fn record_budget_exhaustion(&self, kind: BudgetKind) {
1473 self.budget_tracker.record(kind);
1474 }
1475
1476 pub fn exhausted_budget_kinds(&self) -> BTreeSet<BudgetKind> {
1478 self.budget_tracker.exhausted_kinds()
1479 }
1480
1481 pub fn budget_analysis_incomplete(&self) -> bool {
1483 self.budget_tracker.analysis_incomplete()
1484 }
1485
1486 #[must_use]
1488 pub fn scenario_combinations_used(&self) -> u64 {
1489 self.scenario_combinations_used.load(Ordering::Relaxed)
1490 }
1491
1492 #[must_use]
1497 pub fn scenario_expansion_curtailed(&self) -> bool {
1498 self.scenario_expansion_curtailed.load(Ordering::Relaxed)
1499 }
1500
1501 fn collect_scenarios_with_budget_status(
1502 &self,
1503 value: &ResolvedValue,
1504 per_value_limit: usize,
1505 total_limit: u64,
1506 ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
1507 self.collect_scenarios_with_budget_from(value, &HashMap::new(), per_value_limit, total_limit)
1508 }
1509
1510 fn collect_scenarios_with_budget_from(
1511 &self,
1512 value: &ResolvedValue,
1513 assumptions: &HashMap<String, bool>,
1514 per_value_limit: usize,
1515 total_limit: u64,
1516 ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
1517 let _expansion_guard = self.scenario_expansion_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1518 let used = self.scenario_combinations_used.load(Ordering::Relaxed);
1519 if used >= total_limit {
1520 self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
1521 if total_limit == MAX_TOTAL_SCENARIO_COMBINATIONS {
1522 self.budget_tracker.record(BudgetKind::ScenarioCombinationsTotal);
1523 }
1524 return (Vec::new(), true);
1525 }
1526
1527 let remaining = total_limit - used;
1528 let remaining_limit = usize::try_from(remaining).unwrap_or(usize::MAX);
1529 let effective_limit = per_value_limit.min(remaining_limit);
1530 let mut scenarios = Vec::new();
1531 let was_curtailed = collect_scenarios(value, assumptions, effective_limit, &mut scenarios);
1532 if was_curtailed {
1533 self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
1534 if per_value_limit == MAX_SCENARIO_COMBINATIONS && per_value_limit <= remaining_limit {
1535 self.budget_tracker.record(BudgetKind::ScenarioCombinationsPerValue);
1536 }
1537 if total_limit == MAX_TOTAL_SCENARIO_COMBINATIONS && remaining_limit <= per_value_limit {
1538 self.budget_tracker.record(BudgetKind::ScenarioCombinationsTotal);
1539 }
1540 }
1541 self.scenario_combinations_used.store(used + scenarios.len() as u64, Ordering::Relaxed);
1542 (scenarios, was_curtailed)
1543 }
1544
1545 fn collect_scenarios_with_budget(
1546 &self,
1547 value: &ResolvedValue,
1548 per_value_limit: usize,
1549 total_limit: u64,
1550 ) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
1551 self.collect_scenarios_with_budget_status(value, per_value_limit, total_limit).0
1552 }
1553
1554 #[cfg(test)]
1558 pub(crate) fn add_scenario_combinations_for_test(&self, count: u64) {
1559 let _expansion_guard = self.scenario_expansion_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1560 self.scenario_combinations_used.fetch_add(count, Ordering::Relaxed);
1561 }
1562
1563 #[cfg(test)]
1566 fn set_scenario_expansion_curtailed_for_test(&self) {
1567 self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
1568 }
1569
1570 fn resolved_properties_value(&self, resource_id: &str) -> Option<ResolvedValue> {
1571 let resource = self.resources.get(resource_id)?;
1572 if resource.properties_dynamic {
1573 return None;
1574 }
1575 if resource.properties.len() == 1
1576 && let Some(conditional) = resource.properties.get(FN_IF)
1577 {
1578 return Some(conditional.clone());
1579 }
1580 let mut entries: Vec<MapEntry> = resource
1581 .properties
1582 .iter()
1583 .map(|(key, value)| MapEntry { key: key.clone(), value: value.clone() })
1584 .collect();
1585 entries.sort_by(|left, right| left.key.cmp(&right.key));
1586 Some(ResolvedValue::Map { entries })
1587 }
1588
1589 pub fn resolve_properties_scenarios(&self, resource_id: &str) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
1590 let mut cache = self.properties_scenario_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1591 if let Some(scenarios) = cache.get(resource_id) {
1592 return scenarios.clone();
1593 }
1594 let Some(properties) = self.resolved_properties_value(resource_id) else {
1595 return vec![];
1596 };
1597 let scenarios =
1598 self.collect_scenarios_with_budget(&properties, MAX_SCENARIO_COMBINATIONS, MAX_TOTAL_SCENARIO_COMBINATIONS);
1599 cache.insert(resource_id.to_string(), scenarios.clone());
1600 scenarios
1601 }
1602
1603 pub fn scenario_source_path(
1607 &self,
1608 resource_id: &str,
1609 effective_path: &str,
1610 conditions: &HashMap<String, bool>,
1611 ) -> Option<String> {
1612 let properties = self.resolved_properties_value(resource_id)?;
1613 let relative_path = effective_path
1614 .strip_prefix("Properties.")
1615 .or_else(|| (effective_path == KEY_PROPERTIES).then_some(""))
1616 .unwrap_or(effective_path);
1617 scenario_source_path_at(&properties, relative_path, conditions, KEY_PROPERTIES)
1618 }
1619
1620 pub fn resolve_scenarios(&self, resource_id: &str, path: &str) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
1621 let key = (resource_id.to_string(), path.to_string());
1622 let mut memo = self.raw_scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1623 if let Some(scenarios) = memo.get(&key) {
1624 return scenarios.clone();
1625 }
1626 let scenarios = self.resolve_scenarios_with_limit(resource_id, path, MAX_SCENARIO_COMBINATIONS).0;
1627 memo.insert(key, scenarios.clone());
1628 scenarios
1629 }
1630
1631 pub fn resolve_scenarios_with_limit(
1632 &self,
1633 resource_id: &str,
1634 path: &str,
1635 per_value_limit: usize,
1636 ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
1637 let value = match self.resolve_deep(resource_id, path) {
1638 Some(value) => value,
1639 None => match self.resolve(resource_id, path) {
1640 Some(value) => value.clone(),
1641 None => match self.resolve_via_properties_if(resource_id, path) {
1642 Some(value) => value,
1643 None => return (Vec::new(), false),
1644 },
1645 },
1646 };
1647 self.collect_scenarios_with_budget_status(&value, per_value_limit, MAX_TOTAL_SCENARIO_COMBINATIONS)
1648 }
1649
1650 fn resolve_via_properties_if(&self, resource_id: &str, path: &str) -> Option<ResolvedValue> {
1654 let resource = self.resources.get(resource_id)?;
1655 let conditional = resource.properties.get(FN_IF)?;
1656 let properties_prefix = format!("{}.", KEY_PROPERTIES);
1657 let prop_path = path.strip_prefix(&properties_prefix).unwrap_or(path);
1658 if prop_path.is_empty() {
1659 return Some(conditional.clone());
1660 }
1661 resolved_value_at_path(conditional, prop_path)
1662 }
1663
1664 pub fn resolve_scenarios_json(
1665 &self,
1666 resource_id: &str,
1667 path: &str,
1668 ) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
1669 let memo_key = (resource_id.to_string(), path.to_string());
1670 {
1671 let memo = self.scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1672 if let Some(memoized) = memo.get(&memo_key) {
1673 return memoized.clone();
1674 }
1675 }
1676 let scenarios = self.resolve_scenarios(resource_id, path);
1677 let json_scenarios: Vec<_> = scenarios
1678 .into_iter()
1679 .filter_map(|(val, conds)| {
1680 if contains_dynamic_resolved(&val) {
1681 return None;
1682 }
1683 if !conds.is_empty() {
1684 let assumptions: Vec<(String, bool)> = conds.iter().map(|(k, v)| (k.clone(), *v)).collect();
1685 if !self.conditions.is_satisfiable(&assumptions) {
1686 return None;
1687 }
1688 }
1689 let json = crate::serialization::resolved_value_to_json_clean(&val)?;
1690 if json_contains_markers(&json) {
1691 return None;
1692 }
1693 Some((json, conds))
1694 })
1695 .collect();
1696 let mut memo = self.scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1697 memo.entry(memo_key).or_insert_with(|| json_scenarios).clone()
1698 }
1699
1700 pub fn follow_ref(&self, resource_id: &str, path: &str) -> Option<&str> {
1701 if let Some(ResolvedValue::Reference { target, kind: _ }) = self.resolve(resource_id, path) {
1702 return Some(target.as_str());
1703 }
1704 if let Some(ResolvedValue::Reference { target, kind: _ }) = self.resolve_deep(resource_id, path).as_ref() {
1705 for edge in self.graph.outgoing(resource_id) {
1706 if edge.target == *target {
1707 return Some(&edge.target);
1708 }
1709 }
1710 }
1711 None
1712 }
1713
1714 pub fn source_location(&self, path: &str) -> Option<&SourceSpan> {
1715 self.span_index.get(path)
1716 }
1717
1718 pub fn resource_span(&self, resource_id: &str, prop_path: &str) -> SourceSpan {
1728 if resource_id.is_empty() {
1735 return self.walk_up_span(prop_path).unwrap_or(UNKNOWN_SPAN);
1736 }
1737 if let Some((authored_path, function_name)) = prop_path.rsplit_once('.')
1741 && let Some(node_ref) = self.value_nodes.get(&(resource_id.to_string(), authored_path.to_string()))
1742 && let Node::Intrinsic(intrinsic) = self.arena.node(*node_ref)
1743 && cfn_function_name(intrinsic) == function_name
1744 {
1745 let intrinsic_path = format!("{}/{}", self.arena.get(*node_ref).path, function_name);
1746 if let Some(span) = self.walk_up_span(&intrinsic_path) {
1747 return span;
1748 }
1749 }
1750 let specific = if prop_path.is_empty() {
1751 format!("Resources/{}", resource_id)
1752 } else {
1753 format!("Resources/{}/{}", resource_id, prop_path.replace('.', "/"))
1754 };
1755 self.walk_up_span(&specific).unwrap_or(UNKNOWN_SPAN)
1761 }
1762
1763 fn walk_up_span(&self, key: &str) -> Option<SourceSpan> {
1769 let mut current = key;
1770 loop {
1771 match self.span_index.get(current) {
1776 Some(&span) if span != UNKNOWN_SPAN => return Some(span),
1777 _ => match current.rfind('/') {
1778 Some(cut) => current = ¤t[..cut],
1779 None => return None,
1780 },
1781 }
1782 }
1783 }
1784
1785 pub fn diagnostic_span(&self, resource_id: Option<&str>, property_path: &str) -> Option<SourceSpan> {
1801 let rid = resource_id.filter(|r| !r.is_empty());
1802
1803 if property_path.contains('/') || (rid.is_none() && !property_path.is_empty()) {
1804 if let Some(span) = self.walk_up_span(property_path) {
1806 return Some(span);
1807 }
1808 } else if let Some(rid) = rid {
1809 let key = if property_path.is_empty() {
1812 format!("Resources/{}", rid)
1813 } else {
1814 format!("Resources/{}/{}", rid, property_path.replace('.', "/"))
1815 };
1816 if let Some(span) = self.walk_up_span(&key) {
1817 return Some(span);
1818 }
1819 }
1820
1821 rid.and_then(|rid| self.span_index.get(&format!("Resources/{}", rid)))
1823 .filter(|span| **span != UNKNOWN_SPAN)
1824 .copied()
1825 }
1826
1827 pub fn estimated_string_length_bounds(&self, resource_id: &str, path: &str) -> Option<(usize, usize)> {
1836 let val = self.resolve_deep(resource_id, path).or_else(|| self.resolve(resource_id, path).cloned())?;
1837 if matches!(&val, ResolvedValue::Concrete { value } if value.is_string()) {
1838 return None;
1839 }
1840 estimate_resolved_string_length_bounds(&val)
1841 }
1842}
1843
1844impl SpanProvider for SemanticModel {
1845 fn source_location(&self, path: &str) -> Option<SourceSpan> {
1846 self.span_index.get(path).copied()
1847 }
1848}
1849
1850fn parse_rules(rules_json: &Option<serde_json::Value>, arena: &Arena, rules_node: NodeRef) -> Vec<TemplateRule> {
1851 let Some(rules) = rules_json else {
1852 return Vec::new();
1853 };
1854 let Some(obj) = rules.as_object() else {
1855 return Vec::new();
1856 };
1857
1858 let rule_nodes: HashMap<&str, NodeRef> =
1860 arena.as_map(rules_node).unwrap_or(&[]).iter().map(|(k, v)| (k.as_str(), *v)).collect();
1861
1862 obj.iter()
1863 .filter_map(|(name, rule)| {
1864 let rule_obj = rule.as_object()?;
1865 let condition = rule_obj.get(KEY_RULE_CONDITION).cloned();
1866
1867 let rule_ir = rule_nodes.get(name.as_str()).copied().unwrap_or(NULL_REF);
1869 let condition_node = arena.map_get(rule_ir, KEY_RULE_CONDITION).unwrap_or(NULL_REF);
1870
1871 let assertions_node = arena.map_get(rule_ir, KEY_ASSERTIONS).unwrap_or(NULL_REF);
1872 let assertion_items = arena.as_list(assertions_node).unwrap_or(&[]);
1873
1874 let assertions = rule_obj
1875 .get(KEY_ASSERTIONS)
1876 .and_then(|a| a.as_array())
1877 .map(|arr| {
1878 arr.iter()
1879 .enumerate()
1880 .filter_map(|(idx, a)| {
1881 let a_obj = a.as_object()?;
1882 let assert_node = assertion_items
1883 .get(idx)
1884 .and_then(|item_ref| arena.map_get(*item_ref, KEY_ASSERT))
1885 .unwrap_or(NULL_REF);
1886 Some(RuleAssertion {
1887 assert: a_obj.get(KEY_ASSERT).cloned().unwrap_or(serde_json::Value::Null),
1888 assert_node,
1889 description: a_obj
1890 .get(KEY_ASSERT_DESCRIPTION)
1891 .and_then(|d| d.as_str())
1892 .map(String::from),
1893 })
1894 })
1895 .collect()
1896 })
1897 .unwrap_or_default();
1898 Some(TemplateRule { name: name.clone(), condition, condition_node, assertions })
1899 })
1900 .collect()
1901}
1902
1903fn collect_parameter_definition_refs(ir: &TemplateIR, parameters: &HashMap<String, ParameterInfo>) -> HashSet<String> {
1907 let mut referenced = HashSet::new();
1908 if ir.parameters == NULL_REF {
1909 return referenced;
1910 }
1911 let Some(param_entries) = ir.arena.as_map(ir.parameters) else {
1912 return referenced;
1913 };
1914 for (_, param_ref) in param_entries {
1915 collect_refs_in_subtree(&ir.arena, *param_ref, parameters, &mut referenced);
1916 }
1917 referenced
1918}
1919
1920fn collect_refs_in_subtree(
1921 arena: &Arena,
1922 node_ref: NodeRef,
1923 parameters: &HashMap<String, ParameterInfo>,
1924 referenced: &mut HashSet<String>,
1925) {
1926 match arena.node(node_ref) {
1927 Node::Intrinsic(IntrinsicFn::Ref(target)) => {
1928 if parameters.contains_key(target) {
1929 referenced.insert(target.clone());
1930 }
1931 }
1932 Node::Intrinsic(IntrinsicFn::Sub(_, Some(bindings))) => {
1933 for (_, v) in bindings {
1934 collect_refs_in_subtree(arena, *v, parameters, referenced);
1935 }
1936 }
1937 Node::List(items) => {
1938 for item in items {
1939 collect_refs_in_subtree(arena, *item, parameters, referenced);
1940 }
1941 }
1942 Node::Map(entries) => {
1943 for (_, v) in entries {
1944 collect_refs_in_subtree(arena, *v, parameters, referenced);
1945 }
1946 }
1947 _ => {}
1948 }
1949}
1950
1951fn parameter_name_from_source(source: &str) -> Option<&str> {
1952 let rest = source.strip_prefix(SECTION_PARAMETERS)?.strip_prefix('/')?;
1953 let name = rest.split('/').next()?;
1954 if name.is_empty() { None } else { Some(name) }
1955}
1956
1957fn intrinsic_synthetic_key(arena: &Arena, node_ref: NodeRef) -> Option<String> {
1964 let Node::Intrinsic(func) = arena.node(node_ref) else {
1965 return None;
1966 };
1967 match func {
1968 IntrinsicFn::If(_, _, _) | IntrinsicFn::IfExpr(_, _, _) => Some(FN_IF.to_string()),
1969 IntrinsicFn::ForEach(uid, _, _, _) => Some(format!("{}::{}", FN_FOR_EACH, uid)),
1970 _ => None,
1971 }
1972}
1973
1974const VALID_RESOURCE_ATTRIBUTES: &[&str] = &[
1978 KEY_TYPE,
1979 KEY_PROPERTIES,
1980 KEY_DEPENDS_ON,
1981 KEY_CONDITION,
1982 SECTION_METADATA,
1983 KEY_DELETION_POLICY,
1984 KEY_UPDATE_REPLACE_POLICY,
1985 KEY_UPDATE_POLICY,
1986 KEY_CREATION_POLICY,
1987 FN_TRANSFORM,
1988 "Version",
1989];
1990
1991const SAM_RESOURCE_ATTRIBUTES: &[&str] = &["Connectors", "IgnoreGlobals"];
1995
1996fn invalid_resource_condition_ref(arena: &Arena, node_ref: NodeRef) -> Option<NodeRef> {
1997 let entries = arena.as_map(node_ref)?;
1998 let condition_ref = entries.iter().find(|(key, _)| key == KEY_CONDITION).map(|(_, value)| *value)?;
1999 (!matches!(arena.node(condition_ref), Node::String(_))).then_some(condition_ref)
2000}
2001
2002fn validate_resource_shape(
2006 arena: &Arena,
2007 name: &str,
2008 node_ref: NodeRef,
2009 is_sam: bool,
2010 span_index: &SourceSpanIndex,
2011) -> Vec<ParseDefect> {
2012 let mut out = Vec::new();
2013 let resource_span = span_index.get(&format!("Resources/{}", name)).copied().unwrap_or(UNKNOWN_SPAN);
2014
2015 let entries = match arena.as_map(node_ref) {
2017 Some(entries) => entries,
2018 None => {
2019 let shape = match arena.node(node_ref) {
2020 Node::Null => "null",
2021 Node::Bool(_) => "a boolean",
2022 Node::Int(_) | Node::Float(_) => "a number",
2023 Node::String(_) => "a string",
2024 Node::List(_) => "a list",
2025 Node::Intrinsic(_) => "an intrinsic function",
2026 Node::Map(_) => return out,
2027 };
2028 out.push(crate::make_parse_defect_for_resource(
2029 "E3001",
2030 format!("Resource '{}' body must be an object, got {}", name, shape),
2031 resource_span,
2032 name,
2033 ));
2034 return out;
2035 }
2036 };
2037
2038 match entries.iter().find(|(k, _)| k == KEY_TYPE) {
2040 None => {
2041 out.push(crate::make_parse_defect_for_resource(
2042 "E3001",
2043 format!("Resource '{}' is missing required property 'Type'", name),
2044 resource_span,
2045 name,
2046 ));
2047 }
2048 Some((_, type_ref)) if !matches!(arena.node(*type_ref), Node::String(_)) => {
2049 let type_span = span_index.get(&format!("Resources/{}/Type", name)).copied().unwrap_or(resource_span);
2050 out.push(crate::make_parse_defect_for_resource(
2051 "E3001",
2052 format!("Resource '{}' property 'Type' must be a string", name),
2053 type_span,
2054 name,
2055 ));
2056 }
2057 _ => {}
2058 }
2059
2060 if invalid_resource_condition_ref(arena, node_ref).is_some() {
2062 let cond_span = span_index.get(&format!("Resources/{}/Condition", name)).copied().unwrap_or(resource_span);
2063 out.push(crate::make_parse_defect_for_resource(
2064 "E3001",
2065 format!("Resource '{}' property 'Condition' must be a string", name),
2066 cond_span,
2067 name,
2068 ));
2069 }
2070
2071 if let Some((_, dep_ref)) = entries.iter().find(|(k, _)| k == KEY_DEPENDS_ON) {
2073 match arena.node(*dep_ref) {
2074 Node::String(_) => {}
2075 Node::List(items) => {
2076 for item_ref in items {
2077 if !matches!(arena.node(*item_ref), Node::String(_)) {
2078 let dep_span =
2079 span_index.get(&format!("Resources/{}/DependsOn", name)).copied().unwrap_or(resource_span);
2080 out.push(crate::make_parse_defect_for_resource(
2081 "E3001",
2082 format!("Resource '{}' property 'DependsOn' list elements must be strings", name),
2083 dep_span,
2084 name,
2085 ));
2086 break;
2087 }
2088 }
2089 }
2090 _ => {
2091 let dep_span =
2092 span_index.get(&format!("Resources/{}/DependsOn", name)).copied().unwrap_or(resource_span);
2093 out.push(crate::make_parse_defect_for_resource(
2094 "E3001",
2095 format!("Resource '{}' property 'DependsOn' must be a string or list of strings", name),
2096 dep_span,
2097 name,
2098 ));
2099 }
2100 }
2101 }
2102
2103 let resource_type = entries.iter().find(|(key, _)| key == KEY_TYPE).and_then(|(_, value)| arena.as_str(*value));
2104 if let Some(resource_type) = resource_type {
2105 let is_custom_resource = is_custom_resource_type(resource_type);
2106 if let Some((_, version_ref)) = entries.iter().find(|(key, _)| key == "Version") {
2107 let version_span = span_index.get(&format!("Resources/{}/Version", name)).copied().unwrap_or(resource_span);
2108 if !is_custom_resource {
2109 out.push(crate::make_parse_defect_for_resource(
2110 "E3001",
2111 format!("Resource '{}' property 'Version' is only valid for custom resources", name),
2112 version_span,
2113 name,
2114 ));
2115 } else if !matches!(arena.node(*version_ref), Node::String(_) | Node::Int(_)) {
2116 out.push(crate::make_parse_defect_for_resource(
2117 "E3001",
2118 format!("Resource '{}' property 'Version' must be a string or integer", name),
2119 version_span,
2120 name,
2121 ));
2122 }
2123 }
2124
2125 for policy_name in [KEY_CREATION_POLICY, KEY_UPDATE_POLICY] {
2126 let Some((_, policy_ref)) = entries.iter().find(|(key, _)| key == policy_name) else {
2127 continue;
2128 };
2129 let policy_span =
2130 span_index.get(&format!("Resources/{}/{}", name, policy_name)).copied().unwrap_or(resource_span);
2131 if is_custom_resource {
2132 out.push(crate::make_parse_defect_for_resource(
2133 "E3001",
2134 format!("Resource '{}' property '{}' is not valid for custom resources", name, policy_name),
2135 policy_span,
2136 name,
2137 ));
2138 } else if !matches!(arena.node(*policy_ref), Node::Map(_) | Node::Intrinsic(_)) {
2139 out.push(crate::make_parse_defect_for_resource(
2140 "E3001",
2141 format!("Resource '{}' property '{}' must be an object", name, policy_name),
2142 policy_span,
2143 name,
2144 ));
2145 }
2146 }
2147 }
2148
2149 for (key, _) in entries {
2150 if VALID_RESOURCE_ATTRIBUTES.contains(&key.as_str()) {
2151 continue;
2152 }
2153 if is_sam && SAM_RESOURCE_ATTRIBUTES.contains(&key.as_str()) {
2154 continue;
2155 }
2156 let key_span = span_index.get(&format!("Resources/{}/{}", name, key)).copied().unwrap_or(resource_span);
2157 out.push(crate::make_parse_defect_for_resource(
2158 "E3001",
2159 format!(
2160 "Resource '{}' has invalid property '{}'. Valid resource attributes: {}",
2161 name,
2162 key,
2163 valid_attributes_display(is_sam),
2164 ),
2165 key_span,
2166 name,
2167 ));
2168 }
2169
2170 out
2171}
2172
2173fn valid_attributes_display(is_sam: bool) -> String {
2175 let mut attrs: Vec<&str> = VALID_RESOURCE_ATTRIBUTES.to_vec();
2176 if is_sam {
2177 attrs.extend_from_slice(SAM_RESOURCE_ATTRIBUTES);
2178 }
2179 attrs.join(", ")
2180}
2181
2182fn collect_lifecycle_attribute_status(
2183 arena: &Arena,
2184 node: NodeRef,
2185 conditions: &ConditionModel,
2186 allows_no_value_omission: bool,
2187 assumptions: &mut Vec<(String, bool)>,
2188 status: &mut LifecycleAttributeStatus,
2189) {
2190 if matches!(conditions.satisfiability(assumptions), Satisfiability::Unsatisfiable) {
2191 return;
2192 }
2193
2194 if !allows_no_value_omission {
2195 status.may_be_present = true;
2196 if matches!(
2197 arena.node(node),
2198 Node::Null | Node::Bool(_) | Node::Int(_) | Node::Float(_) | Node::String(_) | Node::List(_)
2199 ) && status.invalid_value.is_none()
2200 {
2201 status.invalid_value = Some(crate::message::render_value(&node_to_json(arena, node)));
2202 }
2203 return;
2204 }
2205
2206 match arena.node(node) {
2207 Node::Intrinsic(IntrinsicFn::Ref(target)) if target == PSEUDO_NO_VALUE => {}
2208 Node::Intrinsic(IntrinsicFn::If(condition, when_true, when_false)) => {
2209 assumptions.push((condition.clone(), true));
2210 collect_lifecycle_attribute_status(
2211 arena,
2212 *when_true,
2213 conditions,
2214 allows_no_value_omission,
2215 assumptions,
2216 status,
2217 );
2218 assumptions.pop();
2219 assumptions.push((condition.clone(), false));
2220 collect_lifecycle_attribute_status(
2221 arena,
2222 *when_false,
2223 conditions,
2224 allows_no_value_omission,
2225 assumptions,
2226 status,
2227 );
2228 assumptions.pop();
2229 }
2230 Node::Intrinsic(_) | Node::Map(_) => status.may_be_present = true,
2231 Node::Null | Node::Bool(_) | Node::Int(_) | Node::Float(_) | Node::String(_) | Node::List(_) => {
2232 status.may_be_present = true;
2233 if status.invalid_value.is_none() {
2234 status.invalid_value = Some(crate::message::render_value(&node_to_json(arena, node)));
2235 }
2236 }
2237 }
2238}
2239
2240fn resolve_resource(arena: &Arena, name: &str, node_ref: NodeRef, resolver: &mut Resolver) -> ResolvedResource {
2241 let entries = arena.as_map(node_ref).unwrap_or(&[]);
2242 let resource_type =
2243 entries.iter().find(|(k, _)| k == KEY_TYPE).and_then(|(_, v)| arena.as_str(*v)).unwrap_or("").to_string();
2244 let condition =
2245 entries.iter().find(|(k, _)| k == KEY_CONDITION).and_then(|(_, v)| arena.as_str(*v)).map(|s| s.to_string());
2246 let depends_on = entries
2247 .iter()
2248 .find(|(k, _)| k == KEY_DEPENDS_ON)
2249 .map(|(_, v)| match arena.node(*v) {
2250 Node::String(s) => vec![s.clone()],
2251 Node::List(items) => items.iter().filter_map(|r| arena.as_str(*r).map(|s| s.to_string())).collect(),
2252 _ => vec![],
2253 })
2254 .unwrap_or_default();
2255 let deletion_policy =
2256 entries.iter().find(|(k, _)| k == KEY_DELETION_POLICY).map(|(_, v)| resolver.resolve_node(*v));
2257 let update_replace_policy =
2258 entries.iter().find(|(k, _)| k == KEY_UPDATE_REPLACE_POLICY).map(|(_, v)| resolver.resolve_node(*v));
2259 let metadata_ref = entries.iter().find(|(k, _)| k == SECTION_METADATA).map(|(_, v)| *v);
2260 let metadata = metadata_ref.map(|v| node_to_json(arena, v));
2261 let update_policy = entries.iter().find(|(k, _)| k == KEY_UPDATE_POLICY).map(|(_, v)| node_to_json(arena, *v));
2262 let creation_policy = entries.iter().find(|(k, _)| k == KEY_CREATION_POLICY).map(|(_, v)| node_to_json(arena, *v));
2263
2264 let resolved_metadata = if let Some(meta_ref) = metadata_ref {
2265 resolver.set_current_path(SECTION_METADATA);
2266 Some(resolver.resolve_node(meta_ref))
2267 } else {
2268 None
2269 };
2270
2271 let mut properties = HashMap::new();
2272 let mut properties_dynamic = false;
2273 if let Some((_, props_ref)) = entries.iter().find(|(k, _)| k == KEY_PROPERTIES) {
2274 if let Some(prop_entries) = arena.as_map(*props_ref) {
2275 for (key, val_ref) in prop_entries {
2276 resolver.set_current_path(&format!("Properties.{}", key));
2277 properties.insert(key.clone(), resolver.resolve_node(*val_ref));
2278 }
2279 } else if let Some(synthetic_key) = intrinsic_synthetic_key(arena, *props_ref) {
2280 resolver.set_current_path(KEY_PROPERTIES);
2288 properties.insert(synthetic_key, resolver.resolve_node(*props_ref));
2289 } else if matches!(arena.node(*props_ref), Node::Intrinsic(_)) {
2290 properties_dynamic = true;
2294 }
2295 }
2296
2297 let mut conditionally_null_props = Vec::new();
2298 for (key, val) in &properties {
2299 collect_conditional_nulls(val, key, &mut conditionally_null_props);
2300 }
2301
2302 let mut condition_refs = Vec::new();
2303 for v in properties.values() {
2304 collect_condition_refs_from_resolved(v, &mut condition_refs);
2305 }
2306 if let Some(ref meta_resolved) = resolved_metadata {
2307 collect_condition_refs_from_resolved(meta_resolved, &mut condition_refs);
2308 }
2309 if let Some(ref dp) = deletion_policy {
2310 collect_condition_refs_from_resolved(dp, &mut condition_refs);
2311 }
2312 if let Some(ref urp) = update_replace_policy {
2313 collect_condition_refs_from_resolved(urp, &mut condition_refs);
2314 }
2315 if let Some(mut extra) = resolver.extra_condition_refs.remove(name) {
2316 condition_refs.append(&mut extra);
2317 }
2318 condition_refs.sort();
2319 condition_refs.dedup();
2320
2321 ResolvedResource {
2322 logical_id: name.to_string(),
2323 resource_type,
2324 condition,
2325 depends_on,
2326 deletion_policy,
2327 update_replace_policy,
2328 update_policy: update_policy.map(JsonValue),
2329 creation_policy: creation_policy.map(JsonValue),
2330 metadata: metadata.map(JsonValue),
2331 properties,
2332 properties_dynamic,
2333 diagnostics: ResourceDiagnostics {
2334 find_in_map_refs: resolver.find_in_map_refs.remove(name).unwrap_or_default(),
2335 simple_subs: resolver
2336 .simple_subs
2337 .remove(name)
2338 .unwrap_or_default()
2339 .into_iter()
2340 .map(|(a, b)| PathValuePair { path: a, value: b })
2341 .collect(),
2342 redundant_subs: resolver.redundant_subs.remove(name).unwrap_or_default(),
2343 empty_joins: resolver.empty_joins.remove(name).unwrap_or_default(),
2344 hardcoded_partition_arns: collapse_list_sibling_arn_paths(
2345 resolver.hardcoded_partition_arns.remove(name).unwrap_or_default(),
2346 ),
2347 foreach_expansions: resolver
2348 .foreach_expansions
2349 .remove(name)
2350 .unwrap_or_default()
2351 .into_iter()
2352 .map(|(path, ident, coll)| ForEachExpansion {
2353 property_path: path,
2354 identifier: ident,
2355 collection_source: coll,
2356 })
2357 .collect(),
2358 unsubstituted_variables: resolver
2359 .unsubstituted_variables
2360 .remove(name)
2361 .unwrap_or_default()
2362 .into_iter()
2363 .map(|(a, b)| PathValuePair { path: a, value: b })
2364 .collect(),
2365 unused_sub_keys: resolver
2366 .unused_sub_keys
2367 .remove(name)
2368 .unwrap_or_default()
2369 .into_iter()
2370 .map(|(a, b)| PathValuePair { path: a, value: b })
2371 .collect(),
2372 raw_pseudo_params: resolver
2373 .raw_pseudo_params
2374 .remove(name)
2375 .unwrap_or_default()
2376 .into_iter()
2377 .map(|(a, b)| PathValuePair { path: a, value: b })
2378 .collect(),
2379 secretsmanager_ref_paths: resolver.secretsmanager_ref_paths.remove(name).unwrap_or_default(),
2380 invalid_refs: resolver
2381 .invalid_refs
2382 .remove(name)
2383 .unwrap_or_default()
2384 .into_iter()
2385 .map(|(a, b)| PathValuePair { path: a, value: b })
2386 .collect(),
2387 conditionally_null_props: conditionally_null_props
2388 .into_iter()
2389 .map(|(a, b, c)| ConditionalNullEntry { path: a, condition: b, null_in_true_branch: c })
2390 .collect(),
2391 condition_refs,
2392 },
2393 }
2394}
2395
2396fn collapse_list_sibling_arn_paths(paths: Vec<String>) -> Vec<String> {
2405 let mut min_index: HashMap<String, usize> = HashMap::new();
2409 let mut ungrouped: Vec<String> = Vec::new();
2410 for path in &paths {
2411 match split_trailing_list_index(path) {
2412 Some((prefix, idx, suffix)) => {
2413 let key = format!("{}\u{0}{}", prefix, suffix);
2414 min_index.entry(key).and_modify(|m| *m = (*m).min(idx)).or_insert(idx);
2415 }
2416 None => ungrouped.push(path.clone()),
2417 }
2418 }
2419 let mut out = ungrouped;
2420 for (key, idx) in min_index {
2421 let (prefix, suffix) = key.split_once('\u{0}').unwrap_or((key.as_str(), ""));
2422 out.push(format!("{}{}.{}", prefix, idx, suffix));
2423 }
2424 out.sort();
2425 out
2426}
2427
2428fn split_trailing_list_index(path: &str) -> Option<(String, usize, String)> {
2431 let suffix = "Fn::Sub";
2432 let stem = path.strip_suffix(suffix)?.strip_suffix('.')?;
2433 let (head, last) = stem.rsplit_once('.')?;
2434 let idx: usize = last.parse().ok()?;
2435 Some((format!("{}.", head), idx, suffix.to_string()))
2436}
2437
2438fn check_output_value_is_string(arena: &Arena, value_ref: NodeRef, output_name: &str, resolver: &mut Resolver) {
2449 let build_path = format!("Outputs/{}/Value", output_name);
2450 check_output_value_node(arena, value_ref, output_name, &build_path, resolver);
2451}
2452
2453fn check_output_value_node(
2459 arena: &Arena,
2460 value_ref: NodeRef,
2461 output_name: &str,
2462 build_path: &str,
2463 resolver: &mut Resolver,
2464) {
2465 let span = arena.span(value_ref);
2466 match arena.node(value_ref) {
2467 Node::List(items) if !items.is_empty() => {
2468 resolver.diagnostics.push(crate::make_parse_defect_at(
2469 "F6101",
2470 format!("Output '{}' value must be a string, not a list", output_name),
2471 span,
2472 build_path,
2473 ));
2474 }
2475 Node::Map(entries) if !entries.is_empty() => {
2476 resolver.diagnostics.push(crate::make_parse_defect_at(
2477 "F6101",
2478 format!("Output '{}' value must be a string, not an object", output_name),
2479 span,
2480 build_path,
2481 ));
2482 }
2483 Node::Intrinsic(IntrinsicFn::If(_, if_true, if_false) | IntrinsicFn::IfExpr(_, if_true, if_false)) => {
2484 let (if_true, if_false) = (*if_true, *if_false);
2485 check_output_value_node(arena, if_true, output_name, &format!("{}/{}/1", build_path, FN_IF), resolver);
2486 check_output_value_node(arena, if_false, output_name, &format!("{}/{}/2", build_path, FN_IF), resolver);
2487 }
2488 Node::Intrinsic(intrinsic) if returns_list(intrinsic) => {
2489 resolver.diagnostics.push(crate::make_parse_defect_at(
2490 "F6101",
2491 format!("Output '{}' value must be a string, not a list", output_name),
2492 span,
2493 build_path,
2494 ));
2495 }
2496 _ => {}
2497 }
2498}
2499
2500fn returns_list(intrinsic: &IntrinsicFn) -> bool {
2505 matches!(intrinsic, IntrinsicFn::GetAZs(_) | IntrinsicFn::Split(_, _) | IntrinsicFn::Cidr(_, _, _))
2506}
2507
2508fn resolve_output(arena: &Arena, node_ref: NodeRef, resolver: &mut Resolver) -> ResolvedOutput {
2509 let entries = arena.as_map(node_ref).unwrap_or(&[]);
2510
2511 const VALID_OUTPUT_KEYS: &[&str] = &[KEY_VALUE, SECTION_DESCRIPTION, KEY_CONDITION, KEY_EXPORT];
2513 let output_name = resolver.current_resource.as_deref().unwrap_or("");
2514 let display_name = output_name.strip_prefix(OUTPUT_PSEUDO_RESOURCE_PREFIX).unwrap_or(output_name);
2515 for (key, _) in entries {
2516 if !VALID_OUTPUT_KEYS.contains(&key.as_str()) {
2517 resolver.diagnostics.push(crate::make_parse_defect(
2518 "E6001",
2519 format!(
2520 "Output '{}' has invalid property '{}'. Valid properties: Value, Description, Condition, Export",
2521 display_name, key
2522 ),
2523 crate::ir::UNKNOWN_SPAN,
2524 ));
2525 }
2526 }
2527
2528 if let Some((_, value_ref)) = entries.iter().find(|(k, _)| k == KEY_VALUE) {
2529 let value_ref = *value_ref;
2530 let name = display_name.to_string();
2531 check_output_value_is_string(arena, value_ref, &name, resolver);
2532 }
2533
2534 let value = entries
2535 .iter()
2536 .find(|(k, _)| k == KEY_VALUE)
2537 .map(|(_, v)| resolver.resolve_node(*v))
2538 .unwrap_or(ResolvedValue::Dynamic { reason: "missing output value".into() });
2539 let description = entries
2540 .iter()
2541 .find(|(k, _)| k == SECTION_DESCRIPTION)
2542 .and_then(|(_, v)| arena.as_str(*v))
2543 .map(|s| s.to_string());
2544 let condition =
2545 entries.iter().find(|(k, _)| k == KEY_CONDITION).and_then(|(_, v)| arena.as_str(*v)).map(|s| s.to_string());
2546 let export_name = entries
2547 .iter()
2548 .find(|(k, _)| k == KEY_EXPORT)
2549 .and_then(|(_, v)| arena.as_map(*v))
2550 .and_then(|m| m.iter().find(|(k, _)| k == KEY_NAME))
2551 .map(|(_, v)| resolver.resolve_node(*v));
2552 ResolvedOutput { value, description, condition, export_name }
2553}
2554
2555#[cfg(test)]
2556mod tests {
2557 use super::*;
2558
2559 #[test]
2560 fn invalid_overrides_reports_nothing_when_unset_or_valid() {
2561 assert!(PseudoParameterOverrides::default().invalid_overrides().is_empty(), "unset overrides are valid");
2562 let valid = PseudoParameterOverrides {
2563 account_id: Some("123456789012".to_string()),
2564 partition: Some("aws-cn".to_string()),
2565 ..Default::default()
2566 };
2567 assert!(valid.invalid_overrides().is_empty(), "well-formed overrides produce no warnings");
2568 }
2569
2570 #[test]
2571 fn invalid_overrides_reports_each_malformed_provided_value() {
2572 let bad = PseudoParameterOverrides {
2573 account_id: Some("unknown-account".to_string()),
2574 partition: Some("gcp".to_string()),
2575 ..Default::default()
2576 };
2577 let problems = bad.invalid_overrides();
2578 assert_eq!(problems.len(), 2, "one entry per invalid provided field");
2579 assert!(problems.iter().any(|p| p.contains("AccountId") && p.contains("unknown-account")));
2580 assert!(problems.iter().any(|p| p.contains("Partition") && p.contains("gcp")));
2581 }
2582
2583 #[test]
2584 fn invalid_overrides_only_checks_account_id_and_partition() {
2585 let only_region = PseudoParameterOverrides { region: Some("not a region".to_string()), ..Default::default() };
2586 assert!(only_region.invalid_overrides().is_empty());
2587 }
2588
2589 #[test]
2590 fn account_id_shape_requires_exactly_twelve_digits() {
2591 assert!(is_account_id_shaped("123456789012"));
2592 assert!(!is_account_id_shaped("12345678901"));
2593 assert!(!is_account_id_shaped("1234567890123"));
2594 assert!(!is_account_id_shaped("12345678901a"));
2595 assert!(!is_account_id_shaped(""));
2596 }
2597
2598 #[test]
2599 fn partition_shape_accepts_aws_family_and_rejects_others() {
2600 for ok in ["aws", "aws-cn", "aws-us-gov", "aws-iso", "aws-iso-b"] {
2601 assert!(is_partition_shaped(ok), "{ok} should be accepted");
2602 }
2603 for bad in ["gcp", "AWS", "azure", ""] {
2604 assert!(!is_partition_shaped(bad), "{bad} should be rejected");
2605 }
2606 }
2607
2608 #[test]
2609 fn model_from_simple_template() {
2610 let input = r#"
2611AWSTemplateFormatVersion: "2010-09-09"
2612Resources:
2613 MyBucket:
2614 Type: AWS::S3::Bucket
2615 Properties:
2616 BucketName: my-bucket
2617 MyBucket2:
2618 Type: AWS::S3::Bucket
2619 Properties:
2620 BucketName: my-bucket-2
2621"#;
2622 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2623 assert_eq!(model.resources.len(), 2);
2624 assert_eq!(model.resources_of_type("AWS::S3::Bucket").len(), 2);
2625 assert_eq!(model.resources_of_type("AWS::Fake::Thing").len(), 0);
2626 }
2627
2628 #[test]
2629 fn diagnostic_span_resolves_dotted_resource_property_path() {
2630 let input = r#"
2631Resources:
2632 MyBucket:
2633 Type: AWS::S3::Bucket
2634 Properties:
2635 BucketName: my-bucket
2636"#;
2637 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2638 let via_diag = model.diagnostic_span(Some("MyBucket"), "Properties.BucketName").expect("should resolve");
2641 let expected = model.source_location("Resources/MyBucket/Properties/BucketName").copied().expect("indexed");
2642 assert_eq!(via_diag, expected, "dotted path should resolve to the exact property span");
2643 }
2644
2645 #[test]
2646 fn diagnostic_span_resolves_bare_top_level_path_without_resource() {
2647 let input = r#"
2648BogusSection:
2649 Value: invalid
2650Resources:
2651 MyBucket:
2652 Type: AWS::S3::Bucket
2653"#;
2654 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2655 let via_diag = model.diagnostic_span(None, "BogusSection").expect("should resolve");
2656 let expected = model.source_location("BogusSection").copied().expect("indexed");
2657 assert_eq!(via_diag, expected, "bare top-level path should resolve to its authored key");
2658 }
2659
2660 #[test]
2661 fn diagnostic_span_resource_relative_path_never_matches_top_level_section() {
2662 let input = r#"
2665Metadata:
2666 AWS::CloudFormation::Interface:
2667 ParameterGroups: []
2668Resources:
2669 MyBucket:
2670 Type: AWS::S3::Bucket
2671 Metadata:
2672 cfn_nag: skip
2673"#;
2674 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2675 let via_diag = model.diagnostic_span(Some("MyBucket"), "Metadata").expect("should resolve");
2676 let resource_meta = model.source_location("Resources/MyBucket/Metadata").copied().expect("indexed");
2677 let top_level_meta = model.source_location("Metadata").copied().expect("indexed");
2678 assert_eq!(via_diag, resource_meta, "resource-relative Metadata must anchor at the resource");
2679 assert_ne!(via_diag, top_level_meta, "must not mislocate onto the top-level Metadata section");
2680 }
2681
2682 #[test]
2683 fn diagnostic_span_resolves_absolute_section_rooted_path() {
2684 let input = r#"
2685Resources:
2686 MyBucket:
2687 Type: AWS::S3::Bucket
2688Outputs:
2689 BucketRef:
2690 Value: !Ref MyBucket
2691"#;
2692 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2693 let span = model.diagnostic_span(Some("BucketRef"), "Outputs/BucketRef/Value");
2696 assert!(span.is_some() && span != Some(UNKNOWN_SPAN), "output path should resolve to a real span");
2697 }
2698
2699 #[test]
2700 fn diagnostic_span_walks_up_when_leaf_node_is_unindexed() {
2701 let input = r#"
2702Resources:
2703 MyBucket:
2704 Type: AWS::S3::Bucket
2705 Properties:
2706 BucketName: my-bucket
2707"#;
2708 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2709 let span = model.diagnostic_span(Some("MyBucket"), "Properties.BucketName.DoesNotExist.Deeper");
2712 assert!(span.is_some() && span != Some(UNKNOWN_SPAN), "unindexed leaf should walk up to a real span");
2713 }
2714
2715 #[test]
2716 fn diagnostic_span_returns_none_when_nothing_resolvable() {
2717 let input = r#"
2718Resources:
2719 MyBucket:
2720 Type: AWS::S3::Bucket
2721"#;
2722 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2723 assert_eq!(model.diagnostic_span(None, ""), None, "no resource and no path yields no span");
2725 assert_eq!(
2728 model.diagnostic_span(None, "NoSuchSection/Deep/Path"),
2729 None,
2730 "an unindexed absolute path yields no span"
2731 );
2732 }
2733
2734 #[test]
2735 fn model_resolve_property() {
2736 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
2737 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2738 match model.resolve("R", "Properties.Name").unwrap() {
2739 ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "hello"),
2740 other => panic!("Expected Concrete, got {:?}", other),
2741 }
2742 }
2743
2744 #[test]
2745 fn model_follow_ref() {
2746 let input = r#"{"Resources":{"Svc":{"Type":"T","Properties":{"TaskDef":{"Ref":"TD"}}},"TD":{"Type":"T2"}}}"#;
2747 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2748 assert_eq!(model.follow_ref("Svc", "Properties.TaskDef"), Some("TD"));
2749 }
2750
2751 #[test]
2752 fn model_to_diagnostic_json() {
2753 let input = r#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","Properties":{"Name":"test"}}}}"#;
2754 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2755 let json = serde_json::to_value(model.to_diagnostic_json()).unwrap();
2756 assert_ne!(json.get("resources"), None, "expected 'resources' key in diagnostic JSON");
2757 assert_ne!(json["resources"].get("R"), None, "expected resource 'R' in diagnostic JSON");
2758 }
2759
2760 #[test]
2761 fn model_send_sync() {
2762 fn assert_send_sync<T: Send + Sync>() {}
2763 assert_send_sync::<SemanticModel>();
2764 }
2765
2766 #[test]
2767 fn model_with_conditions() {
2768 let input = r#"
2769Parameters:
2770 Env:
2771 Type: String
2772 AllowedValues: [Prod, Dev]
2773Conditions:
2774 IsProd:
2775 Fn::Equals: [!Ref Env, Prod]
2776Resources:
2777 R:
2778 Type: T
2779 Condition: IsProd
2780 Properties:
2781 Size:
2782 Fn::If: [IsProd, 100, 20]
2783"#;
2784 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2785 assert_eq!(model.conditions.conditions.len(), 1);
2786 let r = model.resource("R").unwrap();
2787 assert_eq!(r.condition.as_deref(), Some("IsProd"));
2788 assert!(matches!(
2789 r.properties.get("Size"),
2790 Some(ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ })
2791 ));
2792 }
2793
2794 #[test]
2795 fn model_with_outputs() {
2796 let input =
2797 "Resources:\n R:\n Type: T\nOutputs:\n Out1:\n Value: !Ref R\n Description: test output\n";
2798 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2799 assert_eq!(model.outputs.len(), 1);
2800 assert!(model.outputs.contains_key("Out1"));
2801 }
2802
2803 #[test]
2804 fn resolve_deep_nested_object() {
2805 let input =
2806 r#"{"Resources":{"R":{"Type":"T","Properties":{"Config":{"SubKey":"value","Nested":{"Deep":"found"}}}}}}"#;
2807 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2808 match model.resolve_deep("R", "Properties.Config.SubKey") {
2809 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "value"),
2810 other => panic!("Expected Concrete, got {:?}", other),
2811 }
2812 match model.resolve_deep("R", "Properties.Config.Nested.Deep") {
2813 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "found"),
2814 other => panic!("Expected Concrete, got {:?}", other),
2815 }
2816 }
2817
2818 #[test]
2819 fn resolve_deep_array_index() {
2820 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Tags":[{"Key":"Env","Value":"prod"},{"Key":"App","Value":"web"}]}}}}"#;
2821 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2822 match model.resolve_deep("R", "Properties.Tags.0.Key") {
2823 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "Env"),
2824 other => panic!("Expected Concrete, got {:?}", other),
2825 }
2826 }
2827
2828 #[test]
2829 fn resolve_deep_out_of_bounds() {
2830 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Tags":[{"Key":"A"}]}}}}"#;
2831 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2832 assert!(model.resolve_deep("R", "Properties.Tags.5.Key").is_none(), "out-of-bounds index should return None");
2833 }
2834
2835 #[test]
2836 fn resolve_deep_missing_intermediate() {
2837 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
2838 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2839 assert!(model.resolve_deep("R", "Properties.NonExistent.Sub").is_none());
2840 }
2841
2842 #[test]
2843 fn resolve_deep_top_level_still_works() {
2844 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
2845 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2846 match model.resolve_deep("R", "Properties.Name") {
2847 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "hello"),
2848 other => panic!("Expected Concrete, got {:?}", other),
2849 }
2850 }
2851
2852 #[test]
2853 fn model_findinmap_refs_tracked() {
2854 let input = br#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["MyMap","k1","k2"]}}}}}"#;
2855 let model = SemanticModel::from_bytes(input).unwrap();
2856 assert!(model.resource("R").unwrap().diagnostics.find_in_map_refs.contains(&"MyMap".to_string()));
2857 }
2858
2859 #[test]
2860 fn model_findinmap_refs_tracked_yaml() {
2861 let input = b"Resources:\n R:\n Type: T\n Properties:\n V: !FindInMap [MyMap, k1, k2]\n";
2862 let model = SemanticModel::from_bytes(input).unwrap();
2863 assert!(model.resource("R").unwrap().diagnostics.find_in_map_refs.contains(&"MyMap".to_string()));
2864 }
2865
2866 #[test]
2867 fn model_to_diagnostic_json_has_outputs_and_mappings() {
2868 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";
2869 let model = SemanticModel::from_bytes(input).unwrap();
2870 let json = serde_json::to_value(model.to_diagnostic_json()).unwrap();
2871 assert_ne!(json.get("outputs"), None, "expected 'outputs' key in diagnostic JSON");
2872 assert_ne!(json.get("mappings"), None, "expected 'mappings' key in diagnostic JSON");
2873 assert_eq!(json["mappings"]["M"]["k1"]["k2"], "val");
2874 }
2875
2876 #[test]
2877 fn resolve_deep_into_reference() {
2878 let input = r#"{"Resources":{"Svc":{"Type":"T","Properties":{"TaskDef":{"Ref":"TD"}}},"TD":{"Type":"T2"}}}"#;
2879 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2880 assert!(
2881 model.resolve_deep("Svc", "Properties.TaskDef.Something").is_none(),
2882 "path through Reference should return None"
2883 );
2884 match model.resolve_deep("Svc", "Properties.TaskDef") {
2885 Some(ResolvedValue::Reference { target, kind: _ }) => assert_eq!(target, "TD"),
2886 other => panic!("Expected Reference, got {:?}", other),
2887 }
2888 }
2889
2890 #[test]
2891 fn scenario_source_path_maps_effective_indexes_to_authored_branches() {
2892 let input = br#"{
2893 "Parameters": {"Mode": {"Type": "String"}},
2894 "Conditions": {"ChooseFirst": {"Fn::Equals": [{"Ref": "Mode"}, "first"]}},
2895 "Resources": {"R": {"Type": "T", "Properties": {
2896 "Values": {"Fn::If": ["ChooseFirst", ["bad"], ["good"]]}
2897 }}}
2898 }"#;
2899 let model = SemanticModel::from_bytes(input).unwrap();
2900 let true_conditions = HashMap::from([("ChooseFirst".to_string(), true)]);
2901 let false_conditions = HashMap::from([("ChooseFirst".to_string(), false)]);
2902 assert_eq!(
2903 model.scenario_source_path("R", "Properties.Values.0", &true_conditions),
2904 Some("Properties.Values.Fn::If.1.0".to_string())
2905 );
2906 assert_eq!(
2907 model.scenario_source_path("R", "Properties.Values.0", &false_conditions),
2908 Some("Properties.Values.Fn::If.2.0".to_string())
2909 );
2910 }
2911
2912 #[test]
2913 fn resolve_scenarios_json_filters_dynamic() {
2914 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":"Stack"}}}}}"#;
2915 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2916 let scenarios = model.resolve_scenarios_json("R", "Properties.V");
2917 assert!(scenarios.is_empty(), "ImportValue should be filtered as dynamic");
2918 }
2919
2920 #[test]
2921 fn resolve_scenarios_json_returns_concrete() {
2922 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
2923 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2924 let scenarios = model.resolve_scenarios_json("R", "Properties.V");
2925 assert_eq!(scenarios.len(), 1);
2926 assert_eq!(scenarios[0].0, serde_json::json!("hello"));
2927 }
2928
2929 #[test]
2930 fn resolve_scenarios_json_memoized() {
2931 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
2932 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2933 let s1 = model.resolve_scenarios_json("R", "Properties.V");
2934 let s2 = model.resolve_scenarios_json("R", "Properties.V");
2935 assert_eq!(s1, s2);
2936 }
2937
2938 #[test]
2939 fn default_scenario_queries_are_memoized_and_global_budget_halts_uncached_expansion() {
2940 let input = br#"{
2941 "Parameters": {"Env": {"Type": "String"}},
2942 "Conditions": {"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}},
2943 "Resources": {"R": {"Type": "T", "Properties": {
2944 "V": {"Fn::If": ["IsProd", "a", "b"]},
2945 "W": {"Fn::If": ["IsProd", "c", "d"]},
2946 "X": {"Fn::If": ["IsProd", "e", "f"]}
2947 }}}
2948 }"#;
2949 let model = SemanticModel::from_bytes(input).unwrap();
2950
2951 assert_eq!(model.scenario_combinations_used(), 0, "a freshly built model has materialized no scenarios");
2952 assert!(!model.scenario_budget_exhausted());
2953
2954 let first = model.resolve_scenarios("R", "Properties.V");
2955 assert!(first.len() > 1, "an Fn::If value must expand into multiple scenarios; got {}", first.len());
2956 let after_first = model.scenario_combinations_used();
2957 assert_eq!(after_first, first.len() as u64, "the first query charges exactly the scenarios it produced");
2958
2959 let repeated = model.resolve_scenarios("R", "Properties.V");
2960 assert_eq!(repeated.len(), first.len());
2961 assert_eq!(
2962 model.scenario_combinations_used(),
2963 after_first,
2964 "a repeated default-limit query must reuse its raw scenarios without consuming budget"
2965 );
2966
2967 let distinct = model.resolve_scenarios("R", "Properties.W");
2968 assert!(!distinct.is_empty());
2969 assert!(
2970 model.scenario_combinations_used() > after_first,
2971 "a distinct scenario path must still charge the model-wide budget"
2972 );
2973
2974 let to_threshold = MAX_TOTAL_SCENARIO_COMBINATIONS - model.scenario_combinations_used();
2975 model.add_scenario_combinations_for_test(to_threshold);
2976 assert!(model.scenario_budget_exhausted());
2977
2978 let cached = model.resolve_scenarios("R", "Properties.V");
2979 assert_eq!(
2980 cached.len(),
2981 first.len(),
2982 "cached scenarios remain usable after unrelated queries consume the budget"
2983 );
2984 assert!(!model.scenario_expansion_curtailed(), "reading a cached result does not curtail expansion");
2985
2986 let before_short_circuit = model.scenario_combinations_used();
2987 let curtailed = model.resolve_scenarios("R", "Properties.X");
2988 assert!(curtailed.is_empty(), "an uncached query must stop when the model-wide budget is exhausted");
2989 assert_eq!(model.scenario_combinations_used(), before_short_circuit);
2990 assert!(model.scenario_expansion_curtailed());
2991 }
2992
2993 fn two_scenario_value() -> ResolvedValue {
2994 ResolvedValue::Conditional {
2995 condition: "C".to_string(),
2996 if_true: Box::new(ResolvedValue::Concrete { value: serde_json::json!("yes").into() }),
2997 if_false: Box::new(ResolvedValue::Concrete { value: serde_json::json!("no").into() }),
2998 }
2999 }
3000
3001 #[test]
3002 fn exact_remaining_scenario_budget_does_not_mark_curtailment() {
3003 let model = SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap();
3004 let scenarios = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 2);
3005 assert_eq!(scenarios.len(), 2);
3006 assert_eq!(model.scenario_combinations_used(), 2);
3007 assert!(
3008 !model.scenario_expansion_curtailed(),
3009 "materializing exactly every possible scenario at the budget boundary is not curtailment"
3010 );
3011 }
3012
3013 #[test]
3014 fn remaining_scenario_budget_truncates_and_marks_curtailment() {
3015 let model = SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap();
3016 let first = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
3017 assert_eq!(first.len(), 2);
3018 assert!(!model.scenario_expansion_curtailed());
3019
3020 let second = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
3021 assert_eq!(second.len(), 1, "only the one remaining global slot may be used");
3022 assert_eq!(model.scenario_combinations_used(), 3);
3023 assert!(model.scenario_expansion_curtailed());
3024
3025 let exhausted = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
3026 assert!(exhausted.is_empty());
3027 assert_eq!(model.scenario_combinations_used(), 3, "the global limit must never be exceeded");
3028 }
3029
3030 #[test]
3031 fn concurrent_scenario_queries_cannot_exceed_global_budget() {
3032 let model = std::sync::Arc::new(SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap());
3033 let handles: Vec<_> = (0..8)
3034 .map(|_| {
3035 let model = std::sync::Arc::clone(&model);
3036 std::thread::spawn(move || {
3037 let value = ResolvedValue::Concrete { value: serde_json::json!("value").into() };
3038 model.collect_scenarios_with_budget(&value, 8, 4).len()
3039 })
3040 })
3041 .collect();
3042 let produced: usize =
3043 handles.into_iter().map(|handle| handle.join().expect("scenario worker must not panic")).sum();
3044
3045 assert_eq!(produced, 4, "exactly the global budget may be materialized across all workers");
3046 assert_eq!(model.scenario_combinations_used(), 4, "concurrent accounting must not overshoot the limit");
3047 assert!(model.scenario_expansion_curtailed(), "workers denied by exhaustion must mark curtailment");
3048 }
3049
3050 #[test]
3051 fn scenario_expansion_curtailment_flag_starts_false() {
3052 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"literal"}}}}"#;
3053 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3054 assert!(
3055 !model.scenario_expansion_curtailed(),
3056 "a freshly built model must not have scenario expansion curtailed"
3057 );
3058 }
3059
3060 #[test]
3061 fn scenario_expansion_curtailment_flag_set_by_test_helper() {
3062 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"literal"}}}}"#;
3063 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3064 model.set_scenario_expansion_curtailed_for_test();
3065 assert!(model.scenario_expansion_curtailed(), "the test helper must set the curtailment flag");
3066 }
3067
3068 #[test]
3069 fn scenario_expansion_curtailment_flag_is_monotonic() {
3070 let input = r#"{
3073 "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::If": ["C", "a", "b"]}}}}
3074 }"#;
3075 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3076 model.set_scenario_expansion_curtailed_for_test();
3077 let _ = model.resolve_scenarios("R", "Properties.V");
3079 assert!(model.scenario_expansion_curtailed(), "the curtailment flag must be monotonic — once set it stays set");
3080 }
3081
3082 #[test]
3083 fn estimated_bounds_are_absent_for_a_literal_string() {
3084 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
3087 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3088 assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), None);
3089 }
3090
3091 #[test]
3092 fn estimated_bounds_span_every_allowed_value() {
3093 let input = r#"{"Parameters":{"Size":{"Type":"String","AllowedValues":["xs","large"]}},
3094 "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Size"}}}}}"#;
3095 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3096 assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), Some((2, 5)));
3097 }
3098
3099 #[test]
3100 fn estimated_bounds_are_absent_when_a_possibility_is_unknown() {
3101 let input = r#"{"Parameters":{"Free":{"Type":"String"}},
3102 "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Free"}}}}}"#;
3103 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3104 assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), None);
3105 }
3106
3107 #[test]
3108 fn estimate_string_length_missing_property() {
3109 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
3110 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3111 assert!(model.estimated_string_length_bounds("R", "Properties.Missing").is_none());
3112 }
3113
3114 #[test]
3115 fn resource_span_specific_path() {
3116 let input = "Resources:\n R:\n Type: T\n Properties:\n Name: hello\n";
3117 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3118 let span = model.resource_span("R", "Properties/Name");
3119 assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span for Properties/Name");
3121 }
3122
3123 #[test]
3124 fn resource_span_specific_path_dotted_form() {
3125 let input = "Resources:\n R:\n Type: T\n Properties:\n Name: hello\n";
3126 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3127 let dotted = model.resource_span("R", "Properties.Name");
3130 let slashed = model.resource_span("R", "Properties/Name");
3131 let resource = model.resource_span("R", "");
3132 assert_eq!(dotted, slashed, "dotted and slash forms must resolve to the same span");
3133 assert_ne!(dotted, resource, "specific property span must differ from the resource span");
3134 }
3135
3136 #[test]
3137 fn resource_span_falls_back_to_resource() {
3138 let input = "Resources:\n R:\n Type: T\n Properties:\n Name: hello\n";
3139 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3140 let span = model.resource_span("R", "Properties/NonExistent/Deep");
3141 assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span, got {:?}", span);
3143 }
3144
3145 #[test]
3146 fn resource_span_nested_array_property_anchors_at_element() {
3147 let input =
3150 "Resources:\n R:\n Type: T\n Properties:\n Ingress:\n - Port: 80\n - Port: 443\n";
3151 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3152 let first = model.resource_span("R", "Properties.Ingress.0.Port");
3153 let second = model.resource_span("R", "Properties.Ingress.1.Port");
3154 assert_eq!(first.start_line, 6, "Ingress[0].Port is on line 6, got {:?}", first);
3155 assert_eq!(second.start_line, 7, "Ingress[1].Port is on line 7, got {:?}", second);
3156 }
3157
3158 #[test]
3159 fn resource_span_empty_id_resolves_section_absolute_path_precisely() {
3160 let input = concat!(
3165 "Resources:\n R:\n Type: T\n", "Outputs:\n Combined:\n Value: !Join [\"\", [\"a\", \"b\"]]\n", );
3168 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3169 let value = model.resource_span("", "Outputs/Combined/Value");
3171 assert_eq!(value.start_line, 6, "Outputs/Combined/Value is on line 6, got {:?}", value);
3172 let combined = model.resource_span("", "Outputs/Combined");
3174 assert_eq!(combined.start_line, 5, "Outputs/Combined is on line 5, got {:?}", combined);
3175 }
3176
3177 #[test]
3178 fn resource_span_empty_id_fused_intrinsic_suffix_anchors_at_nearest_slash_ancestor() {
3179 let input = concat!(
3188 "Resources:\n R:\n Type: T\n", "Outputs:\n Combined:\n Value: !Join [\"\", [\"a\", \"b\"]]\n", );
3191 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3192 let span = model.resource_span("", "Outputs/Combined/Value.Fn::Join");
3193 assert_eq!(
3194 span.start_line, 5,
3195 "fused-suffix path must anchor at the enclosing output (line 5), got {:?}",
3196 span
3197 );
3198 }
3199
3200 #[test]
3201 fn resource_span_empty_id_does_not_leak_onto_resources_block() {
3202 let input = "Resources:\n R:\n Type: T\n";
3206 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3207 let span = model.resource_span("", "Outputs/DoesNotExist/Value");
3208 assert_eq!(span, UNKNOWN_SPAN, "unresolvable empty-id path must be UNKNOWN, got {:?}", span);
3209 }
3210
3211 #[test]
3212 fn resource_span_empty_path_returns_resource_span() {
3213 let input = "Resources:\n R:\n Type: T\n";
3214 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3215 let span = model.resource_span("R", "");
3216 assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span, got {:?}", span);
3217 }
3218
3219 #[test]
3220 fn resolve_nonexistent_resource_returns_none() {
3221 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"x"}}}}"#;
3222 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3223 assert!(model.resolve("NoSuchResource", "Properties.V").is_none(), "nonexistent resource should return None");
3224 assert!(model.resolve_deep("NoSuchResource", "Properties.V").is_none());
3225 }
3226
3227 #[test]
3228 fn parse_config_with_parameter_overrides() {
3229 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
3230 let config = ParseConfig {
3231 parameters: [("Env".to_string(), "staging".to_string())].into_iter().collect(),
3232 pseudo_parameters: PseudoParameterOverrides::default(),
3233 };
3234 let result = SemanticModel::parse(input.as_bytes(), config).unwrap();
3235 match result.model.resolve("R", "Properties.V") {
3236 Some(ResolvedValue::Concrete { value: v }) => {
3237 assert_eq!(v.as_str().unwrap(), "staging")
3238 }
3239 other => panic!("Expected Concrete(\"staging\"), got {:?}", other),
3240 }
3241 }
3242
3243 #[test]
3244 fn parse_config_with_pseudo_parameter_overrides() {
3245 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
3246 let config = ParseConfig {
3247 parameters: HashMap::new(),
3248 pseudo_parameters: PseudoParameterOverrides { region: Some("eu-west-1".to_string()), ..Default::default() },
3249 };
3250 let result = SemanticModel::parse(input.as_bytes(), config).unwrap();
3251 match result.model.resolve("R", "Properties.V") {
3252 Some(ResolvedValue::Concrete { value: v }) => {
3253 assert_eq!(v.as_str().unwrap(), "eu-west-1")
3254 }
3255 other => panic!("Expected Concrete(\"eu-west-1\"), got {:?}", other),
3256 }
3257 }
3258
3259 #[test]
3260 fn pseudo_parameter_overrides_defaults() {
3261 let overrides = PseudoParameterOverrides::default();
3262 assert_eq!(overrides.region(), DEFAULT_REGION);
3263 assert_eq!(overrides.get("AWS::AccountId").unwrap(), DEFAULT_ACCOUNT_ID);
3264 assert_eq!(overrides.get("AWS::Partition").unwrap(), DEFAULT_PARTITION);
3265 assert_eq!(overrides.get("AWS::StackName").unwrap(), DEFAULT_STACK_NAME);
3266 assert_eq!(overrides.get("AWS::URLSuffix").unwrap(), DEFAULT_URL_SUFFIX);
3267 assert!(overrides.get("AWS::StackId").unwrap().contains("arn:"));
3268 assert!(overrides.get("AWS::NotificationARNs").unwrap().contains("sns"));
3269 assert_eq!(overrides.get("Unknown"), None, "unknown pseudo-param should return None");
3270 }
3271
3272 #[test]
3273 fn pseudo_parameter_overrides_china_region() {
3274 let overrides = PseudoParameterOverrides { region: Some("cn-north-1".to_string()), ..Default::default() };
3275 assert_eq!(overrides.get("AWS::Partition").unwrap(), "aws-cn");
3276 assert_eq!(overrides.get("AWS::URLSuffix").unwrap(), "amazonaws.com.cn");
3277 }
3278
3279 #[test]
3286 fn fixed_value_returns_none_for_unset_pseudo_parameters() {
3287 let overrides = PseudoParameterOverrides::default();
3288
3289 for name in [
3290 "AWS::AccountId",
3291 "AWS::NotificationARNs",
3292 "AWS::Partition",
3293 "AWS::Region",
3294 "AWS::StackId",
3295 "AWS::StackName",
3296 "AWS::URLSuffix",
3297 ] {
3298 assert_eq!(
3299 overrides.fixed_value(name),
3300 None,
3301 "{name} must be a free variable when the user has not explicitly pinned it"
3302 );
3303 }
3304 }
3305
3306 #[test]
3307 fn parameter_name_at_names_the_parameter_behind_a_value() {
3308 let input = r#"{"Parameters":{"BucketName":{"Type":"String"}},
3309 "Resources":{"R":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":{"Ref":"BucketName"}}}}}"#;
3310 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3311 assert_eq!(model.parameter_name_at("R", "Properties.BucketName"), Some("BucketName"));
3312 assert!(model.is_from_parameter("R", "Properties.BucketName"));
3313 }
3314
3315 #[test]
3316 fn parameter_name_at_is_absent_for_a_value_no_parameter_produced() {
3317 let input = r#"{"Resources":{"R":{"Type":"AWS::S3::Bucket",
3320 "Properties":{"BucketName":{"Fn::ImportValue":"SharedName"}}}}}"#;
3321 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3322 assert_eq!(model.parameter_name_at("R", "Properties.BucketName"), None);
3323 assert!(!model.is_from_parameter("R", "Properties.BucketName"));
3324 }
3325
3326 #[test]
3327 fn fixed_value_returns_user_supplied_overrides() {
3328 let overrides = PseudoParameterOverrides {
3329 account_id: Some("999999999999".to_string()),
3330 partition: Some("aws-cn".to_string()),
3331 region: Some("cn-north-1".to_string()),
3332 stack_name: Some("MyStack".to_string()),
3333 ..Default::default()
3334 };
3335
3336 assert_eq!(overrides.fixed_value("AWS::AccountId"), Some("999999999999".to_string()));
3337 assert_eq!(overrides.fixed_value("AWS::Partition"), Some("aws-cn".to_string()));
3338 assert_eq!(overrides.fixed_value("AWS::Region"), Some("cn-north-1".to_string()));
3339 assert_eq!(overrides.fixed_value("AWS::StackName"), Some("MyStack".to_string()));
3340 assert_eq!(overrides.fixed_value("AWS::URLSuffix"), None, "URLSuffix not set; must remain a free variable");
3341 assert_eq!(overrides.fixed_value("Unknown"), None);
3342 }
3343
3344 #[test]
3345 fn parameter_name_from_source_reads_only_a_parameter_entry() {
3346 assert_eq!(parameter_name_from_source("Parameters/InstanceType/Default"), Some("InstanceType"));
3347 assert_eq!(parameter_name_from_source("Parameters/InstanceType/AllowedValues"), Some("InstanceType"));
3348 assert_eq!(parameter_name_from_source("Parameters/InstanceType"), Some("InstanceType"));
3349 assert_eq!(parameter_name_from_source("Intrinsic/Ref"), None);
3350 assert_eq!(parameter_name_from_source("Parameters/"), None);
3351 assert_eq!(parameter_name_from_source("ParametersLookAlike/Name"), None);
3352 }
3353
3354 #[test]
3361 fn fixed_value_does_not_propagate_region_to_partition() {
3362 let overrides = PseudoParameterOverrides { region: Some("cn-north-1".to_string()), ..Default::default() };
3363
3364 assert_eq!(overrides.fixed_value("AWS::Region"), Some("cn-north-1".to_string()));
3365 assert_eq!(
3366 overrides.fixed_value("AWS::Partition"),
3367 None,
3368 "region override must not propagate to partition - partition stays free unless the caller pins it"
3369 );
3370 }
3371
3372 #[test]
3373 fn model_rejects_oversized_template() {
3374 let huge = vec![b' '; 11 * 1024 * 1024];
3375 let result = SemanticModel::from_bytes(&huge);
3376 match result {
3377 Err(e) => assert!(e.message.contains("maximum size")),
3378 Ok(_) => panic!("Expected error for oversized template"),
3379 }
3380 }
3381
3382 #[test]
3383 fn follow_ref_returns_none_for_non_reference() {
3384 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
3385 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3386 assert_eq!(model.follow_ref("R", "Properties.V"), None, "non-reference value should return None");
3387 }
3388
3389 #[test]
3390 fn resolve_deep_memoization_consistency() {
3391 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Config":{"A":"1","B":"2"}}}}}"#;
3392 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3393 let r1 = model.resolve_deep("R", "Properties.Config.A");
3394 let r2 = model.resolve_deep("R", "Properties.Config.A");
3395 match (&r1, &r2) {
3396 (Some(ResolvedValue::Concrete { value: a }), Some(ResolvedValue::Concrete { value: b })) => {
3397 assert_eq!(a, b);
3398 }
3399 _ => panic!("Expected matching Concrete values"),
3400 }
3401 }
3402
3403 #[test]
3404 fn model_with_mappings_and_findinmap() {
3405 let input = r#"
3406Mappings:
3407 RegionMap:
3408 us-east-1:
3409 AMI: ami-12345
3410 us-west-2:
3411 AMI: ami-67890
3412Resources:
3413 R:
3414 Type: T
3415 Properties:
3416 ImageId: !FindInMap [RegionMap, us-east-1, AMI]
3417"#;
3418 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3419 match model.resolve("R", "Properties.ImageId") {
3420 Some(ResolvedValue::Concrete { value: v }) => {
3421 assert_eq!(v.as_str().unwrap(), "ami-12345")
3422 }
3423 other => panic!("Expected Concrete, got {:?}", other),
3424 }
3425 }
3426
3427 #[test]
3428 fn model_invalid_ref_tracked() {
3429 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"NonExistent"}}}}}"#;
3430 let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3431 let r = model.resource("R").unwrap();
3432 assert!(!r.diagnostics.invalid_refs.is_empty());
3433 assert!(r.diagnostics.invalid_refs.iter().any(|s| s.value == "NonExistent"));
3434 }
3435
3436 #[test]
3437 fn collapse_arn_paths_folds_list_siblings_to_lowest_index() {
3438 let input = vec![
3439 "Properties.KeyPolicy.Statement.2.Principal.AWS.1.Fn::Sub".to_string(),
3440 "Properties.KeyPolicy.Statement.2.Principal.AWS.0.Fn::Sub".to_string(),
3441 ];
3442 let out = collapse_list_sibling_arn_paths(input);
3443 assert_eq!(out, vec!["Properties.KeyPolicy.Statement.2.Principal.AWS.0.Fn::Sub".to_string()]);
3444 }
3445
3446 #[test]
3447 fn collapse_arn_paths_keeps_distinct_parent_lists() {
3448 let input = vec![
3451 "Properties.PolicyDocument.Statement.0.Resource.Fn::Sub".to_string(),
3452 "Properties.PolicyDocument.Statement.1.Resource.Fn::Sub".to_string(),
3453 ];
3454 let mut out = collapse_list_sibling_arn_paths(input.clone());
3455 out.sort();
3456 assert_eq!(out, input);
3457 }
3458
3459 #[test]
3460 fn collapse_arn_paths_leaves_scalar_paths_untouched() {
3461 let input = vec![
3462 "Properties.KeyPolicy.Statement.0.Principal.AWS.Fn::Sub".to_string(),
3463 "Properties.KeyPolicy.Statement.1.Principal.AWS.Fn::Sub".to_string(),
3464 ];
3465 let mut out = collapse_list_sibling_arn_paths(input.clone());
3466 out.sort();
3467 assert_eq!(out, input);
3468 }
3469
3470 fn output_string_type_diagnostics(template: &str) -> usize {
3472 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3473 model.diagnostics.iter().filter(|d| d.rule_id == "F6101").count()
3474 }
3475
3476 #[test]
3477 fn output_literal_list_value_flagged() {
3478 let template = "Resources:\n R:\n Type: T\nOutputs:\n O:\n Value:\n - a\n - b\n";
3479 assert_eq!(output_string_type_diagnostics(template), 1);
3480 }
3481
3482 #[test]
3483 fn output_literal_object_value_flagged() {
3484 let template = "Resources:\n R:\n Type: T\nOutputs:\n O:\n Value:\n Key: v\n";
3485 assert_eq!(output_string_type_diagnostics(template), 1);
3486 }
3487
3488 #[test]
3489 fn output_list_returning_functions_flagged() {
3490 for value in ["!GetAZs \"\"", "!Split [\",\", \"a,b\"]", "!Cidr [\"10.0.0.0/16\", 1, 8]"] {
3491 let template = format!("Resources:\n R:\n Type: T\nOutputs:\n O:\n Value: {}\n", value);
3492 assert_eq!(output_string_type_diagnostics(&template), 1, "expected F6101 for output value {}", value);
3493 }
3494 }
3495
3496 #[test]
3497 fn output_empty_container_value_not_flagged() {
3498 for value in ["[]", "{}"] {
3501 let template = format!("Resources:\n R:\n Type: T\nOutputs:\n O:\n Value: {}\n", value);
3502 assert_eq!(output_string_type_diagnostics(&template), 0, "empty container {} must not be flagged", value);
3503 }
3504 }
3505
3506 #[test]
3507 fn output_string_producing_values_not_flagged() {
3508 for value in ["hello", "42", "true", "!Ref R", "!Sub \"${R}\"", "!Join [\",\", [\"a\"]]", "!ImportValue X"] {
3511 let template = format!("Resources:\n R:\n Type: T\nOutputs:\n O:\n Value: {}\n", value);
3512 assert_eq!(output_string_type_diagnostics(&template), 0, "string-valued output {} must not fire", value);
3513 }
3514 }
3515
3516 #[test]
3517 fn output_find_in_map_list_value_not_flagged() {
3518 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";
3524 assert_eq!(output_string_type_diagnostics(template), 0);
3525 }
3526
3527 #[test]
3528 fn output_fn_if_flags_each_list_branch() {
3529 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";
3532 assert_eq!(output_string_type_diagnostics(template), 2);
3533 }
3534
3535 #[test]
3536 fn output_fn_if_string_branches_not_flagged() {
3537 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";
3538 assert_eq!(output_string_type_diagnostics(template), 0);
3539 }
3540
3541 #[test]
3542 fn join_with_enum_list_exact_4096_not_curtailed() {
3543 use crate::consts::MAX_ENUM_EXPANSION;
3544 use crate::resolver::join_with_enum_list;
3545 let variants_a: Vec<ResolvedValue> = (0..64)
3548 .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("a{i}")).into() })
3549 .collect();
3550 let variants_b: Vec<ResolvedValue> = (0..64)
3551 .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("b{i}")).into() })
3552 .collect();
3553 let items = vec![ResolvedValue::Enum { variants: variants_a }, ResolvedValue::Enum { variants: variants_b }];
3554 let (result, curtailed) = join_with_enum_list(",", &items);
3555 assert!(!curtailed, "exactly {MAX_ENUM_EXPANSION} combinations must not signal curtailment");
3556 if let ResolvedValue::Enum { variants } = &result {
3557 assert_eq!(variants.len(), MAX_ENUM_EXPANSION);
3558 } else {
3559 panic!("expected Enum result");
3560 }
3561 }
3562
3563 #[test]
3564 fn join_with_enum_list_one_over_curtailed() {
3565 use crate::consts::MAX_ENUM_EXPANSION;
3566 use crate::resolver::join_with_enum_list;
3567 let variants_a: Vec<ResolvedValue> = (0..65)
3569 .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("a{i}")).into() })
3570 .collect();
3571 let variants_b: Vec<ResolvedValue> = (0..64)
3572 .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("b{i}")).into() })
3573 .collect();
3574 let items = vec![ResolvedValue::Enum { variants: variants_a }, ResolvedValue::Enum { variants: variants_b }];
3575 let (result, curtailed) = join_with_enum_list(",", &items);
3576 assert!(curtailed, "exceeding {MAX_ENUM_EXPANSION} combinations must signal curtailment");
3577 if let ResolvedValue::Enum { variants } = &result {
3578 assert_eq!(variants.len(), MAX_ENUM_EXPANSION);
3579 } else {
3580 panic!("expected Enum result");
3581 }
3582 }
3583
3584 #[test]
3585 fn primary_id_same_parameter_across_ref_sub_join_collides() {
3586 let template = r#"
3587Parameters:
3588 Name: {Type: String}
3589Resources:
3590 A:
3591 Type: AWS::S3::Bucket
3592 Properties: {BucketName: !Ref Name}
3593 B:
3594 Type: AWS::S3::Bucket
3595 Properties: {BucketName: !Sub '${Name}'}
3596 C:
3597 Type: AWS::S3::Bucket
3598 Properties: {BucketName: !Sub ['${V}', {V: !Ref Name}]}
3599 D:
3600 Type: AWS::S3::Bucket
3601 Properties: {BucketName: !Join ['', [!Ref Name]]}
3602"#;
3603 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3604 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3605 assert_eq!(conflicts.len(), 1, "all four forms must collide into one group");
3606 let (_, resources) = conflicts.into_iter().next().unwrap();
3607 assert_eq!(resources.len(), 4, "all four resources must be in the group");
3608 }
3609
3610 #[test]
3611 fn primary_id_different_parameters_with_equal_defaults_stay_distinct() {
3612 let template = r#"
3613Parameters:
3614 A: {Type: String, Default: shared}
3615 B: {Type: String, Default: shared}
3616Resources:
3617 One:
3618 Type: AWS::S3::Bucket
3619 Properties: {BucketName: !Ref A}
3620 Two:
3621 Type: AWS::S3::Bucket
3622 Properties: {BucketName: !Ref B}
3623"#;
3624 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3625 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3626 assert!(conflicts.is_empty(), "different overridable parameters must not collide: {:?}", conflicts);
3627 }
3628
3629 #[test]
3630 fn primary_id_comparison_identity_is_separate_from_display_value() {
3631 let template = r#"
3632Parameters:
3633 Name: {Type: String}
3634Resources:
3635 DynamicOne:
3636 Type: AWS::S3::Bucket
3637 Properties: {BucketName: !Ref Name}
3638 DynamicTwo:
3639 Type: AWS::S3::Bucket
3640 Properties: {BucketName: !Ref Name}
3641 LiteralOne:
3642 Type: AWS::S3::Bucket
3643 Properties: {BucketName: 'Ref("Name")'}
3644 LiteralTwo:
3645 Type: AWS::S3::Bucket
3646 Properties: {BucketName: 'Ref("Name")'}
3647"#;
3648 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3649 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3650 assert_eq!(conflicts.len(), 2, "equal display text must not merge distinct identities");
3651 assert!(
3652 conflicts
3653 .iter()
3654 .all(|(tuple, resources)| { tuple == &["Ref(\"Name\")".to_string()] && resources.len() == 2 })
3655 );
3656 }
3657
3658 #[test]
3659 fn primary_id_mutually_exclusive_conditions_do_not_collide() {
3660 let template = r#"
3661Parameters:
3662 Env: {Type: String}
3663 Name: {Type: String}
3664Conditions:
3665 IsProd: !Equals [!Ref Env, prod]
3666 IsNotProd: !Not [!Condition IsProd]
3667Resources:
3668 ProdBucket:
3669 Type: AWS::S3::Bucket
3670 Condition: IsProd
3671 Properties: {BucketName: !Ref Name}
3672 DevBucket:
3673 Type: AWS::S3::Bucket
3674 Condition: IsNotProd
3675 Properties: {BucketName: !Ref Name}
3676"#;
3677 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3678 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3679 assert!(conflicts.is_empty(), "mutually exclusive conditions must not collide");
3680 }
3681
3682 #[test]
3683 fn primary_id_no_internal_marker_leakage_in_tuple_values() {
3684 let template = r#"
3685Parameters:
3686 Name: {Type: String}
3687Resources:
3688 A:
3689 Type: AWS::S3::Bucket
3690 Properties: {BucketName: !Ref Name}
3691 B:
3692 Type: AWS::S3::Bucket
3693 Properties: {BucketName: !Ref Name}
3694"#;
3695 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3696 let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3697 assert_eq!(conflicts.len(), 1);
3698 let (tuple, _) = conflicts.into_iter().next().unwrap();
3699 let value = &tuple[0];
3700 assert!(!value.contains("$dyn:"), "tuple value must not contain $dyn: marker");
3701 assert!(!value.contains("expr:"), "tuple value must not contain expr: prefix");
3702 }
3703
3704 #[test]
3705 fn output_invalid_ref_has_canonical_path() {
3706 let template = "Resources:\n R:\n Type: T\nOutputs:\n Bad:\n Value: !Ref Missing\n";
3707 let result = SemanticModel::parse(template.as_bytes(), ParseConfig::default()).unwrap();
3708 let ref_defect = result
3709 .model
3710 .diagnostics
3711 .iter()
3712 .find(|d| d.rule_id == "F6101" && d.message.contains("Missing"))
3713 .expect("F6101 for missing ref");
3714 assert_eq!(
3715 ref_defect.property_path.as_deref(),
3716 Some("Outputs.Bad.Value.Ref"),
3717 "parse-time output ref diagnostic must have dot-separated path"
3718 );
3719 assert_ne!(ref_defect.span, crate::UNKNOWN_SPAN, "span must be resolved");
3720 }
3721}