1use crate::coercion::type_compatible;
2use crate::consts::*;
3use crate::ir::*;
4use crate::json_value::JsonValue;
5use crate::message::render_str_list;
6use crate::pattern::{default_matches_pattern, is_service_valid};
7use crate::regions::*;
8use base64::Engine as _;
9use log::{debug, warn};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
18#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
19#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
20pub enum ResolvedValue {
21 Concrete { value: JsonValue },
23 #[cfg_attr(feature = "uniffi-bindings", uniffi(name = "ListValue"))]
25 List { items: Vec<ResolvedValue> },
26 Map { entries: Vec<MapEntry> },
28 #[cfg_attr(feature = "uniffi-bindings", uniffi(name = "EnumValue"))]
30 Enum { variants: Vec<ResolvedValue> },
31 Conditional { condition: String, if_true: Box<ResolvedValue>, if_false: Box<ResolvedValue> },
33 Reference { target: String, kind: RefKind },
35 Dynamic { reason: String },
37 TypedDynamic { reason: String, param_type: String },
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
43#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
44pub struct MapEntry {
45 pub key: String,
46 pub value: ResolvedValue,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
53#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
54#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
55pub enum RefKind {
56 Ref,
57 GetAtt {
59 attr: String,
60 },
61 Sub {
63 var: String,
64 },
65 DependsOn,
66}
67
68#[derive(Debug, Clone)]
69pub struct ResolverEdge {
70 pub source_resource: String,
71 pub source_path: String,
72 pub target: String,
73 pub kind: RefKind,
74 pub span: SourceSpan,
75 pub condition_context: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
81#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
82#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
83#[serde(rename_all = "camelCase")]
84pub struct ParameterInfo {
85 pub param_type: String,
87 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
88 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
89 pub default: Option<String>,
90 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
91 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
92 pub allowed_values: Option<Vec<String>>,
93 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
94 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
95 pub allowed_pattern: Option<String>,
96 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
97 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
98 pub min_length: Option<u64>,
99 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
100 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
101 pub max_length: Option<u64>,
102 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
103 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
104 pub min_value: Option<i64>,
105 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
106 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
107 pub max_value: Option<i64>,
108 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
109 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
110 pub description: Option<String>,
111 pub no_echo: bool,
113 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
115 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
116 pub allowed_pattern_valid: Option<bool>,
117 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
119 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
120 pub default_matches_allowed_pattern: Option<bool>,
121}
122
123pub type MappingData = HashMap<String, HashMap<String, HashMap<String, serde_json::Value>>>;
124
125pub(crate) struct Resolver<'a> {
126 arena: &'a Arena,
127 parameters: &'a HashMap<String, ParameterInfo>,
128 mappings: &'a MappingData,
129 resource_ids: HashSet<String>,
130 pub(crate) edges: Vec<ResolverEdge>,
131 pub(crate) diagnostics: Vec<ParseDefect>,
132 pub(crate) find_in_map_refs: HashMap<String, Vec<String>>,
133 pub(crate) simple_subs: HashMap<String, Vec<(String, String)>>,
134 pub(crate) redundant_subs: HashMap<String, Vec<String>>,
135 pub(crate) empty_joins: HashMap<String, Vec<String>>,
136 pub(crate) hardcoded_partition_arns: HashMap<String, Vec<String>>,
137 pub(crate) foreach_expansions: HashMap<String, Vec<(String, String, String)>>,
138 pub(crate) unsubstituted_variables: HashMap<String, Vec<(String, String)>>,
139 pub(crate) unused_sub_keys: HashMap<String, Vec<(String, String)>>,
140 pub(crate) raw_pseudo_params: HashMap<String, Vec<(String, String)>>,
141 pub(crate) secretsmanager_ref_paths: HashMap<String, Vec<String>>,
142 pub(crate) invalid_refs: HashMap<String, Vec<(String, String)>>,
143 pub(crate) extra_condition_refs: HashMap<String, Vec<String>>,
144 pub(crate) inline_conditions: Vec<(String, crate::conditions::ConditionExpr)>,
145 resolution_source_map: HashMap<(String, String), String>, value_node_map: HashMap<(String, String), NodeRef>,
149 parameter_overrides: &'a HashMap<String, String>,
150 pseudo_parameter_overrides: &'a crate::model::PseudoParameterOverrides,
151
152 pub(crate) current_resource: Option<String>,
153 condition_stack: Vec<(String, bool)>, current_path: String,
155 depth: u32,
156 pub(crate) depth_exceeded: bool,
158 pub(crate) enum_expansion_exceeded: bool,
160 local_bindings: HashMap<String, ResolvedValue>,
161 pub(crate) def_subs_resources: HashMap<String, HashSet<String>>,
165}
166
167impl<'a> Resolver<'a> {
168 pub fn new(
169 arena: &'a Arena,
170 parameters: &'a HashMap<String, ParameterInfo>,
171 mappings: &'a MappingData,
172 resource_ids: HashSet<String>,
173 parameter_overrides: &'a HashMap<String, String>,
174 pseudo_parameter_overrides: &'a crate::model::PseudoParameterOverrides,
175 ) -> Self {
176 Self {
177 arena,
178 parameters,
179 mappings,
180 resource_ids,
181 edges: Vec::new(),
182 diagnostics: Vec::new(),
183 find_in_map_refs: HashMap::new(),
184 simple_subs: HashMap::new(),
185 redundant_subs: HashMap::new(),
186 empty_joins: HashMap::new(),
187 hardcoded_partition_arns: HashMap::new(),
188 foreach_expansions: HashMap::new(),
189 unsubstituted_variables: HashMap::new(),
190 unused_sub_keys: HashMap::new(),
191 raw_pseudo_params: HashMap::new(),
192 secretsmanager_ref_paths: HashMap::new(),
193 invalid_refs: HashMap::new(),
194 extra_condition_refs: HashMap::new(),
195 inline_conditions: Vec::new(),
196 resolution_source_map: HashMap::new(),
197 value_node_map: HashMap::new(),
198 parameter_overrides,
199 pseudo_parameter_overrides,
200
201 current_resource: None,
202 condition_stack: Vec::new(),
203 current_path: String::new(),
204 depth: 0,
205 depth_exceeded: false,
206 enum_expansion_exceeded: false,
207 local_bindings: HashMap::new(),
208 def_subs_resources: HashMap::new(),
209 }
210 }
211
212 pub fn resolution_sources(&self) -> HashMap<(String, String), String> {
213 self.resolution_source_map.clone()
214 }
215
216 pub fn value_nodes(&self) -> HashMap<(String, String), NodeRef> {
217 self.value_node_map.clone()
218 }
219
220 pub fn resolve_node(&mut self, node_ref: NodeRef) -> ResolvedValue {
221 if node_ref == NULL_REF {
222 warn!("Attempted to resolve NULL_REF node at path '{}'", self.current_path);
223 return ResolvedValue::Dynamic { reason: "null reference".into() };
224 }
225 if self.depth >= MAX_RESOLVE_DEPTH {
226 warn!("Recursion depth limit ({}) exceeded at path '{}'", MAX_RESOLVE_DEPTH, self.current_path);
227 self.depth_exceeded = true;
228 return ResolvedValue::Dynamic { reason: "recursion limit exceeded".into() };
229 }
230 self.depth += 1;
231 let result = self.resolve_node_inner(node_ref);
232 self.depth -= 1;
233 let result = opaque_if_dynamic_reference(result);
234 let authored_intrinsic = matches!(self.arena.node(node_ref), Node::Intrinsic(_));
239 if (!matches!(result, ResolvedValue::Concrete { value: _ }) || authored_intrinsic)
240 && let Some(ref resource_id) = self.current_resource
241 {
242 self.value_node_map.insert((resource_id.clone(), self.current_path.clone()), node_ref);
243 }
244 result
245 }
246
247 fn resolve_node_inner(&mut self, node_ref: NodeRef) -> ResolvedValue {
248 let spanned = self.arena.get(node_ref);
249 let span = spanned.span;
250 match &spanned.node {
251 Node::Null => ResolvedValue::Concrete { value: serde_json::Value::Null.into() },
252 Node::Bool(b) => ResolvedValue::Concrete { value: serde_json::Value::Bool(*b).into() },
253 Node::Int(i) => ResolvedValue::Concrete { value: serde_json::json!(*i).into() },
254 Node::Float(f) => ResolvedValue::Concrete { value: serde_json::json!(*f).into() },
255 Node::String(s) => {
256 self.detect_unsubstituted_variables(s);
260 self.detect_raw_pseudo_param(s);
261 self.detect_secretsmanager_ref(s);
262 ResolvedValue::Concrete { value: serde_json::Value::String(s.clone()).into() }
263 }
264 Node::List(items) => {
265 let items = items.clone(); let saved = self.current_path.clone();
267 let resolved: Vec<ResolvedValue> = items
268 .iter()
269 .enumerate()
270 .map(|(i, r)| {
271 self.current_path = format!("{}.{}", saved, i);
272 self.resolve_node(*r)
273 })
274 .collect();
275 self.current_path = saved;
276 if resolved.iter().all(|v| matches!(v, ResolvedValue::Concrete { value: _ })) {
277 let vals: Vec<serde_json::Value> = resolved
278 .into_iter()
279 .map(|v| match v {
280 ResolvedValue::Concrete { value: c } => c.into_inner(),
281 _ => unreachable!(),
282 })
283 .collect();
284 ResolvedValue::Concrete { value: serde_json::Value::Array(vals).into() }
285 } else {
286 ResolvedValue::List { items: resolved }
287 }
288 }
289 Node::Map(entries) => {
290 let refs: Vec<(String, NodeRef)> = entries.iter().map(|(k, v)| (k.clone(), *v)).collect();
291 let saved = self.current_path.clone();
292 let resolved: Vec<MapEntry> = refs
293 .into_iter()
294 .map(|(k, v)| {
295 self.current_path = format!("{}.{}", saved, k);
296 let val = self.resolve_node(v);
297 MapEntry { key: k, value: val }
298 })
299 .collect();
300 self.current_path = saved;
301 if resolved.iter().all(|entry| matches!(entry.value, ResolvedValue::Concrete { value: _ })) {
302 let mut map = serde_json::Map::new();
303 for entry in resolved {
304 if let ResolvedValue::Concrete { value: c } = entry.value {
305 map.insert(entry.key, c.into_inner());
306 }
307 }
308 ResolvedValue::Concrete { value: serde_json::Value::Object(map).into() }
309 } else {
310 ResolvedValue::Map { entries: resolved }
311 }
312 }
313 Node::Intrinsic(intrinsic) => {
314 let intrinsic = intrinsic.clone(); self.resolve_intrinsic(&intrinsic, &span)
316 }
317 }
318 }
319
320 fn resolve_intrinsic(&mut self, intrinsic: &IntrinsicFn, span: &SourceSpan) -> ResolvedValue {
321 debug!("Resolve intrinsic {} at {}", intrinsic_name(intrinsic), self.current_path);
322 if let Some(ref rid) = self.current_resource
330 && matches!(
331 intrinsic,
332 IntrinsicFn::Join(_, _)
333 | IntrinsicFn::Sub(_, _)
334 | IntrinsicFn::Select(_, _)
335 | IntrinsicFn::Split(_, _)
336 | IntrinsicFn::FindInMap(_, _, _, _)
337 | IntrinsicFn::Base64(_)
338 | IntrinsicFn::Cidr(_, _, _)
339 )
340 {
341 self.resolution_source_map
342 .entry((rid.clone(), self.current_path.clone()))
343 .or_insert_with(|| format!("Intrinsic/{}", intrinsic_name(intrinsic)));
344 }
345 match intrinsic {
346 IntrinsicFn::Ref(target) => self.resolve_ref(target, span),
347 IntrinsicFn::GetAtt(resource, attr) => {
348 if self.is_output_context() {
349 let path = format!("{}.Fn::GetAtt", self.current_path);
350 self.record_edge_at_path(resource, RefKind::GetAtt { attr: attr.clone() }, span, path);
351 } else {
352 self.record_edge(resource, RefKind::GetAtt { attr: attr.clone() }, span);
353 }
354 ResolvedValue::Reference { target: resource.clone(), kind: RefKind::GetAtt { attr: attr.clone() } }
355 }
356 IntrinsicFn::If(cond, t_ref, f_ref) => {
357 let saved = self.current_path.clone();
358 self.current_path = format!("{}.Fn::If", saved);
359 self.condition_stack.push((cond.clone(), true));
360 self.current_path = format!("{}.Fn::If.1", saved);
361 let true_branch = self.resolve_node(*t_ref);
362 self.condition_stack.pop();
363 self.condition_stack.push((cond.clone(), false));
364 self.current_path = format!("{}.Fn::If.2", saved);
365 let false_branch = self.resolve_node(*f_ref);
366 self.condition_stack.pop();
367 self.current_path = saved;
368 ResolvedValue::Conditional {
369 condition: cond.clone(),
370 if_true: Box::new(true_branch),
371 if_false: Box::new(false_branch),
372 }
373 }
374 IntrinsicFn::IfExpr(cond_ref, t_ref, f_ref) => {
375 let saved = self.current_path.clone();
376 self.current_path = format!("{}.Fn::If.1", saved);
377 let true_branch = self.resolve_node(*t_ref);
378 self.current_path = format!("{}.Fn::If.2", saved);
379 let false_branch = self.resolve_node(*f_ref);
380 self.current_path = saved;
381 let _cond_resolved = self.resolve_node(*cond_ref);
383
384 let parsed_expr = crate::conditions::parse_condition_expr(self.arena, *cond_ref, self.parameters);
386 if let crate::conditions::ConditionExpr::Equals(
388 crate::conditions::ValueExpr::Literal(a),
389 crate::conditions::ValueExpr::Literal(b),
390 ) = &parsed_expr
391 {
392 return if a == b { true_branch } else { false_branch };
393 }
394
395 let cond_label = format!("__inline_cond_{}", cond_ref);
396 self.inline_conditions.push((cond_label.clone(), parsed_expr));
397 ResolvedValue::Conditional {
398 condition: cond_label,
399 if_true: Box::new(true_branch),
400 if_false: Box::new(false_branch),
401 }
402 }
403 IntrinsicFn::FindInMap(map_name_ref, k1_ref, k2_ref, default_ref) => {
404 self.resolve_findinmap(*map_name_ref, *k1_ref, *k2_ref, *default_ref)
405 }
406 IntrinsicFn::Sub(template, subs) => self.resolve_sub(template, subs, span),
407 IntrinsicFn::Join(delim_ref, values_ref) => {
408 let saved = self.current_path.clone();
409 let join_path = format!("{}.Fn::Join", saved);
410 self.current_path = format!("{}.0", join_path);
411 let delim = self.resolve_node(*delim_ref);
412 if let ResolvedValue::Concrete { value: d } = &delim
414 && d.as_str() == Some("")
415 && self.is_simple_join(*values_ref)
416 {
417 let key = self.current_resource.clone().unwrap_or_else(|| OUTPUTS_PSEUDO_RESOURCE.into());
418 self.empty_joins.entry(key).or_default().push(join_path.clone());
419 }
420 self.current_path = format!("{}.1", join_path);
421 let values = self.resolve_node(*values_ref);
422 self.current_path = saved;
423 match (&delim, &values) {
424 (ResolvedValue::Concrete { value: d }, ResolvedValue::Concrete { value: v }) => {
425 if let (Some(ds), Some(arr)) = (d.as_str(), v.as_array()) {
426 let parts: Vec<String> =
427 arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
428 if parts.len() == arr.len() {
429 return ResolvedValue::Concrete {
430 value: serde_json::Value::String(parts.join(ds)).into(),
431 };
432 }
433 }
434 self.collect_extra_condition_refs(&values);
435 ResolvedValue::Dynamic { reason: "Join with non-string elements".into() }
436 }
437 (
438 ResolvedValue::Concrete { value: d },
439 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f },
440 ) => {
441 if let Some(ds) = d.as_str() {
442 return ResolvedValue::Conditional {
443 condition: cond.clone(),
444 if_true: Box::new(join_resolved(ds, t)),
445 if_false: Box::new(join_resolved(ds, f)),
446 };
447 }
448 self.collect_extra_condition_refs(&values);
449 ResolvedValue::Dynamic { reason: "Join with non-string delimiter".into() }
450 }
451 (ResolvedValue::Concrete { value: d }, ResolvedValue::List { items }) => {
452 if let Some(ds) = d.as_str() {
453 if items.iter().any(|v| {
454 matches!(
455 v,
456 ResolvedValue::Enum { variants: _ }
457 | ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ }
458 )
459 }) {
460 let (result, was_curtailed) = join_with_enum_list(ds, items);
461 if was_curtailed {
462 self.enum_expansion_exceeded = true;
463 }
464 return result;
465 }
466 let parts: Vec<String> = items
468 .iter()
469 .map(|v| match v {
470 ResolvedValue::Concrete { value: cv } => cv.as_str().unwrap_or("").to_string(),
471 ResolvedValue::Reference { target, .. } => {
472 format!("{}{}}}", UNRESOLVED_REF_PLACEHOLDER_PREFIX, target)
473 }
474 _ => UNRESOLVED_DYNAMIC_PLACEHOLDER.to_string(),
475 })
476 .collect();
477 self.collect_extra_condition_refs(&values);
478 return ResolvedValue::Dynamic {
479 reason: format!("{}{}", JOIN_PARTIAL_PREFIX, parts.join(ds)),
480 };
481 }
482 self.collect_extra_condition_refs(&values);
483 ResolvedValue::Dynamic { reason: "Join with unresolvable arguments".into() }
484 }
485 (ResolvedValue::Concrete { value: d }, ResolvedValue::Enum { variants }) => {
486 if let Some(ds) = d.as_str() {
487 let results: Vec<ResolvedValue> = variants.iter().map(|v| join_resolved(ds, v)).collect();
488 return ResolvedValue::Enum { variants: results };
489 }
490 self.collect_extra_condition_refs(&values);
491 ResolvedValue::Dynamic { reason: "Join with non-string delimiter".into() }
492 }
493 _ => {
494 self.collect_extra_condition_refs(&values);
495 ResolvedValue::Dynamic { reason: "Join with unresolvable arguments".into() }
496 }
497 }
498 }
499 IntrinsicFn::Select(idx_ref, list_ref) => {
500 let idx = self.resolve_node(*idx_ref);
501 let list = self.resolve_node(*list_ref);
502 match (&idx, &list) {
503 (ResolvedValue::Concrete { value: i }, ResolvedValue::Concrete { value: l }) => {
504 if let Some(arr) = l.as_array()
505 && let Some(idx) = i.as_u64()
506 {
507 if (idx as usize) < arr.len() {
508 return ResolvedValue::Concrete { value: arr[idx as usize].clone().into() };
509 }
510 return ResolvedValue::Dynamic { reason: "Select index out of bounds".into() };
511 }
512 ResolvedValue::Dynamic { reason: "Select on non-list value".into() }
513 }
514 (
515 ResolvedValue::Concrete { value: i },
516 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f },
517 ) => ResolvedValue::Conditional {
518 condition: cond.clone(),
519 if_true: Box::new(select_resolved(i, t)),
520 if_false: Box::new(select_resolved(i, f)),
521 },
522 (ResolvedValue::Concrete { value: i }, ResolvedValue::Enum { variants }) => {
523 let results: Vec<ResolvedValue> = variants.iter().map(|v| select_resolved(i, v)).collect();
524 ResolvedValue::Enum { variants: results }
525 }
526 (ResolvedValue::Concrete { value: i }, ResolvedValue::List { items }) => {
527 if let Some(idx) = i.as_u64() {
528 if (idx as usize) < items.len() {
529 return items[idx as usize].clone();
530 }
531 return ResolvedValue::Dynamic { reason: "Select index out of bounds".into() };
532 }
533 ResolvedValue::Dynamic { reason: "Select with non-integer index".into() }
534 }
535 _ => ResolvedValue::Dynamic { reason: "Select with unresolvable arguments".into() },
536 }
537 }
538 IntrinsicFn::Split(delim_ref, src_ref) => {
539 let delim = self.resolve_node(*delim_ref);
540 let src = self.resolve_node(*src_ref);
541 match (&delim, &src) {
542 (ResolvedValue::Concrete { value: d }, ResolvedValue::Concrete { value: s }) => {
543 if let (Some(ds), Some(ss)) = (d.as_str(), s.as_str()) {
544 let parts: Vec<serde_json::Value> =
545 ss.split(ds).map(|p| serde_json::Value::String(p.to_string())).collect();
546 return ResolvedValue::Concrete { value: serde_json::Value::Array(parts).into() };
547 }
548 ResolvedValue::Dynamic { reason: "Split with non-string arguments".into() }
549 }
550 (ResolvedValue::Concrete { value: d }, ResolvedValue::Enum { variants }) => {
551 if let Some(ds) = d.as_str() {
552 let results: Vec<ResolvedValue> = variants.iter().map(|v| split_resolved(ds, v)).collect();
553 return ResolvedValue::Enum { variants: results };
554 }
555 ResolvedValue::Dynamic { reason: "Split with non-string delimiter".into() }
556 }
557 (
558 ResolvedValue::Concrete { value: d },
559 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f },
560 ) => {
561 if let Some(ds) = d.as_str() {
562 return ResolvedValue::Conditional {
563 condition: cond.clone(),
564 if_true: Box::new(split_resolved(ds, t)),
565 if_false: Box::new(split_resolved(ds, f)),
566 };
567 }
568 ResolvedValue::Dynamic { reason: "Split with non-string delimiter".into() }
569 }
570 _ => ResolvedValue::Dynamic { reason: "Split with unresolvable arguments".into() },
571 }
572 }
573 IntrinsicFn::Base64(val_ref) => {
574 let saved = self.current_path.clone();
575 self.current_path = format!("{}.Fn::Base64", saved);
576 let val = self.resolve_node(*val_ref);
577 self.current_path = saved;
578 match &val {
579 ResolvedValue::Concrete { value: v } => {
580 if let Some(s) = v.as_str() {
581 let encoded = base64::engine::general_purpose::STANDARD.encode(s);
582 return ResolvedValue::Concrete { value: serde_json::Value::String(encoded).into() };
583 }
584 ResolvedValue::Dynamic { reason: "Base64 with non-string argument".into() }
585 }
586 ResolvedValue::Enum { variants } => {
587 let results: Vec<ResolvedValue> = variants.iter().map(base64_resolved).collect();
588 ResolvedValue::Enum { variants: results }
589 }
590 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
591 ResolvedValue::Conditional {
592 condition: cond.clone(),
593 if_true: Box::new(base64_resolved(t)),
594 if_false: Box::new(base64_resolved(f)),
595 }
596 }
597 _ => ResolvedValue::Dynamic { reason: "Base64 with unresolvable argument".into() },
598 }
599 }
600 IntrinsicFn::ImportValue(arg) => {
601 let reason = match self.resolve_node(*arg) {
602 ResolvedValue::Concrete { value } => match value.as_str() {
603 Some(export) => format!("cross-stack import: {export}"),
604 None => "cross-stack import".into(),
605 },
606 _ => "cross-stack import".into(),
607 };
608 ResolvedValue::TypedDynamic { reason, param_type: PARAM_TYPE_STRING.into() }
609 }
610 IntrinsicFn::GetStackOutput(args) => {
611 let saved = self.current_path.clone();
612 let mut concrete_arguments: Vec<(&str, String)> = Vec::with_capacity(args.len());
613 for (key, arg) in args {
614 self.current_path = format!("{}.{}", saved, key);
615 if let ResolvedValue::Concrete { value } = self.resolve_node(*arg)
616 && let Some(literal) = value.as_str()
617 {
618 concrete_arguments.push((key.as_str(), literal.to_string()));
619 }
620 }
621 self.current_path = saved;
622
623 let identity: Vec<String> = [KEY_STACK_NAME, KEY_REGION, KEY_OUTPUT_NAME]
629 .into_iter()
630 .filter_map(|key| {
631 concrete_arguments
632 .iter()
633 .find(|(name, _)| *name == key)
634 .map(|(_, literal)| format!("{key}={literal:?}"))
635 })
636 .collect();
637 let reason = if identity.is_empty() {
638 "cross-stack output".to_string()
639 } else {
640 format!("cross-stack output: {}", identity.join(", "))
641 };
642 ResolvedValue::Dynamic { reason }
643 }
644 IntrinsicFn::Transform(_, parameters) => {
645 let saved = self.current_path.clone();
646 for (key, argument) in parameters {
647 self.current_path = format!("{}.Fn::Transform.Parameters.{}", saved, key);
648 self.resolve_node(*argument);
649 }
650 self.current_path = saved;
651 ResolvedValue::Dynamic { reason: "macro output".into() }
652 }
653 IntrinsicFn::GetAZs(region_ref) => {
654 if let Some(ref rid) = self.current_resource {
655 self.resolution_source_map
656 .insert((rid.clone(), self.current_path.clone()), "Intrinsic/Fn::GetAZs".to_string());
657 }
658 let region_val = self.resolve_node(*region_ref);
659 resolve_getazs_value(®ion_val, self.pseudo_parameter_overrides)
660 }
661 IntrinsicFn::Cidr(ip_ref, count_ref, bits_ref) => {
662 let ip_val = self.resolve_node(*ip_ref);
663 let count_val = self.resolve_node(*count_ref);
664 let bits_val = self.resolve_node(*bits_ref);
665 match (&ip_val, &count_val, &bits_val) {
666 (
667 ResolvedValue::Concrete { value: ip },
668 ResolvedValue::Concrete { value: cnt },
669 ResolvedValue::Concrete { value: bits },
670 ) => {
671 let ip_str = ip.as_str().unwrap_or("");
672 let count = cnt.as_u64().or_else(|| cnt.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
673 let cidr_bits =
674 bits.as_u64().or_else(|| bits.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
675 match calculate_cidr_blocks(ip_str, count, cidr_bits) {
676 Some(blocks) => ResolvedValue::Concrete {
677 value: serde_json::Value::Array(
678 blocks.into_iter().map(serde_json::Value::String).collect(),
679 )
680 .into(),
681 },
682 None => ResolvedValue::Dynamic { reason: "Cidr calculation failed".into() },
683 }
684 }
685 _ => {
686 if let ResolvedValue::Enum { variants } = &ip_val {
688 let results: Vec<ResolvedValue> =
689 variants.iter().map(|v| resolve_cidr_value(v, &count_val, &bits_val)).collect();
690 return ResolvedValue::Enum { variants: results };
691 }
692 if let ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } = &ip_val {
694 return ResolvedValue::Conditional {
695 condition: cond.clone(),
696 if_true: Box::new(resolve_cidr_value(t, &count_val, &bits_val)),
697 if_false: Box::new(resolve_cidr_value(f, &count_val, &bits_val)),
698 };
699 }
700 ResolvedValue::Dynamic { reason: "Cidr runtime value".into() }
701 }
702 }
703 }
704 IntrinsicFn::Equals(a_ref, b_ref) => {
705 let left = self.resolve_node(*a_ref);
706 let right = self.resolve_node(*b_ref);
707 self.collect_extra_condition_refs(&left);
708 self.collect_extra_condition_refs(&right);
709 match (&left, &right) {
710 (ResolvedValue::Concrete { value: lv }, ResolvedValue::Concrete { value: rv }) => {
711 ResolvedValue::Concrete { value: serde_json::Value::Bool(lv == rv).into() }
712 }
713 (ResolvedValue::Enum { variants }, ResolvedValue::Concrete { value: rv }) => {
714 let results: Vec<ResolvedValue> = variants
715 .iter()
716 .map(|v| match v {
717 ResolvedValue::Concrete { value: lv } => {
718 ResolvedValue::Concrete { value: serde_json::Value::Bool(lv == rv).into() }
719 }
720 _ => ResolvedValue::Dynamic { reason: "condition expression".into() },
721 })
722 .collect();
723 ResolvedValue::Enum { variants: results }
724 }
725 (ResolvedValue::Concrete { value: lv }, ResolvedValue::Enum { variants }) => {
726 let results: Vec<ResolvedValue> = variants
727 .iter()
728 .map(|v| match v {
729 ResolvedValue::Concrete { value: rv } => {
730 ResolvedValue::Concrete { value: serde_json::Value::Bool(lv == rv).into() }
731 }
732 _ => ResolvedValue::Dynamic { reason: "condition expression".into() },
733 })
734 .collect();
735 ResolvedValue::Enum { variants: results }
736 }
737 _ => ResolvedValue::Dynamic { reason: "condition expression".into() },
738 }
739 }
740 IntrinsicFn::And(children) => {
741 let resolved: Vec<ResolvedValue> = children.iter().map(|c| self.resolve_node(*c)).collect();
742 for v in &resolved {
743 self.collect_extra_condition_refs(v);
744 }
745 let all_concrete_bool = resolved
746 .iter()
747 .all(|v| matches!(v, ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Bool(_)) }));
748 if all_concrete_bool {
749 let all_true = resolved.iter().all(|v| match v {
750 ResolvedValue::Concrete { value: bv } => bv.as_bool().unwrap_or(false),
751 _ => false,
752 });
753 ResolvedValue::Concrete { value: serde_json::Value::Bool(all_true).into() }
754 } else {
755 ResolvedValue::Dynamic { reason: "condition expression".into() }
756 }
757 }
758 IntrinsicFn::Or(children) => {
759 let resolved: Vec<ResolvedValue> = children.iter().map(|c| self.resolve_node(*c)).collect();
760 for v in &resolved {
761 self.collect_extra_condition_refs(v);
762 }
763 let all_concrete_bool = resolved
764 .iter()
765 .all(|v| matches!(v, ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Bool(_)) }));
766 if all_concrete_bool {
767 let any_true = resolved.iter().any(|v| match v {
768 ResolvedValue::Concrete { value: bv } => bv.as_bool().unwrap_or(false),
769 _ => false,
770 });
771 ResolvedValue::Concrete { value: serde_json::Value::Bool(any_true).into() }
772 } else {
773 ResolvedValue::Dynamic { reason: "condition expression".into() }
774 }
775 }
776 IntrinsicFn::Not(child) => {
777 let resolved = self.resolve_node(*child);
778 self.collect_extra_condition_refs(&resolved);
779 match &resolved {
780 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Bool(b)) } => {
781 ResolvedValue::Concrete { value: serde_json::Value::Bool(!b).into() }
782 }
783 _ => ResolvedValue::Dynamic { reason: "condition expression".into() },
784 }
785 }
786 IntrinsicFn::RefAll(_) => ResolvedValue::Dynamic { reason: "rules-only function".into() },
787 IntrinsicFn::ValueOf(param_name, _attr) | IntrinsicFn::ValueOfAll(param_name, _attr) => {
788 self.record_edge(param_name, RefKind::Ref, span);
791 ResolvedValue::Dynamic { reason: "rules-only function".into() }
792 }
793 IntrinsicFn::Contains(list_ref, value_ref)
794 | IntrinsicFn::EachMemberEquals(list_ref, value_ref)
795 | IntrinsicFn::EachMemberIn(list_ref, value_ref) => {
796 self.resolve_node(*list_ref);
800 self.resolve_node(*value_ref);
801 ResolvedValue::Dynamic { reason: "rules-only function".into() }
802 }
803 IntrinsicFn::ToJsonString(val_ref) => {
804 let val = self.resolve_node(*val_ref);
805 match &val {
806 ResolvedValue::Concrete { value: v } => {
807 ResolvedValue::Concrete { value: serde_json::Value::String(v.to_string()).into() }
808 }
809 ResolvedValue::Enum { variants } => {
810 let results: Vec<ResolvedValue> = variants.iter().map(to_json_string_resolved).collect();
811 ResolvedValue::Enum { variants: results }
812 }
813 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
814 ResolvedValue::Conditional {
815 condition: cond.clone(),
816 if_true: Box::new(to_json_string_resolved(t)),
817 if_false: Box::new(to_json_string_resolved(f)),
818 }
819 }
820 _ => ResolvedValue::Dynamic { reason: "ToJsonString with unresolvable argument".into() },
821 }
822 }
823 IntrinsicFn::Length(val_ref) => {
824 let val = self.resolve_node(*val_ref);
825 match &val {
826 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Array(arr)) } => {
827 ResolvedValue::Concrete { value: serde_json::json!(arr.len()).into() }
828 }
829 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Object(map)) } => {
830 ResolvedValue::Concrete { value: serde_json::json!(map.len()).into() }
831 }
832 ResolvedValue::List { items } => {
833 ResolvedValue::Concrete { value: serde_json::json!(items.len()).into() }
834 }
835 ResolvedValue::Map { entries } => {
836 ResolvedValue::Concrete { value: serde_json::json!(entries.len()).into() }
837 }
838 ResolvedValue::Enum { variants } => {
839 let results: Vec<ResolvedValue> = variants.iter().map(length_resolved).collect();
840 ResolvedValue::Enum { variants: results }
841 }
842 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
843 ResolvedValue::Conditional {
844 condition: cond.clone(),
845 if_true: Box::new(length_resolved(t)),
846 if_false: Box::new(length_resolved(f)),
847 }
848 }
849 _ => ResolvedValue::Dynamic { reason: "Length with unresolvable argument".into() },
850 }
851 }
852 IntrinsicFn::ForEach(unique_id, identifier, collection_ref, body_ref) => {
853 let unique_id = unique_id.clone();
854 let identifier = identifier.clone();
855 let collection_ref = *collection_ref;
856 let body_ref = *body_ref;
857 if let Some(ref rid) = self.current_resource {
858 let collection_desc = match self.arena.node(collection_ref) {
859 Node::Intrinsic(IntrinsicFn::Ref(t)) => format!("Ref({})", t),
860 Node::List(items) => format!("[{} items]", items.len()),
861 _ => "dynamic".to_string(),
862 };
863 self.foreach_expansions.entry(rid.clone()).or_default().push((
864 self.current_path.clone(),
865 identifier.clone(),
866 collection_desc,
867 ));
868 }
869 let collection = self.resolve_node(collection_ref);
870 let elements: Option<Vec<ResolvedValue>> = match &collection {
871 ResolvedValue::Concrete { value: v } => v.as_array().map(|arr| {
872 arr.iter().map(|item| ResolvedValue::Concrete { value: item.clone().into() }).collect()
873 }),
874 ResolvedValue::List { items } => Some(items.clone()),
875 _ => None,
876 };
877 if let Some(elements) = elements {
878 let mut result_entries = Vec::new();
879 for element in &elements {
880 self.local_bindings.insert(identifier.clone(), element.clone());
881 let resolved_body = self.resolve_node(body_ref);
882 self.local_bindings.remove(&identifier);
883 let key = match element {
885 ResolvedValue::Concrete { value: v } => {
886 format!("{}{}", unique_id, v.as_str().unwrap_or(&v.to_string()))
887 }
888 _ => format!("{}{}", unique_id, "{dynamic}"),
889 };
890 result_entries.push(MapEntry { key, value: resolved_body });
891 }
892 return ResolvedValue::Map { entries: result_entries };
893 }
894 ResolvedValue::Dynamic { reason: "ForEach macro output".into() }
895 }
896 }
897 }
898
899 fn resolve_ref(&mut self, target: &str, span: &SourceSpan) -> ResolvedValue {
900 if let Some(resolved) = self.lookup_ref(target, span) {
901 return resolved;
902 }
903 debug!("Ref '{}' does not reference a valid target", target);
907 if let Some(ref rid) = self.current_resource {
908 self.invalid_refs.entry(rid.clone()).or_default().push((self.current_path.clone(), target.to_string()));
909 }
910 ResolvedValue::Dynamic { reason: format!("unknown ref target: {}", target) }
911 }
912
913 fn lookup_ref(&mut self, target: &str, span: &SourceSpan) -> Option<ResolvedValue> {
920 if let Some(cond_name) = target.strip_prefix(CONDITION_REF_PREFIX) {
921 return Some(ResolvedValue::Dynamic { reason: format!("condition reference: {}", cond_name) });
922 }
923
924 if let Some(bound) = self.local_bindings.get(target) {
926 return Some(bound.clone());
927 }
928
929 if PSEUDO_PARAMETERS.contains(&target) {
930 if let Some(ref rid) = self.current_resource {
931 self.resolution_source_map
932 .entry((rid.clone(), self.current_path.clone()))
933 .or_insert_with(|| format!("Intrinsic/{}", TAG_REF));
934 }
935 if target == PSEUDO_NO_VALUE {
936 return Some(ResolvedValue::Concrete { value: serde_json::Value::Null.into() });
937 }
938 if let Some(value) = self.pseudo_parameter_overrides.get(target) {
939 return Some(ResolvedValue::Concrete { value: pseudo_parameter_ref_to_json(target, value).into() });
940 }
941 return Some(ResolvedValue::Dynamic { reason: format!("pseudo-parameter {}", target) });
942 }
943
944 if let Some(override_val) = self.parameter_overrides.get(target)
946 && let Some(param) = self.parameters.get(target)
947 {
948 self.record_edge(target, RefKind::Ref, span);
949 if let Some(ref rid) = self.current_resource {
950 self.resolution_source_map
951 .insert((rid.clone(), self.current_path.clone()), format!("Parameters/{}/Override", target));
952 }
953 let json_val = param_string_to_json(override_val, ¶m.param_type);
954 return Some(ResolvedValue::Concrete { value: json_val.into() });
955 }
956
957 if let Some(param) = self.parameters.get(target) {
958 self.record_edge(target, RefKind::Ref, span);
959 if let Some(ref rid) = self.current_resource {
960 let source = if param.allowed_values.is_some() {
961 format!("Parameters/{}/AllowedValues", target)
962 } else if param.default.is_some() {
963 format!("Parameters/{}/Default", target)
964 } else {
965 format!("Parameters/{}", target)
966 };
967 self.resolution_source_map.insert((rid.clone(), self.current_path.clone()), source);
968 }
969 if let Some(ref allowed) = param.allowed_values {
970 return Some(ResolvedValue::Enum {
971 variants: allowed
972 .iter()
973 .map(|v| ResolvedValue::Concrete { value: serde_json::Value::String(v.clone()).into() })
974 .collect(),
975 });
976 }
977 if let Some(ref default) = param.default {
978 let json_val = param_string_to_json(default, ¶m.param_type);
979 return Some(ResolvedValue::Concrete { value: json_val.into() });
980 }
981 return Some(ResolvedValue::TypedDynamic {
982 reason: format!("parameter {} value unknown", target),
983 param_type: param.param_type.clone(),
984 });
985 }
986
987 if self.resource_ids.contains(target) {
988 self.record_edge(target, RefKind::Ref, span);
989 return Some(ResolvedValue::Reference { target: target.to_string(), kind: RefKind::Ref });
990 }
991
992 None
993 }
994
995 fn resolve_findinmap(
996 &mut self,
997 map_name_ref: NodeRef,
998 first_key_ref: NodeRef,
999 second_key_ref: NodeRef,
1000 default_ref: Option<NodeRef>,
1001 ) -> ResolvedValue {
1002 let saved_path = self.current_path.clone();
1003 let fim_path = format!("{}.Fn::FindInMap", saved_path);
1004
1005 self.current_path = format!("{}.0", fim_path);
1006 let map_name_resolved = self.resolve_node(map_name_ref);
1007
1008 self.current_path = format!("{}.1", fim_path);
1009 let first_key = self.resolve_node(first_key_ref);
1010
1011 self.current_path = format!("{}.2", fim_path);
1012 let second_key = self.resolve_node(second_key_ref);
1013
1014 self.current_path = saved_path;
1015
1016 match &map_name_resolved {
1017 ResolvedValue::Concrete { value: name_val } => {
1018 let map_name = name_val.as_str().unwrap_or("");
1019 if let Some(ref rid) = self.current_resource {
1020 self.find_in_map_refs.entry(rid.clone()).or_default().push(map_name.to_string());
1021 }
1022 self.lookup_mapping(map_name, &first_key, &second_key, default_ref)
1023 }
1024 ResolvedValue::Enum { variants: name_variants } => {
1025 let results: Vec<ResolvedValue> = name_variants
1026 .iter()
1027 .map(|name_val| {
1028 let map_name = match name_val {
1029 ResolvedValue::Concrete { value: v } => v.as_str().unwrap_or("").to_string(),
1030 _ => {
1031 return ResolvedValue::Dynamic { reason: "non-concrete enum map name".into() };
1032 }
1033 };
1034 if let Some(ref rid) = self.current_resource {
1035 self.find_in_map_refs.entry(rid.clone()).or_default().push(map_name.clone());
1036 }
1037 self.lookup_mapping(&map_name, &first_key, &second_key, default_ref)
1038 })
1039 .collect();
1040 ResolvedValue::Enum { variants: results }
1041 }
1042 _ => {
1043 if let Some(def) = default_ref {
1044 return self.resolve_node(def);
1045 }
1046 ResolvedValue::Dynamic { reason: "FindInMap with dynamic map name".into() }
1047 }
1048 }
1049 }
1050
1051 fn lookup_mapping(
1052 &mut self,
1053 map_name: &str,
1054 first_key: &ResolvedValue,
1055 second_key: &ResolvedValue,
1056 default_ref: Option<NodeRef>,
1057 ) -> ResolvedValue {
1058 let Some(mapping) = self.mappings.get(map_name) else {
1059 warn!("FindInMap references non-existent mapping '{}'", map_name);
1060 if let Some(def) = default_ref {
1061 return self.resolve_node(def);
1062 }
1063 return ResolvedValue::Dynamic { reason: format!("mapping '{}' not found", map_name) };
1064 };
1065
1066 match (first_key, second_key) {
1067 (ResolvedValue::Concrete { value: k1v }, ResolvedValue::Concrete { value: k2v }) => {
1068 let k1s = k1v.as_str().unwrap_or("");
1069 let k2s = k2v.as_str().unwrap_or("");
1070 match mapping.get(k1s).and_then(|m| m.get(k2s)) {
1071 Some(v) => ResolvedValue::Concrete { value: v.clone().into() },
1072 None => {
1073 if let Some(def) = default_ref {
1074 return self.resolve_node(def);
1075 }
1076 ResolvedValue::Dynamic { reason: format!("no mapping entry for {}/{}/{}", map_name, k1s, k2s) }
1077 }
1078 }
1079 }
1080 (ResolvedValue::Concrete { value: k1v }, ResolvedValue::Enum { variants: k2_variants }) => {
1081 let k1s = k1v.as_str().unwrap_or("");
1082 let results: Vec<ResolvedValue> = k2_variants
1083 .iter()
1084 .map(|k2v| match k2v {
1085 ResolvedValue::Concrete { value: v } => {
1086 let k2s = v.as_str().unwrap_or("");
1087 match mapping.get(k1s).and_then(|m| m.get(k2s)) {
1088 Some(val) => ResolvedValue::Concrete { value: val.clone().into() },
1089 None => ResolvedValue::Dynamic {
1090 reason: format!("no mapping entry for {}/{}/{}", map_name, k1s, k2s),
1091 },
1092 }
1093 }
1094 _ => ResolvedValue::Dynamic { reason: "non-concrete enum key".into() },
1095 })
1096 .collect();
1097 ResolvedValue::Enum { variants: results }
1098 }
1099 (
1100 ResolvedValue::Concrete { value: _ },
1101 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f },
1102 ) => ResolvedValue::Conditional {
1103 condition: cond.clone(),
1104 if_true: Box::new(self.lookup_mapping(map_name, first_key, t, default_ref)),
1105 if_false: Box::new(self.lookup_mapping(map_name, first_key, f, default_ref)),
1106 },
1107 (ResolvedValue::Enum { variants: k1_vals }, _) => {
1108 let results: Vec<ResolvedValue> = k1_vals
1109 .iter()
1110 .map(|k1v| {
1111 let k1s = match k1v {
1112 ResolvedValue::Concrete { value: v } => v.as_str().unwrap_or("").to_string(),
1113 _ => {
1114 return ResolvedValue::Dynamic { reason: "non-concrete enum key".into() };
1115 }
1116 };
1117 match second_key {
1118 ResolvedValue::Concrete { value: k2v } => {
1119 let k2s = k2v.as_str().unwrap_or("");
1120 match mapping.get(&k1s).and_then(|m| m.get(k2s)) {
1121 Some(v) => ResolvedValue::Concrete { value: v.clone().into() },
1122 None => ResolvedValue::Dynamic {
1123 reason: format!("no mapping entry for {}/{}/{}", map_name, k1s, k2s),
1124 },
1125 }
1126 }
1127 ResolvedValue::Enum { variants: k2_variants } => {
1128 let inner: Vec<ResolvedValue> = k2_variants
1130 .iter()
1131 .map(|k2v| match k2v {
1132 ResolvedValue::Concrete { value: v } => {
1133 let k2s = v.as_str().unwrap_or("");
1134 match mapping.get(&k1s).and_then(|m| m.get(k2s)) {
1135 Some(val) => ResolvedValue::Concrete { value: val.clone().into() },
1136 None => ResolvedValue::Dynamic {
1137 reason: format!(
1138 "no mapping entry for {}/{}/{}",
1139 map_name, k1s, k2s
1140 ),
1141 },
1142 }
1143 }
1144 _ => ResolvedValue::Dynamic { reason: "non-concrete enum key".into() },
1145 })
1146 .collect();
1147 ResolvedValue::Enum { variants: inner }
1148 }
1149 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
1150 let k1_concrete =
1151 ResolvedValue::Concrete { value: serde_json::Value::String(k1s).into() };
1152 ResolvedValue::Conditional {
1153 condition: cond.clone(),
1154 if_true: Box::new(self.lookup_mapping(map_name, &k1_concrete, t, default_ref)),
1155 if_false: Box::new(self.lookup_mapping(map_name, &k1_concrete, f, default_ref)),
1156 }
1157 }
1158 _ => ResolvedValue::Dynamic { reason: "FindInMap with dynamic key2".into() },
1159 }
1160 })
1161 .collect();
1162 let flattened: Vec<ResolvedValue> = results
1164 .into_iter()
1165 .flat_map(|v| match v {
1166 ResolvedValue::Enum { variants } => variants,
1167 other => vec![other],
1168 })
1169 .collect();
1170 ResolvedValue::Enum { variants: flattened }
1171 }
1172 (_, ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f }) => {
1173 ResolvedValue::Conditional {
1174 condition: cond.clone(),
1175 if_true: Box::new(self.lookup_mapping(map_name, first_key, t, default_ref)),
1176 if_false: Box::new(self.lookup_mapping(map_name, first_key, f, default_ref)),
1177 }
1178 }
1179 _ => {
1180 if let Some(def) = default_ref {
1181 return self.resolve_node(def);
1182 }
1183 ResolvedValue::Dynamic { reason: "FindInMap with dynamic keys".into() }
1184 }
1185 }
1186 }
1187
1188 fn is_definition_substitution(&self, var: &str) -> bool {
1191 self.current_resource
1192 .as_ref()
1193 .and_then(|rid| self.def_subs_resources.get(rid))
1194 .is_some_and(|keys| keys.contains(var))
1195 }
1196
1197 fn detect_unsubstituted_variables(&mut self, s: &str) {
1198 if !s.contains("${") {
1199 return;
1200 }
1201 let Some(ref rid) = self.current_resource else {
1202 return;
1203 };
1204 let path = &self.current_path;
1206 if path.contains("TemplateBody") {
1207 return;
1208 }
1209
1210 let mut i = 0;
1211 let bytes = s.as_bytes();
1212 while i < bytes.len() {
1213 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1214 if i + 2 < bytes.len() && bytes[i + 2] == b'!' {
1215 i += 3;
1216 continue;
1217 }
1218 let start = i + 2;
1219 if let Some(end) = s[start..].find('}') {
1220 let var = s[start..start + end].trim();
1221 if var.starts_with("stageVariables.") {
1222 i = start + end + 1;
1223 continue;
1224 }
1225 let is_sub_style_variable = !var.is_empty()
1233 && var.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | ':' | '.'));
1234 let names_known_target = self.resource_ids.contains(var)
1235 || self.parameters.contains_key(var)
1236 || PSEUDO_PARAMETERS.contains(&var)
1237 || (var.contains('.')
1238 && var.split('.').next().map(|prefix| self.resource_ids.contains(prefix)).unwrap_or(false));
1239 let in_definition = path.contains("DefinitionString") || path.contains("Definition");
1244 if in_definition && self.is_definition_substitution(var) {
1245 i = start + end + 1;
1246 continue;
1247 }
1248 if is_sub_style_variable && (names_known_target || in_definition) {
1249 self.unsubstituted_variables
1250 .entry(rid.clone())
1251 .or_default()
1252 .push((path.clone(), format!("${{{}}}", var)));
1253 }
1254 i = start + end + 1;
1255 } else {
1256 break;
1257 }
1258 } else {
1259 i += 1;
1260 }
1261 }
1262 }
1263
1264 fn detect_raw_pseudo_param(&mut self, s: &str) {
1265 if !s.starts_with(PSEUDO_PREFIX) {
1266 return;
1267 }
1268 let Some(ref rid) = self.current_resource else {
1269 return;
1270 };
1271 if PSEUDO_PARAMETERS.contains(&s) {
1272 self.raw_pseudo_params.entry(rid.clone()).or_default().push((self.current_path.clone(), s.to_string()));
1273 }
1274 }
1275
1276 fn detect_secretsmanager_ref(&mut self, s: &str) {
1277 if !s.contains("{{resolve:secretsmanager:") {
1278 return;
1279 }
1280 let Some(ref rid) = self.current_resource else {
1281 return;
1282 };
1283 if !self.current_path.starts_with("Properties.") && self.current_path != "Properties" {
1289 return;
1290 }
1291 self.secretsmanager_ref_paths.entry(rid.clone()).or_default().push(self.current_path.clone());
1292 }
1293
1294 fn is_simple_join(&self, values_ref: NodeRef) -> bool {
1296 let items = match self.arena.as_list(values_ref) {
1297 Some(items) => items,
1298 None => return false,
1299 };
1300 items.iter().all(|item_ref| {
1301 match &self.arena.node(*item_ref) {
1302 Node::String(_) | Node::Int(_) | Node::Float(_) | Node::Bool(_) => true,
1303 Node::Intrinsic(intrinsic) => matches!(
1304 intrinsic,
1305 IntrinsicFn::Ref(_) | IntrinsicFn::GetAtt(_, _) | IntrinsicFn::Sub(_, None) ),
1307 _ => false,
1308 }
1309 })
1310 }
1311
1312 fn resolve_sub(
1313 &mut self,
1314 template: &str,
1315 subs: &Option<Vec<(String, NodeRef)>>,
1316 span: &SourceSpan,
1317 ) -> ResolvedValue {
1318 let mut vars: Vec<String> = Vec::new();
1319 let mut i = 0;
1320 let bytes = template.as_bytes();
1321 while i < bytes.len() {
1322 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1323 let start = i + 2;
1324 if let Some(end) = template[start..].find('}') {
1325 let variable = template[start..start + end].trim();
1326 if !variable.starts_with('!') {
1327 vars.push(variable.to_string());
1328 }
1329 i = start + end + 1;
1330 } else {
1331 i += 1;
1332 }
1333 } else {
1334 i += 1;
1335 }
1336 }
1337
1338 let mut sub_map: HashMap<String, ResolvedValue> = HashMap::new();
1339 let invalid_refs_before = self.invalid_ref_count();
1340 if let Some(explicit_subs) = subs {
1341 for (k, v) in explicit_subs {
1342 sub_map.insert(k.clone(), self.resolve_node(*v));
1343 }
1344 if let Some(ref rid) = self.current_resource {
1345 for (k, _) in explicit_subs {
1346 if !vars.iter().any(|v| v == k) {
1347 self.unused_sub_keys
1348 .entry(rid.clone())
1349 .or_default()
1350 .push((self.current_path.clone(), k.clone()));
1351 }
1352 }
1353 }
1354 }
1355 let sub_map_is_valid = self.invalid_ref_count() == invalid_refs_before;
1356
1357 for var in &vars {
1358 if sub_map.contains_key(var) {
1359 continue;
1360 }
1361 if let Some(dot_pos) = var.find('.') {
1363 let resource = &var[..dot_pos];
1364 let attr = &var[dot_pos + 1..];
1365 if self.resource_ids.contains(resource) {
1366 if self.is_output_context() {
1367 let path = format!("{}.Fn::Sub", self.current_path);
1368 self.record_edge_at_path(resource, RefKind::GetAtt { attr: attr.to_string() }, span, path);
1369 } else {
1370 self.record_edge(resource, RefKind::GetAtt { attr: attr.to_string() }, span);
1371 }
1372 sub_map.insert(
1373 var.clone(),
1374 ResolvedValue::Reference {
1375 target: resource.to_string(),
1376 kind: RefKind::GetAtt { attr: attr.to_string() },
1377 },
1378 );
1379 continue;
1380 }
1381 }
1382 let resolved = match self.lookup_ref(var, span) {
1390 Some(value) => value,
1391 None => {
1392 if sub_map_is_valid {
1393 let path = if self.is_output_context() {
1394 format!("{}.Fn::Sub", self.current_path)
1395 } else {
1396 self.current_path.clone()
1397 };
1398 self.record_edge_at_path(var, RefKind::Sub { var: var.clone() }, span, path);
1399 }
1400 ResolvedValue::Dynamic { reason: format!("unknown sub variable: {}", var) }
1401 }
1402 };
1403 sub_map.insert(var.clone(), resolved);
1404 }
1405
1406 if vars.len() == 1 && subs.is_none() {
1407 let var = &vars[0];
1408 let expected = format!("${{{}}}", var);
1409 if template == expected
1410 && let Some(ref rid) = self.current_resource
1411 {
1412 self.simple_subs.entry(rid.clone()).or_default().push((self.current_path.clone(), var.clone()));
1413 }
1414 }
1415
1416 if vars.is_empty()
1417 && subs.is_none()
1418 && !template.contains("${!")
1419 && let Some(ref rid) = self.current_resource
1420 {
1421 self.redundant_subs.entry(rid.clone()).or_default().push(format!("{}.Fn::Sub", self.current_path));
1422 }
1423
1424 if template.contains("arn:aws:")
1425 && let Some(ref rid) = self.current_resource
1426 {
1427 self.hardcoded_partition_arns
1430 .entry(rid.clone())
1431 .or_default()
1432 .push(format!("{}.Fn::Sub", self.current_path));
1433 }
1434
1435 let all_concrete = sub_map.values().all(|v| matches!(v, ResolvedValue::Concrete { value: _ }));
1436 if all_concrete {
1437 let mut result = template.to_string();
1438 for (var, val) in &sub_map {
1439 if let ResolvedValue::Concrete { value: v } = val {
1440 let replacement = v.as_str().unwrap_or(&v.to_string()).to_string();
1441 result = result.replace(&format!("${{{}}}", var), &replacement);
1442 }
1443 }
1444 return ResolvedValue::Concrete {
1445 value: serde_json::Value::String(crate::transform_expansion::unescape_sub_literals(&result)).into(),
1446 };
1447 }
1448
1449 let has_enum = sub_map.values().any(|v| matches!(v, ResolvedValue::Enum { variants: _ }));
1450 if has_enum {
1451 let mut enum_vars: Vec<(String, Vec<String>)> = Vec::new();
1452 for (var, val) in &sub_map {
1453 if let ResolvedValue::Enum { variants } = val {
1454 let concrete: Vec<String> = variants
1455 .iter()
1456 .filter_map(|v| {
1457 if let ResolvedValue::Concrete { value: cv } = v {
1458 Some(cv.as_str().unwrap_or("").to_string())
1459 } else {
1460 None
1461 }
1462 })
1463 .collect();
1464 if !concrete.is_empty() {
1465 enum_vars.push((var.clone(), concrete));
1466 }
1467 }
1468 }
1469 if !enum_vars.is_empty() {
1470 let mut combos: Vec<Vec<(String, String)>> = vec![vec![]];
1471 for (var, vals) in &enum_vars {
1472 let mut new_combos = Vec::new();
1473 for combo in &combos {
1474 for val in vals {
1475 let mut c = combo.clone();
1476 c.push((var.clone(), val.clone()));
1477 new_combos.push(c);
1478 }
1479 }
1480 combos = new_combos;
1481 }
1482 let results: Vec<ResolvedValue> = combos
1483 .into_iter()
1484 .map(|combo| {
1485 let mut result = template.to_string();
1486 for (var, val) in &combo {
1487 result = result.replace(&format!("${{{}}}", var), val);
1488 }
1489 for (var, val) in &sub_map {
1490 if let ResolvedValue::Concrete { value: cv } = val {
1491 let replacement = cv.as_str().unwrap_or(&cv.to_string()).to_string();
1492 result = result.replace(&format!("${{{}}}", var), &replacement);
1493 }
1494 }
1495 if crate::transform_expansion::contains_sub_variable(&result) {
1497 ResolvedValue::Dynamic { reason: format!("{}{}", SUB_PARTIAL_PREFIX, result) }
1498 } else {
1499 ResolvedValue::Concrete {
1500 value: serde_json::Value::String(crate::transform_expansion::unescape_sub_literals(
1501 &result,
1502 ))
1503 .into(),
1504 }
1505 }
1506 })
1507 .collect();
1508 if !results.is_empty() {
1509 return ResolvedValue::Enum { variants: results };
1510 }
1511 }
1512 }
1513
1514 let mut partial = template.to_string();
1515 for (var, val) in &sub_map {
1516 if let ResolvedValue::Concrete { value: v } = val {
1517 let replacement = v.as_str().unwrap_or(&v.to_string()).to_string();
1518 partial = partial.replace(&format!("${{{}}}", var), &replacement);
1519 }
1520 }
1521 for val in sub_map.values() {
1523 self.collect_extra_condition_refs(val);
1524 }
1525 ResolvedValue::Dynamic { reason: format!("{}{}", SUB_PARTIAL_PREFIX, partial) }
1526 }
1527
1528 fn collect_extra_condition_refs(&mut self, val: &ResolvedValue) {
1529 let key = match &self.current_resource {
1530 Some(r) => r.clone(),
1531 None => return,
1532 };
1533 let mut conds = Vec::new();
1534 crate::resolved_value::collect_condition_refs_from_resolved(val, &mut conds);
1535 if !conds.is_empty() {
1536 self.extra_condition_refs.entry(key).or_default().append(&mut conds);
1537 }
1538 }
1539
1540 fn invalid_ref_count(&self) -> usize {
1541 self.invalid_refs.values().map(Vec::len).sum()
1542 }
1543
1544 fn record_edge(&mut self, target: &str, kind: RefKind, span: &SourceSpan) {
1545 self.record_edge_at_path(target, kind, span, self.current_path.clone());
1546 }
1547
1548 fn record_edge_at_path(&mut self, target: &str, kind: RefKind, span: &SourceSpan, path: String) {
1553 if let Some(ref resource) = self.current_resource.clone() {
1554 let condition_context = if self.condition_stack.is_empty() {
1555 None
1556 } else {
1557 let ctx: Vec<String> =
1558 self.condition_stack.iter().map(|(c, b)| if *b { c.clone() } else { format!("!{}", c) }).collect();
1559 Some(ctx.join(","))
1560 };
1561 self.edges.push(ResolverEdge {
1562 source_resource: resource.clone(),
1563 source_path: path,
1564 target: target.to_string(),
1565 kind,
1566 span: *span,
1567 condition_context,
1568 });
1569 }
1570 }
1571
1572 fn is_output_context(&self) -> bool {
1574 self.current_resource.as_ref().is_some_and(|r| r.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX))
1575 }
1576
1577 pub fn set_current_resource(&mut self, resource_id: &str) {
1578 self.current_resource = Some(resource_id.to_string());
1579 }
1580
1581 pub fn set_current_path(&mut self, path: &str) {
1582 self.current_path = path.to_string();
1583 }
1584}
1585
1586fn pseudo_parameter_ref_to_json(name: &str, value: String) -> serde_json::Value {
1587 if name == PSEUDO_NOTIFICATION_ARNS {
1588 return serde_json::Value::Array(vec![serde_json::Value::String(value)]);
1589 }
1590 serde_json::Value::String(value)
1591}
1592
1593fn param_string_to_json(value: &str, param_type: &str) -> serde_json::Value {
1594 if param_type == PARAM_TYPE_COMMA_DELIMITED_LIST || param_type.starts_with("List<") {
1595 return serde_json::Value::Array(
1596 value.split(',').map(|v| serde_json::Value::String(v.trim().to_string())).collect(),
1597 );
1598 }
1599 match param_type {
1600 PARAM_TYPE_NUMBER => value
1604 .parse::<i64>()
1605 .map(|i| serde_json::Value::Number(i.into()))
1606 .or_else(|_| {
1607 value.parse::<f64>().map(|n| {
1608 serde_json::Number::from_f64(n)
1609 .map(serde_json::Value::Number)
1610 .unwrap_or(serde_json::Value::String(value.to_string()))
1611 })
1612 })
1613 .unwrap_or(serde_json::Value::String(value.to_string())),
1614 _ => serde_json::Value::String(value.to_string()),
1615 }
1616}
1617
1618fn calculate_cidr_blocks(ip_block: &str, count: u64, cidr_bits: u64) -> Option<Vec<String>> {
1619 let (ip_str, prefix_str) = ip_block.split_once('/')?;
1620 let prefix_len: u32 = prefix_str.parse().ok()?;
1621 let new_prefix = 32 - cidr_bits as u32;
1622 if new_prefix <= prefix_len {
1623 return None;
1624 }
1625 let ip: u32 = ip_str.split('.').enumerate().try_fold(0u32, |acc, (i, octet)| {
1626 let o: u32 = octet.parse().ok()?;
1627 Some(acc | (o << (24 - i * 8)))
1628 })?;
1629 let subnet_size = 1u32 << cidr_bits;
1630 let base = ip & !((1u32 << (32 - prefix_len)) - 1);
1631 let mut results = Vec::new();
1632 for i in 0..count.min(256) {
1633 let subnet_ip = base + (i as u32) * subnet_size;
1634 let a = (subnet_ip >> 24) & 0xFF;
1635 let b = (subnet_ip >> 16) & 0xFF;
1636 let c = (subnet_ip >> 8) & 0xFF;
1637 let d = subnet_ip & 0xFF;
1638 results.push(format!("{}.{}.{}.{}/{}", a, b, c, d, new_prefix));
1639 }
1640 Some(results)
1641}
1642
1643fn opaque_if_dynamic_reference(value: ResolvedValue) -> ResolvedValue {
1647 if let ResolvedValue::Concrete { value: ref json } = value
1648 && let Some(s) = json.as_str()
1649 && s.contains("{{resolve:")
1650 {
1651 return ResolvedValue::TypedDynamic {
1652 reason: format!("dynamic reference: {}", s),
1653 param_type: PARAM_TYPE_STRING.into(),
1654 };
1655 }
1656 value
1657}
1658
1659fn join_resolved(delim: &str, values: &ResolvedValue) -> ResolvedValue {
1660 match values {
1661 ResolvedValue::Concrete { value: v } => {
1662 if let Some(arr) = v.as_array() {
1663 let parts: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
1664 if parts.len() == arr.len() {
1665 return ResolvedValue::Concrete { value: serde_json::Value::String(parts.join(delim)).into() };
1666 }
1667 }
1668 ResolvedValue::Dynamic { reason: "Join with non-string elements".into() }
1669 }
1670 ResolvedValue::Enum { variants } => {
1671 let results: Vec<ResolvedValue> = variants.iter().map(|v| join_resolved(delim, v)).collect();
1672 ResolvedValue::Enum { variants: results }
1673 }
1674 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1675 condition: cond.clone(),
1676 if_true: Box::new(join_resolved(delim, t)),
1677 if_false: Box::new(join_resolved(delim, f)),
1678 },
1679 _ => ResolvedValue::Dynamic { reason: "Join with unresolvable arguments".into() },
1680 }
1681}
1682
1683pub(crate) fn join_with_enum_list(delim: &str, items: &[ResolvedValue]) -> (ResolvedValue, bool) {
1684 let mut combos: Vec<Vec<String>> = vec![vec![]];
1688 let mut curtailed = false;
1689 for item in items {
1690 match item {
1691 ResolvedValue::Concrete { value: v } => {
1692 let s = v.as_str().unwrap_or("").to_string();
1693 for combo in &mut combos {
1694 combo.push(s.clone());
1695 }
1696 }
1697 ResolvedValue::Enum { variants } => {
1698 let strs: Vec<String> = variants
1699 .iter()
1700 .filter_map(|v| {
1701 if let ResolvedValue::Concrete { value: cv } = v {
1702 cv.as_str().map(|s| s.to_string())
1703 } else {
1704 None
1705 }
1706 })
1707 .collect();
1708 if strs.is_empty() {
1709 return (ResolvedValue::Dynamic { reason: "Join with non-concrete enum".into() }, false);
1710 }
1711 let mut new_combos = Vec::new();
1712 for combo in &combos {
1713 for s in &strs {
1714 if new_combos.len() == MAX_ENUM_EXPANSION {
1715 curtailed = true;
1716 break;
1717 }
1718 let mut c = combo.clone();
1719 c.push(s.clone());
1720 new_combos.push(c);
1721 }
1722 if curtailed {
1723 break;
1724 }
1725 }
1726 combos = new_combos;
1727 }
1728 _ => {
1729 return (ResolvedValue::Dynamic { reason: "Join with unresolvable list element".into() }, false);
1730 }
1731 }
1732 }
1733 let results: Vec<ResolvedValue> = combos
1734 .into_iter()
1735 .map(|parts| ResolvedValue::Concrete { value: serde_json::Value::String(parts.join(delim)).into() })
1736 .collect();
1737 let result = if results.len() == 1 {
1738 results.into_iter().next().unwrap()
1739 } else {
1740 ResolvedValue::Enum { variants: results }
1741 };
1742 (result, curtailed)
1743}
1744
1745fn select_resolved(idx: &serde_json::Value, list: &ResolvedValue) -> ResolvedValue {
1746 match list {
1747 ResolvedValue::Concrete { value: l } => {
1748 if let (Some(arr), Some(i)) = (l.as_array(), idx.as_u64())
1749 && (i as usize) < arr.len()
1750 {
1751 return ResolvedValue::Concrete { value: arr[i as usize].clone().into() };
1752 }
1753 ResolvedValue::Dynamic { reason: "Select index out of bounds".into() }
1754 }
1755 ResolvedValue::List { items } => {
1756 if let Some(i) = idx.as_u64()
1757 && (i as usize) < items.len()
1758 {
1759 return items[i as usize].clone();
1760 }
1761 ResolvedValue::Dynamic { reason: "Select index out of bounds".into() }
1762 }
1763 ResolvedValue::Enum { variants } => {
1764 let results: Vec<ResolvedValue> = variants.iter().map(|v| select_resolved(idx, v)).collect();
1765 ResolvedValue::Enum { variants: results }
1766 }
1767 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1768 condition: cond.clone(),
1769 if_true: Box::new(select_resolved(idx, t)),
1770 if_false: Box::new(select_resolved(idx, f)),
1771 },
1772 _ => ResolvedValue::Dynamic { reason: "Select with unresolvable arguments".into() },
1773 }
1774}
1775
1776fn split_resolved(delim: &str, src: &ResolvedValue) -> ResolvedValue {
1777 match src {
1778 ResolvedValue::Concrete { value: s } => {
1779 if let Some(ss) = s.as_str() {
1780 let parts: Vec<serde_json::Value> =
1781 ss.split(delim).map(|p| serde_json::Value::String(p.to_string())).collect();
1782 return ResolvedValue::Concrete { value: serde_json::Value::Array(parts).into() };
1783 }
1784 ResolvedValue::Dynamic { reason: "Split with non-string argument".into() }
1785 }
1786 ResolvedValue::Enum { variants } => {
1787 let results: Vec<ResolvedValue> = variants.iter().map(|v| split_resolved(delim, v)).collect();
1788 ResolvedValue::Enum { variants: results }
1789 }
1790 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1791 condition: cond.clone(),
1792 if_true: Box::new(split_resolved(delim, t)),
1793 if_false: Box::new(split_resolved(delim, f)),
1794 },
1795 _ => ResolvedValue::Dynamic { reason: "Split with unresolvable argument".into() },
1796 }
1797}
1798
1799fn base64_resolved(val: &ResolvedValue) -> ResolvedValue {
1800 match val {
1801 ResolvedValue::Concrete { value: v } => {
1802 if let Some(s) = v.as_str() {
1803 let encoded = base64::engine::general_purpose::STANDARD.encode(s);
1804 return ResolvedValue::Concrete { value: serde_json::Value::String(encoded).into() };
1805 }
1806 ResolvedValue::Dynamic { reason: "Base64 with non-string argument".into() }
1807 }
1808 ResolvedValue::Enum { variants } => {
1809 let results: Vec<ResolvedValue> = variants.iter().map(base64_resolved).collect();
1810 ResolvedValue::Enum { variants: results }
1811 }
1812 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1813 condition: cond.clone(),
1814 if_true: Box::new(base64_resolved(t)),
1815 if_false: Box::new(base64_resolved(f)),
1816 },
1817 _ => ResolvedValue::Dynamic { reason: "Base64 with unresolvable argument".into() },
1818 }
1819}
1820
1821fn length_resolved(val: &ResolvedValue) -> ResolvedValue {
1822 match val {
1823 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Array(arr)) } => {
1824 ResolvedValue::Concrete { value: serde_json::json!(arr.len()).into() }
1825 }
1826 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Object(map)) } => {
1827 ResolvedValue::Concrete { value: serde_json::json!(map.len()).into() }
1828 }
1829 ResolvedValue::Enum { variants } => {
1830 let results: Vec<ResolvedValue> = variants.iter().map(length_resolved).collect();
1831 ResolvedValue::Enum { variants: results }
1832 }
1833 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1834 condition: cond.clone(),
1835 if_true: Box::new(length_resolved(t)),
1836 if_false: Box::new(length_resolved(f)),
1837 },
1838 _ => ResolvedValue::Dynamic { reason: "Length with unresolvable argument".into() },
1839 }
1840}
1841
1842fn resolve_getazs_value(
1843 region_val: &ResolvedValue,
1844 pseudo_overrides: &crate::model::PseudoParameterOverrides,
1845) -> ResolvedValue {
1846 match region_val {
1847 ResolvedValue::Concrete { value: v } => {
1848 let region_str = v.as_str().unwrap_or("");
1849 let effective_region = if region_str.is_empty() { pseudo_overrides.region() } else { region_str };
1850 match availability_zones_for_region(effective_region) {
1851 Some(azs) => ResolvedValue::Concrete {
1852 value: serde_json::Value::Array(
1853 azs.iter().map(|z| serde_json::Value::String((*z).to_string())).collect(),
1854 )
1855 .into(),
1856 },
1857 None => ResolvedValue::Dynamic { reason: format!("GetAZs unknown region {}", effective_region) },
1858 }
1859 }
1860 ResolvedValue::Enum { variants } => {
1861 let results: Vec<ResolvedValue> =
1862 variants.iter().map(|v| resolve_getazs_value(v, pseudo_overrides)).collect();
1863 ResolvedValue::Enum { variants: results }
1864 }
1865 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1866 condition: cond.clone(),
1867 if_true: Box::new(resolve_getazs_value(t, pseudo_overrides)),
1868 if_false: Box::new(resolve_getazs_value(f, pseudo_overrides)),
1869 },
1870 _ => ResolvedValue::Dynamic { reason: "GetAZs runtime value".into() },
1871 }
1872}
1873
1874fn resolve_cidr_value(ip_val: &ResolvedValue, count_val: &ResolvedValue, bits_val: &ResolvedValue) -> ResolvedValue {
1875 match (ip_val, count_val, bits_val) {
1876 (
1877 ResolvedValue::Concrete { value: ip },
1878 ResolvedValue::Concrete { value: cnt },
1879 ResolvedValue::Concrete { value: bits },
1880 ) => {
1881 let ip_str = ip.as_str().unwrap_or("");
1882 let count = cnt.as_u64().or_else(|| cnt.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
1883 let cidr_bits = bits.as_u64().or_else(|| bits.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
1884 match calculate_cidr_blocks(ip_str, count, cidr_bits) {
1885 Some(blocks) => ResolvedValue::Concrete {
1886 value: serde_json::Value::Array(blocks.into_iter().map(serde_json::Value::String).collect()).into(),
1887 },
1888 None => ResolvedValue::Dynamic { reason: "Cidr calculation failed".into() },
1889 }
1890 }
1891 _ => ResolvedValue::Dynamic { reason: "Cidr runtime value".into() },
1892 }
1893}
1894
1895fn to_json_string_resolved(val: &ResolvedValue) -> ResolvedValue {
1896 match val {
1897 ResolvedValue::Concrete { value: v } => {
1898 ResolvedValue::Concrete { value: serde_json::Value::String(v.to_string()).into() }
1899 }
1900 ResolvedValue::Enum { variants } => {
1901 let results: Vec<ResolvedValue> = variants.iter().map(to_json_string_resolved).collect();
1902 ResolvedValue::Enum { variants: results }
1903 }
1904 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1905 condition: cond.clone(),
1906 if_true: Box::new(to_json_string_resolved(t)),
1907 if_false: Box::new(to_json_string_resolved(f)),
1908 },
1909 _ => ResolvedValue::Dynamic { reason: "ToJsonString with unresolvable argument".into() },
1910 }
1911}
1912
1913fn node_matches_param_type(node: &Node, expected: &str) -> bool {
1919 match (expected, node) {
1920 ("integer", Node::Int(_)) => true,
1921 ("number", Node::Int(_) | Node::Float(_)) => true,
1922 ("boolean", Node::Bool(_)) => true,
1923 (_, Node::String(s)) => type_compatible(&serde_json::Value::String(s.clone()), expected),
1924 _ => false,
1925 }
1926}
1927
1928pub fn extract_parameters(ir: &TemplateIR) -> (HashMap<String, ParameterInfo>, Vec<ParseDefect>) {
1929 let mut params = HashMap::new();
1930 let mut diags = Vec::new();
1931 if ir.parameters == NULL_REF {
1932 return (params, diags);
1933 }
1934 let Some(entries) = ir.arena.as_map(ir.parameters) else {
1935 return (params, diags);
1936 };
1937
1938 let e2001 = |msg: String, param_name: &str, prop: Option<&str>, span: SourceSpan| -> ParseDefect {
1939 let path = match prop {
1942 Some(p) => format!("Parameters/{}/{}", param_name, p),
1943 None => format!("Parameters/{}", param_name),
1944 };
1945 let mut d = crate::make_parse_defect("E2001", msg, span);
1946 d.property_path = Some(path);
1947 d
1948 };
1949
1950 const VALID_KEYS: &[&str] = &[
1951 KEY_ALLOWED_PATTERN,
1952 KEY_ALLOWED_VALUES,
1953 KEY_CONSTRAINT_DESCRIPTION,
1954 KEY_DEFAULT,
1955 SECTION_DESCRIPTION,
1956 KEY_MAX_LENGTH,
1957 KEY_MIN_LENGTH,
1958 KEY_MAX_VALUE,
1959 KEY_MIN_VALUE,
1960 KEY_NO_ECHO,
1961 KEY_TYPE,
1962 ];
1963
1964 for (name, node_ref) in entries {
1965 let param_span = ir.arena.span(*node_ref);
1966
1967 let Some(param_map) = ir.arena.as_map(*node_ref) else {
1969 if matches!(ir.arena.node(*node_ref), Node::Null) {
1970 diags.push(e2001(
1971 format!("Parameter '{}': None is not of type 'object'", name),
1972 name,
1973 None,
1974 param_span,
1975 ));
1976 }
1977 continue;
1978 };
1979
1980 let has_type = param_map.iter().any(|(k, _)| k == KEY_TYPE);
1981 if !has_type {
1982 diags.push(e2001(format!("Parameter '{}': 'Type' is a required property", name), name, None, param_span));
1983 }
1984
1985 for (key, val_ref) in param_map {
1986 let val_span = ir.arena.span(*val_ref);
1987 let node = ir.arena.node(*val_ref);
1988
1989 if !VALID_KEYS.contains(&key.as_str()) {
1990 diags.push(e2001(
1991 format!("Parameter '{}': '{}' is not one of {}", name, key, render_str_list(VALID_KEYS)),
1992 name,
1993 Some(key),
1994 val_span,
1995 ));
1996 continue;
1997 }
1998
1999 match key.as_str() {
2000 KEY_TYPE => {
2001 if matches!(node, Node::Null) {
2002 diags.push(e2001(
2003 format!("Parameter '{}': Type must not be null", name),
2004 name,
2005 Some("Type"),
2006 val_span,
2007 ));
2008 } else if !matches!(node, Node::String(_)) {
2009 diags.push(e2001(
2010 format!("Parameter '{}': Type must be a string", name),
2011 name,
2012 Some("Type"),
2013 val_span,
2014 ));
2015 }
2016 }
2017 KEY_ALLOWED_VALUES => {
2018 if !matches!(node, Node::List(_)) {
2019 diags.push(e2001(
2020 format!("Parameter '{}': AllowedValues must be an array", name),
2021 name,
2022 Some("AllowedValues"),
2023 val_span,
2024 ));
2025 }
2026 }
2027 KEY_NO_ECHO => {
2028 if matches!(node, Node::Null) {
2029 diags.push(e2001(
2030 format!("Parameter '{}': NoEcho must not be null", name),
2031 name,
2032 Some("NoEcho"),
2033 val_span,
2034 ));
2035 } else if !node_matches_param_type(node, "boolean") {
2036 diags.push(e2001(
2037 format!("Parameter '{}': NoEcho must be a boolean", name),
2038 name,
2039 Some("NoEcho"),
2040 val_span,
2041 ));
2042 }
2043 }
2044 KEY_MIN_VALUE | KEY_MAX_VALUE => {
2045 if matches!(node, Node::Null) {
2046 diags.push(e2001(
2047 format!("Parameter '{}': {} must not be null", name, key),
2048 name,
2049 Some(key),
2050 val_span,
2051 ));
2052 } else if !node_matches_param_type(node, "number") {
2053 diags.push(e2001(
2054 format!("Parameter '{}': {} must be a number", name, key),
2055 name,
2056 Some(key),
2057 val_span,
2058 ));
2059 }
2060 }
2061 KEY_MIN_LENGTH | KEY_MAX_LENGTH => {
2062 if matches!(node, Node::Null) {
2063 diags.push(e2001(
2064 format!("Parameter '{}': {} must not be null", name, key),
2065 name,
2066 Some(key),
2067 val_span,
2068 ));
2069 } else if !node_matches_param_type(node, "integer") {
2070 diags.push(e2001(
2071 format!("Parameter '{}': {} must be an integer", name, key),
2072 name,
2073 Some(key),
2074 val_span,
2075 ));
2076 }
2077 }
2078 KEY_DEFAULT | SECTION_DESCRIPTION | KEY_ALLOWED_PATTERN | KEY_CONSTRAINT_DESCRIPTION => {
2079 if matches!(node, Node::Null) {
2080 diags.push(e2001(
2081 format!("Parameter '{}': {} must not be null", name, key),
2082 name,
2083 Some(key),
2084 val_span,
2085 ));
2086 } else if matches!(node, Node::Map(_) | Node::Intrinsic(_)) {
2087 diags.push(e2001(
2088 format!("Parameter '{}': {} must be a string", name, key),
2089 name,
2090 Some(key),
2091 val_span,
2092 ));
2093 }
2094 }
2095 _ => {}
2096 }
2097 }
2098
2099 let param_type = param_map
2100 .iter()
2101 .find(|(k, _)| k == KEY_TYPE)
2102 .and_then(|(_, v)| ir.arena.as_str(*v))
2103 .unwrap_or(PARAM_TYPE_STRING)
2104 .to_string();
2105
2106 let default = param_map.iter().find(|(k, _)| k == KEY_DEFAULT).and_then(|(_, v)| match ir.arena.node(*v) {
2107 Node::String(s) => Some(s.clone()),
2108 Node::Int(i) => Some(i.to_string()),
2109 Node::Float(f) => Some(f.to_string()),
2110 Node::Bool(b) => Some(b.to_string()),
2111 _ => None,
2112 });
2113
2114 let allowed_values =
2115 param_map.iter().find(|(k, _)| k == KEY_ALLOWED_VALUES).and_then(|(_, v)| ir.arena.as_list(*v)).map(
2116 |items| {
2117 items
2118 .iter()
2119 .filter_map(|r| match ir.arena.node(*r) {
2120 Node::String(s) => Some(s.clone()),
2121 Node::Int(i) => Some(i.to_string()),
2122 Node::Float(f) => Some(f.to_string()),
2123 Node::Bool(b) => Some(b.to_string()),
2124 _ => None,
2125 })
2126 .collect()
2127 },
2128 );
2129
2130 let description = param_map
2131 .iter()
2132 .find(|(k, _)| k == SECTION_DESCRIPTION)
2133 .and_then(|(_, v)| ir.arena.as_str(*v))
2134 .map(|s| s.to_string());
2135
2136 let no_echo = param_map
2137 .iter()
2138 .find(|(k, _)| k == KEY_NO_ECHO)
2139 .and_then(|(_, v)| match ir.arena.node(*v) {
2140 Node::Bool(b) => Some(*b),
2141 Node::String(s) => Some(s.eq_ignore_ascii_case("true")),
2142 _ => None,
2143 })
2144 .unwrap_or(false);
2145
2146 let allowed_pattern = param_map
2147 .iter()
2148 .find(|(k, _)| k == KEY_ALLOWED_PATTERN)
2149 .and_then(|(_, v)| ir.arena.as_str(*v))
2150 .map(|s| s.to_string());
2151
2152 let min_length =
2153 param_map.iter().find(|(k, _)| k == KEY_MIN_LENGTH).and_then(|(_, v)| match ir.arena.node(*v) {
2154 Node::Int(i) => Some(*i as u64),
2155 Node::String(s) => s.parse().ok(),
2156 _ => None,
2157 });
2158
2159 let max_length =
2160 param_map.iter().find(|(k, _)| k == KEY_MAX_LENGTH).and_then(|(_, v)| match ir.arena.node(*v) {
2161 Node::Int(i) => Some(*i as u64),
2162 Node::String(s) => s.parse().ok(),
2163 _ => None,
2164 });
2165
2166 let min_value = param_map.iter().find(|(k, _)| k == KEY_MIN_VALUE).and_then(|(_, v)| match ir.arena.node(*v) {
2167 Node::Int(i) => Some(*i),
2168 Node::String(s) => s.parse().ok(),
2169 _ => None,
2170 });
2171
2172 let max_value = param_map.iter().find(|(k, _)| k == KEY_MAX_VALUE).and_then(|(_, v)| match ir.arena.node(*v) {
2173 Node::Int(i) => Some(*i),
2174 Node::String(s) => s.parse().ok(),
2175 _ => None,
2176 });
2177
2178 let allowed_pattern_valid = allowed_pattern.as_deref().map(is_service_valid);
2179 let is_comma_delimited = param_type == PARAM_TYPE_COMMA_DELIMITED_LIST || param_type.starts_with("List<");
2180 let default_matches_allowed_pattern = match (&allowed_pattern, &default) {
2181 (Some(pattern), Some(value)) => default_matches_pattern(pattern, value, is_comma_delimited),
2182 _ => None,
2183 };
2184
2185 params.insert(
2186 name.clone(),
2187 ParameterInfo {
2188 param_type,
2189 default,
2190 allowed_values,
2191 allowed_pattern,
2192 min_length,
2193 max_length,
2194 min_value,
2195 max_value,
2196 description,
2197 no_echo,
2198 allowed_pattern_valid,
2199 default_matches_allowed_pattern,
2200 },
2201 );
2202 }
2203 let with_allowed = params.values().filter(|p| p.allowed_values.is_some()).count();
2204 let with_defaults = params.values().filter(|p| p.default.is_some()).count();
2205 debug!(
2206 "Extracted {} parameters ({} with AllowedValues, {} with Default)",
2207 params.len(),
2208 with_allowed,
2209 with_defaults
2210 );
2211 (params, diags)
2212}
2213
2214pub fn extract_mappings(ir: &TemplateIR) -> (MappingData, Vec<ParseDefect>) {
2215 let mut mappings = MappingData::new();
2216 let mut diagnostics = Vec::new();
2217 if ir.mappings == NULL_REF {
2218 return (mappings, diagnostics);
2219 }
2220 let Some(entries) = ir.arena.as_map(ir.mappings) else {
2221 return (mappings, diagnostics);
2222 };
2223 for (map_name, map_ref) in entries {
2224 let Some(level1) = ir.arena.as_map(*map_ref) else {
2225 diagnostics.push(crate::make_parse_defect_at(
2226 "F0017",
2227 format!("Mapping '{}' must be a map, not a scalar value", map_name),
2228 ir.arena.span(*map_ref),
2229 &format!("Mappings/{}", map_name),
2230 ));
2231 continue;
2232 };
2233 let mut l1_map = HashMap::new();
2234 for (k1, k1_ref) in level1 {
2235 let Some(level2) = ir.arena.as_map(*k1_ref) else {
2236 diagnostics.push(crate::make_parse_defect_at(
2237 "F0017",
2238 format!("Mapping '{}' second level key '{}' must be a map", map_name, k1),
2239 ir.arena.span(*k1_ref),
2240 &format!("Mappings/{}/{}", map_name, k1),
2241 ));
2242 continue;
2243 };
2244 let mut l2_map = HashMap::new();
2245 for (k2, k2_ref) in level2 {
2246 let val = node_to_json(&ir.arena, *k2_ref);
2247 l2_map.insert(k2.clone(), val);
2248 }
2249 l1_map.insert(k1.clone(), l2_map);
2250 }
2251 mappings.insert(map_name.clone(), l1_map);
2252 }
2253 let total_entries: usize = mappings.values().map(|l1| l1.values().map(|l2| l2.len()).sum::<usize>()).sum();
2254 debug!("Extracted {} mappings with {} total leaf entries", mappings.len(), total_entries);
2255 (mappings, diagnostics)
2256}
2257
2258pub fn node_to_json(arena: &Arena, node_ref: NodeRef) -> serde_json::Value {
2259 if node_ref == NULL_REF {
2260 return serde_json::Value::Null;
2261 }
2262 match arena.node(node_ref) {
2263 Node::Null => serde_json::Value::Null,
2264 Node::Bool(b) => serde_json::Value::Bool(*b),
2265 Node::Int(i) => serde_json::json!(*i),
2266 Node::Float(f) => serde_json::json!(*f),
2267 Node::String(s) => serde_json::Value::String(s.clone()),
2268 Node::List(items) => serde_json::Value::Array(items.iter().map(|r| node_to_json(arena, *r)).collect()),
2269 Node::Map(entries) => {
2270 let mut map = serde_json::Map::new();
2271 for (k, v) in entries {
2272 map.insert(k.clone(), node_to_json(arena, *v));
2273 }
2274 serde_json::Value::Object(map)
2275 }
2276 Node::Intrinsic(intrinsic) => {
2277 serde_json::json!({MARKER_INTRINSIC: intrinsic_name(intrinsic)})
2278 }
2279 }
2280}
2281
2282fn intrinsic_name(intrinsic: &IntrinsicFn) -> &'static str {
2283 match intrinsic {
2284 IntrinsicFn::Ref(_) => TAG_REF,
2285 IntrinsicFn::GetAtt(_, _) => TAG_GET_ATT,
2286 IntrinsicFn::If(_, _, _) => TAG_IF,
2287 IntrinsicFn::IfExpr(_, _, _) => TAG_IF_EXPR,
2288 IntrinsicFn::FindInMap(_, _, _, _) => TAG_FIND_IN_MAP,
2289 IntrinsicFn::Sub(_, _) => TAG_SUB,
2290 IntrinsicFn::Join(_, _) => TAG_JOIN,
2291 IntrinsicFn::Select(_, _) => TAG_SELECT,
2292 IntrinsicFn::Split(_, _) => TAG_SPLIT,
2293 IntrinsicFn::Base64(_) => TAG_BASE64,
2294 IntrinsicFn::ImportValue(_) => TAG_IMPORT_VALUE,
2295 IntrinsicFn::GetStackOutput(_) => TAG_GET_STACK_OUTPUT,
2296 IntrinsicFn::Transform(_, _) => TAG_TRANSFORM,
2297 IntrinsicFn::GetAZs(_) => TAG_GET_AZS,
2298 IntrinsicFn::Cidr(_, _, _) => TAG_CIDR,
2299 IntrinsicFn::And(_) => TAG_AND,
2300 IntrinsicFn::Or(_) => TAG_OR,
2301 IntrinsicFn::Not(_) => TAG_NOT,
2302 IntrinsicFn::Equals(_, _) => TAG_EQUALS,
2303 IntrinsicFn::ToJsonString(_) => TAG_TO_JSON_STRING,
2304 IntrinsicFn::Length(_) => TAG_LENGTH,
2305 IntrinsicFn::ForEach(_, _, _, _) => TAG_FOR_EACH,
2306 IntrinsicFn::ValueOf(_, _) => TAG_VALUE_OF,
2307 IntrinsicFn::ValueOfAll(_, _) => TAG_VALUE_OF_ALL,
2308 IntrinsicFn::RefAll(_) => TAG_REF_ALL,
2309 IntrinsicFn::Contains(_, _) => TAG_CONTAINS,
2310 IntrinsicFn::EachMemberEquals(_, _) => TAG_EACH_MEMBER_EQUALS,
2311 IntrinsicFn::EachMemberIn(_, _) => TAG_EACH_MEMBER_IN,
2312 }
2313}
2314
2315#[cfg(test)]
2316mod tests {
2317 use super::*;
2318 use crate::parser;
2319
2320 fn concrete_property<'a>(
2321 model: &'a crate::model::SemanticModel,
2322 resource_id: &str,
2323 property_path: &str,
2324 ) -> &'a serde_json::Value {
2325 match model.resolve(resource_id, property_path) {
2326 Some(ResolvedValue::Concrete { value }) => value,
2327 other => panic!("Expected concrete value at {resource_id}.{property_path}, got {other:?}"),
2328 }
2329 }
2330
2331 #[test]
2332 fn param_number_whole_value_resolves_to_integer() {
2333 assert_eq!(param_string_to_json("30", "Number"), serde_json::json!(30));
2336 assert!(param_string_to_json("30", "Number").is_i64(), "whole number must be an integer");
2337 assert_eq!(param_string_to_json("1.5", "Number"), serde_json::json!(1.5));
2338 assert_eq!(param_string_to_json("not-a-number", "Number"), serde_json::json!("not-a-number"));
2339 }
2340
2341 #[test]
2342 fn resolve_ref_param_with_allowed_values() {
2343 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
2344 let ir = parser::parse(input.as_bytes()).unwrap();
2345 let (params, _) = extract_parameters(&ir);
2346 let resource_ids: HashSet<String> = ["R".to_string()].into_iter().collect();
2347 let (mappings, _) = extract_mappings(&ir);
2348 let no_param_overrides = HashMap::new();
2349 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2350 let mut resolver =
2351 Resolver::new(&ir.arena, ¶ms, &mappings, resource_ids, &no_param_overrides, &no_pseudo_overrides);
2352 let res_map = ir.arena.as_map(ir.resources).unwrap();
2353 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2354 let v_ref = ir.arena.map_get(props, "V").unwrap();
2355 let result = resolver.resolve_node(v_ref);
2356 match result {
2357 ResolvedValue::Enum { variants: vals } => assert_eq!(vals.len(), 2),
2358 other => panic!("Expected Enum, got {:?}", other),
2359 }
2360 }
2361
2362 #[test]
2363 fn resolve_ref_pseudo_params() {
2364 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
2365 let ir = parser::parse(input.as_bytes()).unwrap();
2366 let (params, _) = extract_parameters(&ir);
2367 let (mappings, _) = extract_mappings(&ir);
2368 let no_param_overrides = HashMap::new();
2369 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2370 let mut resolver = Resolver::new(
2371 &ir.arena,
2372 ¶ms,
2373 &mappings,
2374 ["R".to_string()].into_iter().collect(),
2375 &no_param_overrides,
2376 &no_pseudo_overrides,
2377 );
2378 let res_map = ir.arena.as_map(ir.resources).unwrap();
2379 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2380 let v_ref = ir.arena.map_get(props, "V").unwrap();
2381 let result = resolver.resolve_node(v_ref);
2382 match result {
2383 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::String(s)) } => {
2384 assert_eq!(s, DEFAULT_REGION);
2385 }
2386 other => panic!("Expected Concrete(\"us-east-1\"), got {:?}", other),
2387 }
2388 }
2389
2390 #[test]
2391 fn resolve_ref_notification_arns_as_list() {
2392 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::NotificationARNs"}}}}}"#;
2393 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).expect("template parses");
2394 assert_eq!(
2395 concrete_property(&model, "R", "Properties.V"),
2396 &serde_json::json!(["arn:aws:sns:us-east-1:123456789012:notification"]),
2397 "NotificationARNs must keep its CloudFormation list shape"
2398 );
2399 }
2400
2401 #[test]
2402 fn notification_arns_override_preserves_list_shape_through_intrinsics() {
2403 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{
2404 "Notifications":{"Ref":"AWS::NotificationARNs"},
2405 "First":{"Fn::Select":[0,{"Ref":"AWS::NotificationARNs"}]},
2406 "Joined":{"Fn::Join":["|",{"Ref":"AWS::NotificationARNs"}]}
2407 }}}}"#;
2408 let configured_arn = "arn:aws:sns:us-east-1:123456789012:configured";
2409 let config = crate::model::ParseConfig {
2410 parameters: HashMap::new(),
2411 pseudo_parameters: crate::model::PseudoParameterOverrides {
2412 notification_arns: Some(configured_arn.to_string()),
2413 ..Default::default()
2414 },
2415 };
2416 let model = crate::model::SemanticModel::parse(input.as_bytes(), config).expect("template parses").model;
2417
2418 assert_eq!(concrete_property(&model, "R", "Properties.Notifications"), &serde_json::json!([configured_arn]));
2419 assert_eq!(concrete_property(&model, "R", "Properties.First"), &serde_json::json!(configured_arn));
2420 assert_eq!(concrete_property(&model, "R", "Properties.Joined"), &serde_json::json!(configured_arn));
2421 }
2422
2423 #[test]
2424 fn resolve_if() {
2425 let input = r#"{"Conditions":{"C":{"Fn::Equals":["a","a"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::If":["C",100,20]}}}}}"#;
2426 let ir = parser::parse(input.as_bytes()).unwrap();
2427 let (params, _) = extract_parameters(&ir);
2428 let (mappings, _) = extract_mappings(&ir);
2429 let no_param_overrides = HashMap::new();
2430 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2431 let mut resolver = Resolver::new(
2432 &ir.arena,
2433 ¶ms,
2434 &mappings,
2435 ["R".to_string()].into_iter().collect(),
2436 &no_param_overrides,
2437 &no_pseudo_overrides,
2438 );
2439 let res_map = ir.arena.as_map(ir.resources).unwrap();
2440 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2441 let v_ref = ir.arena.map_get(props, "V").unwrap();
2442 let result = resolver.resolve_node(v_ref);
2443 assert!(matches!(result, ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ }));
2444 }
2445
2446 #[test]
2447 fn resolve_join_concrete() {
2448 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["a","b","c"]]}}}}}"#;
2449 let ir = parser::parse(input.as_bytes()).unwrap();
2450 let (params, _) = extract_parameters(&ir);
2451 let (mappings, _) = extract_mappings(&ir);
2452 let no_param_overrides = HashMap::new();
2453 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2454 let mut resolver = Resolver::new(
2455 &ir.arena,
2456 ¶ms,
2457 &mappings,
2458 ["R".to_string()].into_iter().collect(),
2459 &no_param_overrides,
2460 &no_pseudo_overrides,
2461 );
2462 let res_map = ir.arena.as_map(ir.resources).unwrap();
2463 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2464 let v_ref = ir.arena.map_get(props, "V").unwrap();
2465 let result = resolver.resolve_node(v_ref);
2466 match result {
2467 ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "a-b-c"),
2468 other => panic!("Expected Concrete, got {:?}", other),
2469 }
2470 }
2471
2472 #[test]
2473 fn resolve_ref_param_override_bypasses_allowed_values() {
2474 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
2475 let ir = parser::parse(input.as_bytes()).unwrap();
2476 let (params, _) = extract_parameters(&ir);
2477 let (mappings, _) = extract_mappings(&ir);
2478 let param_overrides = [("Env".to_string(), "staging".to_string())].into_iter().collect();
2479 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2480 let mut resolver = Resolver::new(
2481 &ir.arena,
2482 ¶ms,
2483 &mappings,
2484 ["R".to_string()].into_iter().collect(),
2485 ¶m_overrides,
2486 &no_pseudo_overrides,
2487 );
2488 let res_map = ir.arena.as_map(ir.resources).unwrap();
2489 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2490 let v_ref = ir.arena.map_get(props, "V").unwrap();
2491 let result = resolver.resolve_node(v_ref);
2492 match result {
2493 ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "staging"),
2494 other => panic!("Expected Concrete(\"staging\"), got {:?}", other),
2495 }
2496 }
2497
2498 #[test]
2499 fn resolve_ref_pseudo_param_override() {
2500 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
2501 let ir = parser::parse(input.as_bytes()).unwrap();
2502 let (params, _) = extract_parameters(&ir);
2503 let (mappings, _) = extract_mappings(&ir);
2504 let no_param_overrides = HashMap::new();
2505 let pseudo_overrides =
2506 crate::model::PseudoParameterOverrides { region: Some("us-west-2".to_string()), ..Default::default() };
2507 let mut resolver = Resolver::new(
2508 &ir.arena,
2509 ¶ms,
2510 &mappings,
2511 ["R".to_string()].into_iter().collect(),
2512 &no_param_overrides,
2513 &pseudo_overrides,
2514 );
2515 let res_map = ir.arena.as_map(ir.resources).unwrap();
2516 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2517 let v_ref = ir.arena.map_get(props, "V").unwrap();
2518 let result = resolver.resolve_node(v_ref);
2519 match result {
2520 ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "us-west-2"),
2521 other => panic!("Expected Concrete(\"us-west-2\"), got {:?}", other),
2522 }
2523 }
2524
2525 #[test]
2526 fn resolve_sub_concrete_interpolation() {
2527 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"prefix-${AWS::Region}-suffix"}}}}}"#;
2528 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2529 match model.resolve("R", "Properties.V") {
2530 Some(ResolvedValue::Concrete { value: v }) => {
2531 assert_eq!(v.as_str().unwrap(), "prefix-us-east-1-suffix");
2532 }
2533 other => panic!("Expected Concrete, got {:?}", other),
2534 }
2535 }
2536
2537 #[test]
2538 fn resolve_sub_with_enum_produces_enum() {
2539 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"app-${Env}"}}}}}"#;
2540 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2541 match model.resolve("R", "Properties.V") {
2542 Some(ResolvedValue::Enum { variants: vals }) => {
2543 assert_eq!(vals.len(), 2);
2544 let strs: Vec<String> = vals
2545 .iter()
2546 .filter_map(|v| match v {
2547 ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2548 _ => None,
2549 })
2550 .collect();
2551 assert!(strs.contains(&"app-dev".to_string()));
2552 assert!(strs.contains(&"app-prod".to_string()));
2553 }
2554 other => panic!("Expected Enum, got {:?}", other),
2555 }
2556 }
2557
2558 #[test]
2559 fn resolve_sub_no_variables_is_redundant() {
2560 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"no-vars-here"}}}}}"#;
2561 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2562 let r = model.resource("R").unwrap();
2563 assert!(!r.diagnostics.redundant_subs.is_empty());
2564 }
2565
2566 #[test]
2567 fn resolve_sub_single_variable_is_simple() {
2568 let input = r#"{"Parameters":{"P":{"Type":"String","Default":"val"}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"${P}"}}}}}"#;
2569 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2570 let r = model.resource("R").unwrap();
2571 assert!(!r.diagnostics.simple_subs.is_empty());
2572 }
2573
2574 #[test]
2575 fn resolve_split_concrete() {
2576 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Split":[",","a,b,c"]}}}}}"#;
2577 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2578 match model.resolve("R", "Properties.V") {
2579 Some(ResolvedValue::Concrete { value: v }) => {
2580 let arr = v.as_array().unwrap();
2581 assert_eq!(arr.len(), 3);
2582 assert_eq!(arr[0], "a");
2583 assert_eq!(arr[1], "b");
2584 assert_eq!(arr[2], "c");
2585 }
2586 other => panic!("Expected Concrete array, got {:?}", other),
2587 }
2588 }
2589
2590 #[test]
2591 fn resolve_base64_concrete() {
2592 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Base64":"hello"}}}}}"#;
2593 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2594 match model.resolve("R", "Properties.V") {
2595 Some(ResolvedValue::Concrete { value: v }) => {
2596 assert_eq!(v.as_str().unwrap(), "aGVsbG8=");
2597 }
2598 other => panic!("Expected Concrete base64, got {:?}", other),
2599 }
2600 }
2601
2602 #[test]
2603 fn resolve_select_concrete() {
2604 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[1,["a","b","c"]]}}}}}"#;
2605 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2606 match model.resolve("R", "Properties.V") {
2607 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "b"),
2608 other => panic!("Expected Concrete(\"b\"), got {:?}", other),
2609 }
2610 }
2611
2612 #[test]
2613 fn resolve_select_out_of_bounds() {
2614 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[5,["a","b"]]}}}}}"#;
2615 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2616 match model.resolve("R", "Properties.V") {
2617 Some(ResolvedValue::Dynamic { reason: msg }) => assert!(msg.contains("out of bounds")),
2618 other => panic!("Expected Dynamic, got {:?}", other),
2619 }
2620 }
2621
2622 #[test]
2623 fn resolve_getazs_default_region() {
2624 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAZs":""}}}}}"#;
2625 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2626 match model.resolve("R", "Properties.V") {
2627 Some(ResolvedValue::Concrete { value: v }) => {
2628 let arr = v.as_array().unwrap();
2629 assert!(arr.len() >= 3);
2630 assert!(arr[0].as_str().unwrap().starts_with("us-east-1"));
2631 }
2632 other => panic!("Expected Concrete AZ array, got {:?}", other),
2633 }
2634 }
2635
2636 #[test]
2637 fn resolve_to_json_string() {
2638 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"key":"value"}}}}}}"#;
2639 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2640 match model.resolve("R", "Properties.V") {
2641 Some(ResolvedValue::Concrete { value: v }) => {
2642 let s = v.as_str().unwrap();
2643 assert!(s.contains("key"));
2644 assert!(s.contains("value"));
2645 }
2646 other => panic!("Expected Concrete JSON string, got {:?}", other),
2647 }
2648 }
2649
2650 #[test]
2651 fn resolve_length_array() {
2652 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Length":["a","b","c"]}}}}}"#;
2653 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2654 match model.resolve("R", "Properties.V") {
2655 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_i64().unwrap(), 3),
2656 other => panic!("Expected Concrete(3), got {:?}", other),
2657 }
2658 }
2659
2660 #[test]
2661 fn resolve_cidr_concrete() {
2662 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Cidr":["10.0.0.0/16","2","8"]}}}}}"#;
2663 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2664 match model.resolve("R", "Properties.V") {
2665 Some(ResolvedValue::Concrete { value: v }) => {
2666 let arr = v.as_array().unwrap();
2667 assert_eq!(arr.len(), 2);
2668 assert!(arr[0].as_str().unwrap().contains("/24"));
2669 }
2670 other => panic!("Expected Concrete CIDR array, got {:?}", other),
2671 }
2672 }
2673
2674 #[test]
2675 fn resolve_import_value_is_typed_dynamic() {
2676 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":"StackExport"}}}}}"#;
2677 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2678 match model.resolve("R", "Properties.V") {
2679 Some(ResolvedValue::TypedDynamic { reason, param_type: t }) => {
2680 assert_eq!(t, "String");
2681 assert!(reason.contains("StackExport"), "reason should carry the export name, got {reason:?}");
2683 }
2684 other => panic!("Expected TypedDynamic, got {:?}", other),
2685 }
2686 }
2687
2688 #[test]
2689 fn resolve_import_value_distinct_exports_differ() {
2690 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{
2691 "A":{"Fn::ImportValue":"ExportOne"},
2692 "B":{"Fn::ImportValue":"ExportTwo"}
2693 }}}}"#;
2694 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2695 let a = model.resolve("R", "Properties.A").cloned();
2696 let b = model.resolve("R", "Properties.B").cloned();
2697 match (a, b) {
2698 (
2699 Some(ResolvedValue::TypedDynamic { reason: ra, .. }),
2700 Some(ResolvedValue::TypedDynamic { reason: rb, .. }),
2701 ) => assert_ne!(ra, rb, "distinct exports must produce distinct symbolic values"),
2702 other => panic!("Expected two TypedDynamic, got {:?}", other),
2703 }
2704 }
2705
2706 #[test]
2707 fn resolve_get_stack_output_carries_source_identity() {
2708 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetStackOutput":{
2709 "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2710 }}}}}}"#;
2711 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2712 match model.resolve("R", "Properties.V") {
2713 Some(ResolvedValue::Dynamic { reason }) => {
2714 for field in ["VpcStack", "ap-northeast-1", "PublicSubnetOne"] {
2716 assert!(reason.contains(field), "reason should carry {field}, got {reason:?}");
2717 }
2718 }
2719 other => panic!("Expected Dynamic, got {:?}", other),
2720 }
2721 }
2722
2723 #[test]
2724 fn resolve_get_stack_output_distinct_sources_differ() {
2725 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{
2726 "Baseline":{"Fn::GetStackOutput":{
2727 "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2728 }},
2729 "OtherStack":{"Fn::GetStackOutput":{
2730 "StackName":"OtherVpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2731 }},
2732 "OtherRegion":{"Fn::GetStackOutput":{
2733 "StackName":"VpcStack","Region":"us-east-1","OutputName":"PublicSubnetOne"
2734 }},
2735 "OtherOutput":{"Fn::GetStackOutput":{
2736 "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetTwo"
2737 }},
2738 "Reordered":{"Fn::GetStackOutput":{
2739 "OutputName":"PublicSubnetOne","StackName":"VpcStack","Region":"ap-northeast-1"
2740 }},
2741 "OtherRole":{"Fn::GetStackOutput":{
2742 "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne",
2743 "RoleArn":"arn:aws:iam::444455556666:role/Lookup"
2744 }}
2745 }}}}"#;
2746 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2747 let source_of = |property: &str| match model.resolve("R", &format!("Properties.{property}")) {
2748 Some(ResolvedValue::Dynamic { reason }) => reason.clone(),
2749 other => panic!("Expected Dynamic for {property}, got {other:?}"),
2750 };
2751
2752 let baseline = source_of("Baseline");
2753 for distinct in ["OtherStack", "OtherRegion", "OtherOutput"] {
2754 assert_ne!(baseline, source_of(distinct), "{distinct} must not collapse onto the baseline output");
2755 }
2756 assert_eq!(baseline, source_of("Reordered"), "argument order must not make the same output distinct");
2757 assert_eq!(baseline, source_of("OtherRole"), "RoleArn must not make the same output distinct");
2760 }
2761
2762 #[test]
2763 fn embedded_dynamic_reference_is_opaque() {
2764 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"prefix-{{resolve:ssm:/my/param}}"}}}}"#;
2767 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2768 match model.resolve("R", "Properties.V") {
2769 Some(ResolvedValue::TypedDynamic { reason, param_type: t }) => {
2770 assert_eq!(t, "String");
2771 assert!(
2772 reason.contains("{{resolve:ssm:/my/param}}"),
2773 "reason should carry the literal, got {reason:?}"
2774 );
2775 }
2776 other => panic!("Expected TypedDynamic, got {:?}", other),
2777 }
2778 }
2779
2780 #[test]
2781 fn sub_producing_dynamic_reference_is_opaque() {
2782 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"${AWS::Region}-{{resolve:ssm:/my/param}}"}}}}}"#;
2785 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2786 match model.resolve("R", "Properties.V") {
2787 Some(ResolvedValue::TypedDynamic { reason, .. }) => {
2788 assert!(
2789 reason.contains("{{resolve:ssm:/my/param}}"),
2790 "reason should carry the literal, got {reason:?}"
2791 );
2792 }
2793 other => panic!("Expected TypedDynamic, got {:?}", other),
2794 }
2795 }
2796
2797 #[test]
2798 fn join_producing_dynamic_reference_is_opaque() {
2799 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["prefix","{{resolve:ssm:/my/param}}"]]}}}}}"#;
2803 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2804 let resolved = model.resolve("R", "Properties.V").expect("V should resolve");
2805 assert!(
2806 crate::resolved_value::contains_dynamic_resolved(resolved),
2807 "Fn::Join embedding a dynamic reference must be opaque, got {resolved:?}"
2808 );
2809 assert!(
2810 !matches!(resolved, ResolvedValue::Concrete { .. }),
2811 "the join result must not be a concrete literal, got {resolved:?}"
2812 );
2813 }
2814
2815 #[test]
2816 fn select_producing_dynamic_reference_is_opaque() {
2817 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[1,["a","{{resolve:ssm:/my/param}}"]]}}}}}"#;
2819 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2820 match model.resolve("R", "Properties.V") {
2821 Some(ResolvedValue::TypedDynamic { reason, .. }) => {
2822 assert!(
2823 reason.contains("{{resolve:ssm:/my/param}}"),
2824 "reason should carry the literal, got {reason:?}"
2825 );
2826 }
2827 other => panic!("Expected TypedDynamic, got {:?}", other),
2828 }
2829 }
2830
2831 #[test]
2832 fn nested_dynamic_reference_stays_inside_list() {
2833 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":["plain","{{resolve:ssm:/my/param}}"]}}}}"#;
2836 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2837 match model.resolve("R", "Properties.V") {
2838 Some(ResolvedValue::List { items }) => {
2839 assert_eq!(items.len(), 2);
2840 assert!(matches!(items[0], ResolvedValue::Concrete { .. }));
2841 assert!(
2842 matches!(items[1], ResolvedValue::TypedDynamic { .. }),
2843 "the embedded reference element must be opaque, got {:?}",
2844 items[1]
2845 );
2846 }
2847 other => panic!("Expected List with an opaque element, got {:?}", other),
2848 }
2849 }
2850
2851 #[test]
2852 fn malformed_dynamic_reference_with_space_stays_concrete() {
2853 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"{{ resolve:ssm:/my/param}}"}}}}"#;
2857 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2858 match model.resolve("R", "Properties.V") {
2859 Some(ResolvedValue::Concrete { value: v }) => {
2860 assert_eq!(v.as_str().unwrap(), "{{ resolve:ssm:/my/param}}");
2861 }
2862 other => panic!("Expected Concrete, got {:?}", other),
2863 }
2864 }
2865
2866 #[test]
2867 fn comma_delimited_list_default_resolves_to_array() {
2868 let input = r#"{"Parameters":{"P":{"Type":"CommaDelimitedList","Default":"GET, PUT ,POST"}},
2869 "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"P"}}}}}"#;
2870 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2871 match model.resolve("R", "Properties.V") {
2872 Some(ResolvedValue::Concrete { value }) => {
2873 assert_eq!(
2874 **value,
2875 serde_json::json!(["GET", "PUT", "POST"]),
2876 "default must split into a trimmed array"
2877 );
2878 }
2879 other => panic!("Expected Concrete array, got {:?}", other),
2880 }
2881 }
2882
2883 #[test]
2884 fn resolve_no_value_is_null() {
2885 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::NoValue"}}}}}"#;
2886 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2887 match model.resolve("R", "Properties.V") {
2888 Some(ResolvedValue::Concrete { value: v }) => assert!(v.is_null()),
2889 other => panic!("Expected Concrete(null), got {:?}", other),
2890 }
2891 assert!(model.is_from_intrinsic("R", "Properties.V"));
2892 }
2893
2894 #[test]
2895 fn extract_parameters_with_constraints() {
2896 let input = r#"{"Parameters":{"P":{"Type":"String","MinLength":1,"MaxLength":100,"AllowedPattern":"^[a-z]+$","Default":"abc","NoEcho":true}},"Resources":{"R":{"Type":"T"}}}"#;
2897 let ir = parser::parse(input.as_bytes()).unwrap();
2898 let (params, _) = extract_parameters(&ir);
2899 let p = ¶ms["P"];
2900 assert_eq!(p.param_type, "String");
2901 assert_eq!(p.default.as_deref(), Some("abc"));
2902 assert_eq!(p.min_length, Some(1));
2903 assert_eq!(p.max_length, Some(100));
2904 assert_eq!(p.allowed_pattern.as_deref(), Some("^[a-z]+$"));
2905 assert!(p.no_echo);
2906 }
2907
2908 #[test]
2909 fn extract_parameters_number_type() {
2910 let input = r#"{"Parameters":{"N":{"Type":"Number","Default":"42","MinValue":0,"MaxValue":100}},"Resources":{"R":{"Type":"T"}}}"#;
2911 let ir = parser::parse(input.as_bytes()).unwrap();
2912 let (params, _) = extract_parameters(&ir);
2913 let n = ¶ms["N"];
2914 assert_eq!(n.param_type, "Number");
2915 assert_eq!(n.default.as_deref(), Some("42"));
2916 assert_eq!(n.min_value, Some(0));
2917 assert_eq!(n.max_value, Some(100));
2918 }
2919
2920 #[test]
2921 fn extract_mappings_three_level() {
2922 let input = r#"{"Mappings":{"M":{"k1":{"k2":"val"}}},"Resources":{"R":{"Type":"T"}}}"#;
2923 let ir = parser::parse(input.as_bytes()).unwrap();
2924 let (mappings, diags) = extract_mappings(&ir);
2925 assert!(diags.is_empty());
2926 assert_eq!(mappings["M"]["k1"]["k2"], serde_json::json!("val"));
2927 }
2928
2929 #[test]
2930 fn extract_mappings_invalid_structure_produces_diagnostic() {
2931 let input = r#"{"Mappings":{"M":"not-a-map"},"Resources":{"R":{"Type":"T"}}}"#;
2932 let ir = parser::parse(input.as_bytes()).unwrap();
2933 let (_, diags) = extract_mappings(&ir);
2934 assert!(!diags.is_empty());
2935 assert_eq!(diags[0].rule_id, "F0017");
2936 }
2937
2938 #[test]
2939 fn resolve_findinmap_with_enum_key() {
2940 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Mappings":{"M":{"dev":{"v":"d"},"prod":{"v":"p"}}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M",{"Ref":"Env"},"v"]}}}}}"#;
2941 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2942 match model.resolve("R", "Properties.V") {
2943 Some(ResolvedValue::Enum { variants: vals }) => {
2944 assert_eq!(vals.len(), 2);
2945 let strs: Vec<String> = vals
2946 .iter()
2947 .filter_map(|v| match v {
2948 ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2949 _ => None,
2950 })
2951 .collect();
2952 assert!(strs.contains(&"d".to_string()));
2953 assert!(strs.contains(&"p".to_string()));
2954 }
2955 other => panic!("Expected Enum, got {:?}", other),
2956 }
2957 }
2958
2959 #[test]
2960 fn resolve_hardcoded_partition_arn_tracked() {
2961 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"arn:aws:s3:::my-bucket"}}}}}"#;
2962 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2963 let r = model.resource("R").unwrap();
2964 assert!(!r.diagnostics.hardcoded_partition_arns.is_empty());
2965 }
2966
2967 #[test]
2968 fn resolve_getatt_produces_reference() {
2969 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAtt":["Other","Arn"]}}},"Other":{"Type":"T2"}}}"#;
2970 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2971 match model.resolve("R", "Properties.V") {
2972 Some(ResolvedValue::Reference { target: t, kind: RefKind::GetAtt { attr: a } }) => {
2973 assert_eq!(t, "Other");
2974 assert_eq!(a, "Arn");
2975 }
2976 other => panic!("Expected Reference(GetAtt), got {:?}", other),
2977 }
2978 }
2979
2980 #[test]
2981 fn findinmap_concrete_first_key_enum_second_key_produces_enum() {
2982 let input = r#"{
2983 "Parameters":{"K2":{"Type":"String","AllowedValues":["a","b"]}},
2984 "Mappings":{"M":{"x":{"a":"va","b":"vb"}}},
2985 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M","x",{"Ref":"K2"}]}}}}
2986 }"#;
2987 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2988 match model.resolve("R", "Properties.V") {
2989 Some(ResolvedValue::Enum { variants }) => {
2990 assert_eq!(variants.len(), 2);
2991 let strs: Vec<String> = variants
2992 .iter()
2993 .filter_map(|v| match v {
2994 ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2995 _ => None,
2996 })
2997 .collect();
2998 assert!(strs.contains(&"va".to_string()));
2999 assert!(strs.contains(&"vb".to_string()));
3000 }
3001 other => panic!("Expected Enum, got {:?}", other),
3002 }
3003 }
3004
3005 #[test]
3006 fn findinmap_enum_first_key_enum_second_key_produces_flattened_enum() {
3007 let input = r#"{
3008 "Parameters":{
3009 "K1":{"Type":"String","AllowedValues":["r1","r2"]},
3010 "K2":{"Type":"String","AllowedValues":["a","b"]}
3011 },
3012 "Mappings":{"M":{"r1":{"a":"r1a","b":"r1b"},"r2":{"a":"r2a","b":"r2b"}}},
3013 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M",{"Ref":"K1"},{"Ref":"K2"}]}}}}
3014 }"#;
3015 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3016 match model.resolve("R", "Properties.V") {
3017 Some(ResolvedValue::Enum { variants }) => {
3018 assert_eq!(variants.len(), 4);
3020 let strs: Vec<String> = variants
3021 .iter()
3022 .filter_map(|v| match v {
3023 ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
3024 _ => None,
3025 })
3026 .collect();
3027 assert!(strs.contains(&"r1a".to_string()));
3028 assert!(strs.contains(&"r1b".to_string()));
3029 assert!(strs.contains(&"r2a".to_string()));
3030 assert!(strs.contains(&"r2b".to_string()));
3031 }
3032 other => panic!("Expected Enum with 4 variants, got {:?}", other),
3033 }
3034 }
3035
3036 #[test]
3037 fn findinmap_conditional_second_key_produces_conditional() {
3038 let input = r#"{
3039 "Conditions":{"C":{"Fn::Equals":["a","a"]}},
3040 "Mappings":{"M":{"x":{"a":"va","b":"vb"}}},
3041 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M","x",{"Fn::If":["C","a","b"]}]}}}}
3042 }"#;
3043 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3044 match model.resolve("R", "Properties.V") {
3045 Some(ResolvedValue::Conditional { .. }) => {}
3046 other => panic!("Expected Conditional, got {:?}", other),
3047 }
3048 }
3049
3050 #[test]
3051 fn select_from_list_with_mixed_items_returns_element() {
3052 let input = r#"{
3055 "Resources":{
3056 "Other":{"Type":"T2"},
3057 "R":{"Type":"T","Properties":{"V":{"Fn::Select":[0,[{"Fn::GetAZs":"us-east-1"},{"Ref":"Other"}]]}}}
3058 }
3059 }"#;
3060 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3061 match model.resolve("R", "Properties.V") {
3062 Some(ResolvedValue::Concrete { value: v }) => {
3063 let arr = v.as_array().unwrap();
3064 assert!(arr[0].as_str().unwrap().starts_with("us-east-1"));
3065 }
3066 other => panic!("Expected Concrete AZ array from Select on List, got {:?}", other),
3067 }
3068 }
3069
3070 #[test]
3071 fn select_from_list_out_of_bounds_returns_dynamic() {
3072 let input = r#"{
3073 "Resources":{
3074 "Other":{"Type":"T2"},
3075 "R":{"Type":"T","Properties":{"V":{"Fn::Select":[5,["a",{"Ref":"Other"}]]}}}
3076 }
3077 }"#;
3078 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3079 match model.resolve("R", "Properties.V") {
3080 Some(ResolvedValue::Dynamic { reason }) => {
3081 assert!(reason.contains("out of bounds"));
3082 }
3083 other => panic!("Expected Dynamic out of bounds, got {:?}", other),
3084 }
3085 }
3086
3087 #[test]
3088 fn equals_two_concrete_strings_returns_true() {
3089 let input = r#"{
3090 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":["hello","hello"]}}}}
3091 }"#;
3092 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3093 match model.resolve("R", "Properties.V") {
3094 Some(ResolvedValue::Concrete { value: v }) => {
3095 assert_eq!(v.as_bool(), Some(true));
3096 }
3097 other => panic!("Expected Concrete(true), got {:?}", other),
3098 }
3099 }
3100
3101 #[test]
3102 fn equals_two_different_strings_returns_false() {
3103 let input = r#"{
3104 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":["a","b"]}}}}
3105 }"#;
3106 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3107 match model.resolve("R", "Properties.V") {
3108 Some(ResolvedValue::Concrete { value: v }) => {
3109 assert_eq!(v.as_bool(), Some(false));
3110 }
3111 other => panic!("Expected Concrete(false), got {:?}", other),
3112 }
3113 }
3114
3115 #[test]
3116 fn equals_enum_against_concrete_produces_enum_of_bools() {
3117 let input = r#"{
3118 "Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},
3119 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":[{"Ref":"Env"},"prod"]}}}}
3120 }"#;
3121 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3122 match model.resolve("R", "Properties.V") {
3123 Some(ResolvedValue::Enum { variants }) => {
3124 assert_eq!(variants.len(), 2);
3125 let bools: Vec<bool> = variants
3126 .iter()
3127 .filter_map(|v| match v {
3128 ResolvedValue::Concrete { value: j } => j.as_bool(),
3129 _ => None,
3130 })
3131 .collect();
3132 assert!(bools.contains(&true));
3133 assert!(bools.contains(&false));
3134 }
3135 other => panic!("Expected Enum of bools, got {:?}", other),
3136 }
3137 }
3138
3139 #[test]
3140 fn and_all_true_returns_true() {
3141 let input = r#"{
3142 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":["b","b"]}]}}}}
3143 }"#;
3144 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3145 match model.resolve("R", "Properties.V") {
3146 Some(ResolvedValue::Concrete { value: v }) => {
3147 assert_eq!(v.as_bool(), Some(true));
3148 }
3149 other => panic!("Expected Concrete(true), got {:?}", other),
3150 }
3151 }
3152
3153 #[test]
3154 fn and_one_false_returns_false() {
3155 let input = r#"{
3156 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":["a","b"]}]}}}}
3157 }"#;
3158 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3159 match model.resolve("R", "Properties.V") {
3160 Some(ResolvedValue::Concrete { value: v }) => {
3161 assert_eq!(v.as_bool(), Some(false));
3162 }
3163 other => panic!("Expected Concrete(false), got {:?}", other),
3164 }
3165 }
3166
3167 #[test]
3168 fn or_one_true_returns_true() {
3169 let input = r#"{
3170 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Or":[{"Fn::Equals":["a","b"]},{"Fn::Equals":["c","c"]}]}}}}
3171 }"#;
3172 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3173 match model.resolve("R", "Properties.V") {
3174 Some(ResolvedValue::Concrete { value: v }) => {
3175 assert_eq!(v.as_bool(), Some(true));
3176 }
3177 other => panic!("Expected Concrete(true), got {:?}", other),
3178 }
3179 }
3180
3181 #[test]
3182 fn or_all_false_returns_false() {
3183 let input = r#"{
3184 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Or":[{"Fn::Equals":["a","b"]},{"Fn::Equals":["c","d"]}]}}}}
3185 }"#;
3186 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3187 match model.resolve("R", "Properties.V") {
3188 Some(ResolvedValue::Concrete { value: v }) => {
3189 assert_eq!(v.as_bool(), Some(false));
3190 }
3191 other => panic!("Expected Concrete(false), got {:?}", other),
3192 }
3193 }
3194
3195 #[test]
3196 fn not_true_returns_false() {
3197 let input = r#"{
3198 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Not":[{"Fn::Equals":["a","a"]}]}}}}
3199 }"#;
3200 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3201 match model.resolve("R", "Properties.V") {
3202 Some(ResolvedValue::Concrete { value: v }) => {
3203 assert_eq!(v.as_bool(), Some(false));
3204 }
3205 other => panic!("Expected Concrete(false), got {:?}", other),
3206 }
3207 }
3208
3209 #[test]
3210 fn and_with_dynamic_child_returns_dynamic() {
3211 let input = r#"{
3212 "Resources":{
3213 "Other":{"Type":"T2"},
3214 "R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":[{"Ref":"Other"},"x"]}]}}}
3215 }
3216 }"#;
3217 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3218 match model.resolve("R", "Properties.V") {
3219 Some(ResolvedValue::Dynamic { .. }) => {}
3220 other => panic!("Expected Dynamic, got {:?}", other),
3221 }
3222 }
3223
3224 #[test]
3225 fn sub_enum_plus_reference_produces_enum_with_dynamic_variants() {
3226 let input = r#"{
3227 "Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},
3228 "Resources":{
3229 "Bucket":{"Type":"AWS::S3::Bucket"},
3230 "R":{"Type":"T","Properties":{"V":{"Fn::Sub":"arn:${Env}:${Bucket}"}}}
3231 }
3232 }"#;
3233 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3234 match model.resolve("R", "Properties.V") {
3235 Some(ResolvedValue::Enum { variants }) => {
3236 assert_eq!(variants.len(), 2);
3237 for v in variants {
3239 match v {
3240 ResolvedValue::Dynamic { reason } => {
3241 assert!(reason.starts_with("Sub:"));
3242 }
3243 _ => panic!("Expected Dynamic variant, got {:?}", v),
3244 }
3245 }
3246 }
3247 other => panic!("Expected Enum with Dynamic variants, got {:?}", other),
3248 }
3249 }
3250
3251 #[test]
3252 fn join_list_with_mixed_concrete_and_reference_produces_partial_dynamic() {
3253 let input = r#"{
3254 "Resources":{
3255 "Other":{"Type":"T2"},
3256 "R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["prefix",{"Ref":"Other"},"suffix"]]}}}
3257 }
3258 }"#;
3259 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3260 match model.resolve("R", "Properties.V") {
3261 Some(ResolvedValue::Dynamic { reason }) => {
3262 assert!(reason.starts_with("Join:"));
3263 assert!(reason.contains("prefix"));
3264 assert!(reason.contains("suffix"));
3265 }
3266 other => panic!("Expected Dynamic with Join: prefix, got {:?}", other),
3267 }
3268 }
3269
3270 #[test]
3271 fn getazs_enum_region_produces_enum_of_az_arrays() {
3272 let input = r#"{
3273 "Parameters":{"Region":{"Type":"String","AllowedValues":["us-east-1","us-west-2"]}},
3274 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAZs":{"Ref":"Region"}}}}}
3275 }"#;
3276 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3277 match model.resolve("R", "Properties.V") {
3278 Some(ResolvedValue::Enum { variants }) => {
3279 assert_eq!(variants.len(), 2);
3280 for v in variants {
3281 match v {
3282 ResolvedValue::Concrete { value: arr } => {
3283 assert!(arr.as_array().unwrap().len() >= 2);
3284 }
3285 _ => panic!("Expected Concrete AZ arrays, got {:?}", v),
3286 }
3287 }
3288 }
3289 other => panic!("Expected Enum of AZ arrays, got {:?}", other),
3290 }
3291 }
3292
3293 #[test]
3294 fn cidr_enum_ip_block_produces_enum_of_cidr_arrays() {
3295 let input = r#"{
3296 "Parameters":{"Cidr":{"Type":"String","AllowedValues":["10.0.0.0/16","172.16.0.0/16"]}},
3297 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Cidr":[{"Ref":"Cidr"},"2","8"]}}}}
3298 }"#;
3299 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3300 match model.resolve("R", "Properties.V") {
3301 Some(ResolvedValue::Enum { variants }) => {
3302 assert_eq!(variants.len(), 2);
3303 for v in variants {
3304 match v {
3305 ResolvedValue::Concrete { value: arr } => {
3306 assert_eq!(arr.as_array().unwrap().len(), 2);
3307 }
3308 _ => panic!("Expected Concrete CIDR arrays, got {:?}", v),
3309 }
3310 }
3311 }
3312 other => panic!("Expected Enum of CIDR arrays, got {:?}", other),
3313 }
3314 }
3315
3316 #[test]
3317 fn to_json_string_enum_produces_enum_of_strings() {
3318 let input = r#"{
3319 "Parameters":{"Val":{"Type":"String","AllowedValues":["a","b"]}},
3320 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"Ref":"Val"}}}}}
3321 }"#;
3322 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3323 match model.resolve("R", "Properties.V") {
3324 Some(ResolvedValue::Enum { variants }) => {
3325 assert_eq!(variants.len(), 2);
3326 for v in variants {
3327 match v {
3328 ResolvedValue::Concrete { value: j } => {
3329 assert!(j.is_string());
3330 }
3331 _ => panic!("Expected Concrete string, got {:?}", v),
3332 }
3333 }
3334 }
3335 other => panic!("Expected Enum, got {:?}", other),
3336 }
3337 }
3338
3339 #[test]
3340 fn to_json_string_conditional_produces_conditional() {
3341 let input = r#"{
3342 "Conditions":{"C":{"Fn::Equals":["a","a"]}},
3343 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"Fn::If":["C","yes","no"]}}}}}
3344 }"#;
3345 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3346 match model.resolve("R", "Properties.V") {
3347 Some(ResolvedValue::Conditional { .. }) => {}
3348 other => panic!("Expected Conditional, got {:?}", other),
3349 }
3350 }
3351
3352 #[test]
3353 fn length_of_resolved_list_returns_count() {
3354 let input = r#"{
3356 "Resources":{
3357 "Other":{"Type":"T2"},
3358 "R":{"Type":"T","Properties":{"V":{"Fn::Length":[1,{"Ref":"Other"},3]}}}
3359 }
3360 }"#;
3361 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3362 match model.resolve("R", "Properties.V") {
3363 Some(ResolvedValue::Concrete { value: v }) => {
3364 assert_eq!(v.as_i64(), Some(3));
3365 }
3366 other => panic!("Expected Concrete(3), got {:?}", other),
3367 }
3368 }
3369
3370 #[test]
3371 fn foreach_concrete_collection_evaluates_body() {
3372 let input = r#"{
3373 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ForEach":["Id","Item",["a","b"],{"Fn::Sub":"prefix-${Item}"}]}}}}
3374 }"#;
3375 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3376 match model.resolve("R", "Properties.V") {
3377 Some(ResolvedValue::Map { entries }) => {
3378 assert_eq!(entries.len(), 2);
3379 let values: Vec<String> = entries
3380 .iter()
3381 .filter_map(|e| match &e.value {
3382 ResolvedValue::Concrete { value: v } => v.as_str().map(|s| s.to_string()),
3383 _ => None,
3384 })
3385 .collect();
3386 assert!(values.contains(&"prefix-a".to_string()));
3387 assert!(values.contains(&"prefix-b".to_string()));
3388 }
3389 other => panic!("Expected Map with evaluated body, got {:?}", other),
3390 }
3391 }
3392
3393 #[test]
3394 fn foreach_dynamic_collection_returns_dynamic() {
3395 let input = r#"{
3396 "Resources":{
3397 "Other":{"Type":"T2"},
3398 "R":{"Type":"T","Properties":{"V":{"Fn::ForEach":["Id","Item",{"Ref":"Other"},"body"]}}}
3399 }
3400 }"#;
3401 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3402 match model.resolve("R", "Properties.V") {
3403 Some(ResolvedValue::Dynamic { .. }) => {}
3404 other => panic!("Expected Dynamic, got {:?}", other),
3405 }
3406 }
3407
3408 #[test]
3409 fn output_getatt_edge_carries_terminal_intrinsic_path() {
3410 let input = r#"{
3413 "Resources":{"R":{"Type":"T","Properties":{}}},
3414 "Outputs":{
3415 "DottedString":{"Value":{"Fn::GetAtt":["R","Arn"]}},
3416 "ListForm":{"Value":{"Fn::GetAtt":["R","Arn"]}}
3417 }
3418 }"#;
3419 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3420 let edges: Vec<_> = model.graph.edges.iter().filter(|e| e.source_resource.starts_with("__output__")).collect();
3421 assert_eq!(edges.len(), 2);
3422 for edge in &edges {
3423 assert!(
3424 edge.source_path.ends_with(".Fn::GetAtt"),
3425 "output GetAtt edge path should end with .Fn::GetAtt, got: {}",
3426 edge.source_path,
3427 );
3428 }
3429 }
3430
3431 #[test]
3432 fn output_sub_implicit_getatt_edge_carries_fn_sub_path() {
3433 let input = r#"{
3435 "Resources":{"R":{"Type":"T","Properties":{}}},
3436 "Outputs":{
3437 "SubOut":{"Value":{"Fn::Sub":"${R.Arn}"}}
3438 }
3439 }"#;
3440 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3441 let edges: Vec<_> = model
3442 .graph
3443 .edges
3444 .iter()
3445 .filter(|e| e.source_resource.starts_with("__output__"))
3446 .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3447 .collect();
3448 assert_eq!(edges.len(), 1);
3449 assert_eq!(edges[0].source_path, "Outputs/SubOut/Value.Fn::Sub");
3450 }
3451
3452 #[test]
3453 fn output_join_nested_getatt_edge_path() {
3454 let input = r#"{
3457 "Resources":{"R":{"Type":"T","Properties":{}}},
3458 "Outputs":{
3459 "JoinOut":{"Value":{"Fn::Join":["",["ignored",{"Fn::GetAtt":["R","Arn"]}]]}}
3460 }
3461 }"#;
3462 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3463 let edges: Vec<_> = model
3464 .graph
3465 .edges
3466 .iter()
3467 .filter(|e| e.source_resource == "__output__JoinOut")
3468 .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3469 .collect();
3470 assert_eq!(edges.len(), 1);
3471 assert_eq!(edges[0].source_path, "Outputs/JoinOut/Value.Fn::Join.1.1.Fn::GetAtt");
3472 }
3473
3474 #[test]
3475 fn resource_getatt_edge_path_unchanged() {
3476 let input = r#"{
3479 "Resources":{
3480 "R":{"Type":"T","Properties":{"V":{"Fn::GetAtt":["Other","Arn"]}}},
3481 "Other":{"Type":"T2","Properties":{}}
3482 }
3483 }"#;
3484 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3485 let edges: Vec<_> = model
3486 .graph
3487 .edges
3488 .iter()
3489 .filter(|e| e.source_resource == "R")
3490 .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3491 .collect();
3492 assert_eq!(edges.len(), 1);
3493 assert_eq!(edges[0].source_path, "Properties.V");
3494 assert!(!edges[0].source_path.contains("Fn::GetAtt"));
3495 }
3496}