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(val) = self.pseudo_parameter_overrides.get(target) {
939 return Some(ResolvedValue::Concrete { value: serde_json::Value::String(val).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 param_string_to_json(value: &str, param_type: &str) -> serde_json::Value {
1587 if param_type == PARAM_TYPE_COMMA_DELIMITED_LIST || param_type.starts_with("List<") {
1588 return serde_json::Value::Array(
1589 value.split(',').map(|v| serde_json::Value::String(v.trim().to_string())).collect(),
1590 );
1591 }
1592 match param_type {
1593 PARAM_TYPE_NUMBER => value
1597 .parse::<i64>()
1598 .map(|i| serde_json::Value::Number(i.into()))
1599 .or_else(|_| {
1600 value.parse::<f64>().map(|n| {
1601 serde_json::Number::from_f64(n)
1602 .map(serde_json::Value::Number)
1603 .unwrap_or(serde_json::Value::String(value.to_string()))
1604 })
1605 })
1606 .unwrap_or(serde_json::Value::String(value.to_string())),
1607 _ => serde_json::Value::String(value.to_string()),
1608 }
1609}
1610
1611fn calculate_cidr_blocks(ip_block: &str, count: u64, cidr_bits: u64) -> Option<Vec<String>> {
1612 let (ip_str, prefix_str) = ip_block.split_once('/')?;
1613 let prefix_len: u32 = prefix_str.parse().ok()?;
1614 let new_prefix = 32 - cidr_bits as u32;
1615 if new_prefix <= prefix_len {
1616 return None;
1617 }
1618 let ip: u32 = ip_str.split('.').enumerate().try_fold(0u32, |acc, (i, octet)| {
1619 let o: u32 = octet.parse().ok()?;
1620 Some(acc | (o << (24 - i * 8)))
1621 })?;
1622 let subnet_size = 1u32 << cidr_bits;
1623 let base = ip & !((1u32 << (32 - prefix_len)) - 1);
1624 let mut results = Vec::new();
1625 for i in 0..count.min(256) {
1626 let subnet_ip = base + (i as u32) * subnet_size;
1627 let a = (subnet_ip >> 24) & 0xFF;
1628 let b = (subnet_ip >> 16) & 0xFF;
1629 let c = (subnet_ip >> 8) & 0xFF;
1630 let d = subnet_ip & 0xFF;
1631 results.push(format!("{}.{}.{}.{}/{}", a, b, c, d, new_prefix));
1632 }
1633 Some(results)
1634}
1635
1636fn opaque_if_dynamic_reference(value: ResolvedValue) -> ResolvedValue {
1640 if let ResolvedValue::Concrete { value: ref json } = value
1641 && let Some(s) = json.as_str()
1642 && s.contains("{{resolve:")
1643 {
1644 return ResolvedValue::TypedDynamic {
1645 reason: format!("dynamic reference: {}", s),
1646 param_type: PARAM_TYPE_STRING.into(),
1647 };
1648 }
1649 value
1650}
1651
1652fn join_resolved(delim: &str, values: &ResolvedValue) -> ResolvedValue {
1653 match values {
1654 ResolvedValue::Concrete { value: v } => {
1655 if let Some(arr) = v.as_array() {
1656 let parts: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
1657 if parts.len() == arr.len() {
1658 return ResolvedValue::Concrete { value: serde_json::Value::String(parts.join(delim)).into() };
1659 }
1660 }
1661 ResolvedValue::Dynamic { reason: "Join with non-string elements".into() }
1662 }
1663 ResolvedValue::Enum { variants } => {
1664 let results: Vec<ResolvedValue> = variants.iter().map(|v| join_resolved(delim, v)).collect();
1665 ResolvedValue::Enum { variants: results }
1666 }
1667 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1668 condition: cond.clone(),
1669 if_true: Box::new(join_resolved(delim, t)),
1670 if_false: Box::new(join_resolved(delim, f)),
1671 },
1672 _ => ResolvedValue::Dynamic { reason: "Join with unresolvable arguments".into() },
1673 }
1674}
1675
1676pub(crate) fn join_with_enum_list(delim: &str, items: &[ResolvedValue]) -> (ResolvedValue, bool) {
1677 let mut combos: Vec<Vec<String>> = vec![vec![]];
1681 let mut curtailed = false;
1682 for item in items {
1683 match item {
1684 ResolvedValue::Concrete { value: v } => {
1685 let s = v.as_str().unwrap_or("").to_string();
1686 for combo in &mut combos {
1687 combo.push(s.clone());
1688 }
1689 }
1690 ResolvedValue::Enum { variants } => {
1691 let strs: Vec<String> = variants
1692 .iter()
1693 .filter_map(|v| {
1694 if let ResolvedValue::Concrete { value: cv } = v {
1695 cv.as_str().map(|s| s.to_string())
1696 } else {
1697 None
1698 }
1699 })
1700 .collect();
1701 if strs.is_empty() {
1702 return (ResolvedValue::Dynamic { reason: "Join with non-concrete enum".into() }, false);
1703 }
1704 let mut new_combos = Vec::new();
1705 for combo in &combos {
1706 for s in &strs {
1707 if new_combos.len() == MAX_ENUM_EXPANSION {
1708 curtailed = true;
1709 break;
1710 }
1711 let mut c = combo.clone();
1712 c.push(s.clone());
1713 new_combos.push(c);
1714 }
1715 if curtailed {
1716 break;
1717 }
1718 }
1719 combos = new_combos;
1720 }
1721 _ => {
1722 return (ResolvedValue::Dynamic { reason: "Join with unresolvable list element".into() }, false);
1723 }
1724 }
1725 }
1726 let results: Vec<ResolvedValue> = combos
1727 .into_iter()
1728 .map(|parts| ResolvedValue::Concrete { value: serde_json::Value::String(parts.join(delim)).into() })
1729 .collect();
1730 let result = if results.len() == 1 {
1731 results.into_iter().next().unwrap()
1732 } else {
1733 ResolvedValue::Enum { variants: results }
1734 };
1735 (result, curtailed)
1736}
1737
1738fn select_resolved(idx: &serde_json::Value, list: &ResolvedValue) -> ResolvedValue {
1739 match list {
1740 ResolvedValue::Concrete { value: l } => {
1741 if let (Some(arr), Some(i)) = (l.as_array(), idx.as_u64())
1742 && (i as usize) < arr.len()
1743 {
1744 return ResolvedValue::Concrete { value: arr[i as usize].clone().into() };
1745 }
1746 ResolvedValue::Dynamic { reason: "Select index out of bounds".into() }
1747 }
1748 ResolvedValue::List { items } => {
1749 if let Some(i) = idx.as_u64()
1750 && (i as usize) < items.len()
1751 {
1752 return items[i as usize].clone();
1753 }
1754 ResolvedValue::Dynamic { reason: "Select index out of bounds".into() }
1755 }
1756 ResolvedValue::Enum { variants } => {
1757 let results: Vec<ResolvedValue> = variants.iter().map(|v| select_resolved(idx, v)).collect();
1758 ResolvedValue::Enum { variants: results }
1759 }
1760 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1761 condition: cond.clone(),
1762 if_true: Box::new(select_resolved(idx, t)),
1763 if_false: Box::new(select_resolved(idx, f)),
1764 },
1765 _ => ResolvedValue::Dynamic { reason: "Select with unresolvable arguments".into() },
1766 }
1767}
1768
1769fn split_resolved(delim: &str, src: &ResolvedValue) -> ResolvedValue {
1770 match src {
1771 ResolvedValue::Concrete { value: s } => {
1772 if let Some(ss) = s.as_str() {
1773 let parts: Vec<serde_json::Value> =
1774 ss.split(delim).map(|p| serde_json::Value::String(p.to_string())).collect();
1775 return ResolvedValue::Concrete { value: serde_json::Value::Array(parts).into() };
1776 }
1777 ResolvedValue::Dynamic { reason: "Split with non-string argument".into() }
1778 }
1779 ResolvedValue::Enum { variants } => {
1780 let results: Vec<ResolvedValue> = variants.iter().map(|v| split_resolved(delim, v)).collect();
1781 ResolvedValue::Enum { variants: results }
1782 }
1783 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1784 condition: cond.clone(),
1785 if_true: Box::new(split_resolved(delim, t)),
1786 if_false: Box::new(split_resolved(delim, f)),
1787 },
1788 _ => ResolvedValue::Dynamic { reason: "Split with unresolvable argument".into() },
1789 }
1790}
1791
1792fn base64_resolved(val: &ResolvedValue) -> ResolvedValue {
1793 match val {
1794 ResolvedValue::Concrete { value: v } => {
1795 if let Some(s) = v.as_str() {
1796 let encoded = base64::engine::general_purpose::STANDARD.encode(s);
1797 return ResolvedValue::Concrete { value: serde_json::Value::String(encoded).into() };
1798 }
1799 ResolvedValue::Dynamic { reason: "Base64 with non-string argument".into() }
1800 }
1801 ResolvedValue::Enum { variants } => {
1802 let results: Vec<ResolvedValue> = variants.iter().map(base64_resolved).collect();
1803 ResolvedValue::Enum { variants: results }
1804 }
1805 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1806 condition: cond.clone(),
1807 if_true: Box::new(base64_resolved(t)),
1808 if_false: Box::new(base64_resolved(f)),
1809 },
1810 _ => ResolvedValue::Dynamic { reason: "Base64 with unresolvable argument".into() },
1811 }
1812}
1813
1814fn length_resolved(val: &ResolvedValue) -> ResolvedValue {
1815 match val {
1816 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Array(arr)) } => {
1817 ResolvedValue::Concrete { value: serde_json::json!(arr.len()).into() }
1818 }
1819 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Object(map)) } => {
1820 ResolvedValue::Concrete { value: serde_json::json!(map.len()).into() }
1821 }
1822 ResolvedValue::Enum { variants } => {
1823 let results: Vec<ResolvedValue> = variants.iter().map(length_resolved).collect();
1824 ResolvedValue::Enum { variants: results }
1825 }
1826 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1827 condition: cond.clone(),
1828 if_true: Box::new(length_resolved(t)),
1829 if_false: Box::new(length_resolved(f)),
1830 },
1831 _ => ResolvedValue::Dynamic { reason: "Length with unresolvable argument".into() },
1832 }
1833}
1834
1835fn resolve_getazs_value(
1836 region_val: &ResolvedValue,
1837 pseudo_overrides: &crate::model::PseudoParameterOverrides,
1838) -> ResolvedValue {
1839 match region_val {
1840 ResolvedValue::Concrete { value: v } => {
1841 let region_str = v.as_str().unwrap_or("");
1842 let effective_region = if region_str.is_empty() { pseudo_overrides.region() } else { region_str };
1843 match availability_zones_for_region(effective_region) {
1844 Some(azs) => ResolvedValue::Concrete {
1845 value: serde_json::Value::Array(
1846 azs.iter().map(|z| serde_json::Value::String((*z).to_string())).collect(),
1847 )
1848 .into(),
1849 },
1850 None => ResolvedValue::Dynamic { reason: format!("GetAZs unknown region {}", effective_region) },
1851 }
1852 }
1853 ResolvedValue::Enum { variants } => {
1854 let results: Vec<ResolvedValue> =
1855 variants.iter().map(|v| resolve_getazs_value(v, pseudo_overrides)).collect();
1856 ResolvedValue::Enum { variants: results }
1857 }
1858 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1859 condition: cond.clone(),
1860 if_true: Box::new(resolve_getazs_value(t, pseudo_overrides)),
1861 if_false: Box::new(resolve_getazs_value(f, pseudo_overrides)),
1862 },
1863 _ => ResolvedValue::Dynamic { reason: "GetAZs runtime value".into() },
1864 }
1865}
1866
1867fn resolve_cidr_value(ip_val: &ResolvedValue, count_val: &ResolvedValue, bits_val: &ResolvedValue) -> ResolvedValue {
1868 match (ip_val, count_val, bits_val) {
1869 (
1870 ResolvedValue::Concrete { value: ip },
1871 ResolvedValue::Concrete { value: cnt },
1872 ResolvedValue::Concrete { value: bits },
1873 ) => {
1874 let ip_str = ip.as_str().unwrap_or("");
1875 let count = cnt.as_u64().or_else(|| cnt.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
1876 let cidr_bits = bits.as_u64().or_else(|| bits.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
1877 match calculate_cidr_blocks(ip_str, count, cidr_bits) {
1878 Some(blocks) => ResolvedValue::Concrete {
1879 value: serde_json::Value::Array(blocks.into_iter().map(serde_json::Value::String).collect()).into(),
1880 },
1881 None => ResolvedValue::Dynamic { reason: "Cidr calculation failed".into() },
1882 }
1883 }
1884 _ => ResolvedValue::Dynamic { reason: "Cidr runtime value".into() },
1885 }
1886}
1887
1888fn to_json_string_resolved(val: &ResolvedValue) -> ResolvedValue {
1889 match val {
1890 ResolvedValue::Concrete { value: v } => {
1891 ResolvedValue::Concrete { value: serde_json::Value::String(v.to_string()).into() }
1892 }
1893 ResolvedValue::Enum { variants } => {
1894 let results: Vec<ResolvedValue> = variants.iter().map(to_json_string_resolved).collect();
1895 ResolvedValue::Enum { variants: results }
1896 }
1897 ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1898 condition: cond.clone(),
1899 if_true: Box::new(to_json_string_resolved(t)),
1900 if_false: Box::new(to_json_string_resolved(f)),
1901 },
1902 _ => ResolvedValue::Dynamic { reason: "ToJsonString with unresolvable argument".into() },
1903 }
1904}
1905
1906fn node_matches_param_type(node: &Node, expected: &str) -> bool {
1912 match (expected, node) {
1913 ("integer", Node::Int(_)) => true,
1914 ("number", Node::Int(_) | Node::Float(_)) => true,
1915 ("boolean", Node::Bool(_)) => true,
1916 (_, Node::String(s)) => type_compatible(&serde_json::Value::String(s.clone()), expected),
1917 _ => false,
1918 }
1919}
1920
1921pub fn extract_parameters(ir: &TemplateIR) -> (HashMap<String, ParameterInfo>, Vec<ParseDefect>) {
1922 let mut params = HashMap::new();
1923 let mut diags = Vec::new();
1924 if ir.parameters == NULL_REF {
1925 return (params, diags);
1926 }
1927 let Some(entries) = ir.arena.as_map(ir.parameters) else {
1928 return (params, diags);
1929 };
1930
1931 let e2001 = |msg: String, param_name: &str, prop: Option<&str>, span: SourceSpan| -> ParseDefect {
1932 let path = match prop {
1935 Some(p) => format!("Parameters/{}/{}", param_name, p),
1936 None => format!("Parameters/{}", param_name),
1937 };
1938 let mut d = crate::make_parse_defect("E2001", msg, span);
1939 d.property_path = Some(path);
1940 d
1941 };
1942
1943 const VALID_KEYS: &[&str] = &[
1944 KEY_ALLOWED_PATTERN,
1945 KEY_ALLOWED_VALUES,
1946 KEY_CONSTRAINT_DESCRIPTION,
1947 KEY_DEFAULT,
1948 SECTION_DESCRIPTION,
1949 KEY_MAX_LENGTH,
1950 KEY_MIN_LENGTH,
1951 KEY_MAX_VALUE,
1952 KEY_MIN_VALUE,
1953 KEY_NO_ECHO,
1954 KEY_TYPE,
1955 ];
1956
1957 for (name, node_ref) in entries {
1958 let param_span = ir.arena.span(*node_ref);
1959
1960 let Some(param_map) = ir.arena.as_map(*node_ref) else {
1962 if matches!(ir.arena.node(*node_ref), Node::Null) {
1963 diags.push(e2001(
1964 format!("Parameter '{}': None is not of type 'object'", name),
1965 name,
1966 None,
1967 param_span,
1968 ));
1969 }
1970 continue;
1971 };
1972
1973 let has_type = param_map.iter().any(|(k, _)| k == KEY_TYPE);
1974 if !has_type {
1975 diags.push(e2001(format!("Parameter '{}': 'Type' is a required property", name), name, None, param_span));
1976 }
1977
1978 for (key, val_ref) in param_map {
1979 let val_span = ir.arena.span(*val_ref);
1980 let node = ir.arena.node(*val_ref);
1981
1982 if !VALID_KEYS.contains(&key.as_str()) {
1983 diags.push(e2001(
1984 format!("Parameter '{}': '{}' is not one of {}", name, key, render_str_list(VALID_KEYS)),
1985 name,
1986 Some(key),
1987 val_span,
1988 ));
1989 continue;
1990 }
1991
1992 match key.as_str() {
1993 KEY_TYPE => {
1994 if matches!(node, Node::Null) {
1995 diags.push(e2001(
1996 format!("Parameter '{}': Type must not be null", name),
1997 name,
1998 Some("Type"),
1999 val_span,
2000 ));
2001 } else if !matches!(node, Node::String(_)) {
2002 diags.push(e2001(
2003 format!("Parameter '{}': Type must be a string", name),
2004 name,
2005 Some("Type"),
2006 val_span,
2007 ));
2008 }
2009 }
2010 KEY_ALLOWED_VALUES => {
2011 if !matches!(node, Node::List(_)) {
2012 diags.push(e2001(
2013 format!("Parameter '{}': AllowedValues must be an array", name),
2014 name,
2015 Some("AllowedValues"),
2016 val_span,
2017 ));
2018 }
2019 }
2020 KEY_NO_ECHO => {
2021 if matches!(node, Node::Null) {
2022 diags.push(e2001(
2023 format!("Parameter '{}': NoEcho must not be null", name),
2024 name,
2025 Some("NoEcho"),
2026 val_span,
2027 ));
2028 } else if !node_matches_param_type(node, "boolean") {
2029 diags.push(e2001(
2030 format!("Parameter '{}': NoEcho must be a boolean", name),
2031 name,
2032 Some("NoEcho"),
2033 val_span,
2034 ));
2035 }
2036 }
2037 KEY_MIN_VALUE | KEY_MAX_VALUE => {
2038 if matches!(node, Node::Null) {
2039 diags.push(e2001(
2040 format!("Parameter '{}': {} must not be null", name, key),
2041 name,
2042 Some(key),
2043 val_span,
2044 ));
2045 } else if !node_matches_param_type(node, "number") {
2046 diags.push(e2001(
2047 format!("Parameter '{}': {} must be a number", name, key),
2048 name,
2049 Some(key),
2050 val_span,
2051 ));
2052 }
2053 }
2054 KEY_MIN_LENGTH | KEY_MAX_LENGTH => {
2055 if matches!(node, Node::Null) {
2056 diags.push(e2001(
2057 format!("Parameter '{}': {} must not be null", name, key),
2058 name,
2059 Some(key),
2060 val_span,
2061 ));
2062 } else if !node_matches_param_type(node, "integer") {
2063 diags.push(e2001(
2064 format!("Parameter '{}': {} must be an integer", name, key),
2065 name,
2066 Some(key),
2067 val_span,
2068 ));
2069 }
2070 }
2071 KEY_DEFAULT | SECTION_DESCRIPTION | KEY_ALLOWED_PATTERN | KEY_CONSTRAINT_DESCRIPTION => {
2072 if matches!(node, Node::Null) {
2073 diags.push(e2001(
2074 format!("Parameter '{}': {} must not be null", name, key),
2075 name,
2076 Some(key),
2077 val_span,
2078 ));
2079 } else if matches!(node, Node::Map(_) | Node::Intrinsic(_)) {
2080 diags.push(e2001(
2081 format!("Parameter '{}': {} must be a string", name, key),
2082 name,
2083 Some(key),
2084 val_span,
2085 ));
2086 }
2087 }
2088 _ => {}
2089 }
2090 }
2091
2092 let param_type = param_map
2093 .iter()
2094 .find(|(k, _)| k == KEY_TYPE)
2095 .and_then(|(_, v)| ir.arena.as_str(*v))
2096 .unwrap_or(PARAM_TYPE_STRING)
2097 .to_string();
2098
2099 let default = param_map.iter().find(|(k, _)| k == KEY_DEFAULT).and_then(|(_, v)| match ir.arena.node(*v) {
2100 Node::String(s) => Some(s.clone()),
2101 Node::Int(i) => Some(i.to_string()),
2102 Node::Float(f) => Some(f.to_string()),
2103 Node::Bool(b) => Some(b.to_string()),
2104 _ => None,
2105 });
2106
2107 let allowed_values =
2108 param_map.iter().find(|(k, _)| k == KEY_ALLOWED_VALUES).and_then(|(_, v)| ir.arena.as_list(*v)).map(
2109 |items| {
2110 items
2111 .iter()
2112 .filter_map(|r| match ir.arena.node(*r) {
2113 Node::String(s) => Some(s.clone()),
2114 Node::Int(i) => Some(i.to_string()),
2115 Node::Float(f) => Some(f.to_string()),
2116 Node::Bool(b) => Some(b.to_string()),
2117 _ => None,
2118 })
2119 .collect()
2120 },
2121 );
2122
2123 let description = param_map
2124 .iter()
2125 .find(|(k, _)| k == SECTION_DESCRIPTION)
2126 .and_then(|(_, v)| ir.arena.as_str(*v))
2127 .map(|s| s.to_string());
2128
2129 let no_echo = param_map
2130 .iter()
2131 .find(|(k, _)| k == KEY_NO_ECHO)
2132 .and_then(|(_, v)| match ir.arena.node(*v) {
2133 Node::Bool(b) => Some(*b),
2134 Node::String(s) => Some(s.eq_ignore_ascii_case("true")),
2135 _ => None,
2136 })
2137 .unwrap_or(false);
2138
2139 let allowed_pattern = param_map
2140 .iter()
2141 .find(|(k, _)| k == KEY_ALLOWED_PATTERN)
2142 .and_then(|(_, v)| ir.arena.as_str(*v))
2143 .map(|s| s.to_string());
2144
2145 let min_length =
2146 param_map.iter().find(|(k, _)| k == KEY_MIN_LENGTH).and_then(|(_, v)| match ir.arena.node(*v) {
2147 Node::Int(i) => Some(*i as u64),
2148 Node::String(s) => s.parse().ok(),
2149 _ => None,
2150 });
2151
2152 let max_length =
2153 param_map.iter().find(|(k, _)| k == KEY_MAX_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 min_value = param_map.iter().find(|(k, _)| k == KEY_MIN_VALUE).and_then(|(_, v)| match ir.arena.node(*v) {
2160 Node::Int(i) => Some(*i),
2161 Node::String(s) => s.parse().ok(),
2162 _ => None,
2163 });
2164
2165 let max_value = param_map.iter().find(|(k, _)| k == KEY_MAX_VALUE).and_then(|(_, v)| match ir.arena.node(*v) {
2166 Node::Int(i) => Some(*i),
2167 Node::String(s) => s.parse().ok(),
2168 _ => None,
2169 });
2170
2171 let allowed_pattern_valid = allowed_pattern.as_deref().map(is_service_valid);
2172 let is_comma_delimited = param_type == PARAM_TYPE_COMMA_DELIMITED_LIST || param_type.starts_with("List<");
2173 let default_matches_allowed_pattern = match (&allowed_pattern, &default) {
2174 (Some(pattern), Some(value)) => default_matches_pattern(pattern, value, is_comma_delimited),
2175 _ => None,
2176 };
2177
2178 params.insert(
2179 name.clone(),
2180 ParameterInfo {
2181 param_type,
2182 default,
2183 allowed_values,
2184 allowed_pattern,
2185 min_length,
2186 max_length,
2187 min_value,
2188 max_value,
2189 description,
2190 no_echo,
2191 allowed_pattern_valid,
2192 default_matches_allowed_pattern,
2193 },
2194 );
2195 }
2196 let with_allowed = params.values().filter(|p| p.allowed_values.is_some()).count();
2197 let with_defaults = params.values().filter(|p| p.default.is_some()).count();
2198 debug!(
2199 "Extracted {} parameters ({} with AllowedValues, {} with Default)",
2200 params.len(),
2201 with_allowed,
2202 with_defaults
2203 );
2204 (params, diags)
2205}
2206
2207pub fn extract_mappings(ir: &TemplateIR) -> (MappingData, Vec<ParseDefect>) {
2208 let mut mappings = MappingData::new();
2209 let mut diagnostics = Vec::new();
2210 if ir.mappings == NULL_REF {
2211 return (mappings, diagnostics);
2212 }
2213 let Some(entries) = ir.arena.as_map(ir.mappings) else {
2214 return (mappings, diagnostics);
2215 };
2216 for (map_name, map_ref) in entries {
2217 let Some(level1) = ir.arena.as_map(*map_ref) else {
2218 diagnostics.push(crate::make_parse_defect_at(
2219 "F0017",
2220 format!("Mapping '{}' must be a map, not a scalar value", map_name),
2221 ir.arena.span(*map_ref),
2222 &format!("Mappings/{}", map_name),
2223 ));
2224 continue;
2225 };
2226 let mut l1_map = HashMap::new();
2227 for (k1, k1_ref) in level1 {
2228 let Some(level2) = ir.arena.as_map(*k1_ref) else {
2229 diagnostics.push(crate::make_parse_defect_at(
2230 "F0017",
2231 format!("Mapping '{}' second level key '{}' must be a map", map_name, k1),
2232 ir.arena.span(*k1_ref),
2233 &format!("Mappings/{}/{}", map_name, k1),
2234 ));
2235 continue;
2236 };
2237 let mut l2_map = HashMap::new();
2238 for (k2, k2_ref) in level2 {
2239 let val = node_to_json(&ir.arena, *k2_ref);
2240 l2_map.insert(k2.clone(), val);
2241 }
2242 l1_map.insert(k1.clone(), l2_map);
2243 }
2244 mappings.insert(map_name.clone(), l1_map);
2245 }
2246 let total_entries: usize = mappings.values().map(|l1| l1.values().map(|l2| l2.len()).sum::<usize>()).sum();
2247 debug!("Extracted {} mappings with {} total leaf entries", mappings.len(), total_entries);
2248 (mappings, diagnostics)
2249}
2250
2251pub fn node_to_json(arena: &Arena, node_ref: NodeRef) -> serde_json::Value {
2252 if node_ref == NULL_REF {
2253 return serde_json::Value::Null;
2254 }
2255 match arena.node(node_ref) {
2256 Node::Null => serde_json::Value::Null,
2257 Node::Bool(b) => serde_json::Value::Bool(*b),
2258 Node::Int(i) => serde_json::json!(*i),
2259 Node::Float(f) => serde_json::json!(*f),
2260 Node::String(s) => serde_json::Value::String(s.clone()),
2261 Node::List(items) => serde_json::Value::Array(items.iter().map(|r| node_to_json(arena, *r)).collect()),
2262 Node::Map(entries) => {
2263 let mut map = serde_json::Map::new();
2264 for (k, v) in entries {
2265 map.insert(k.clone(), node_to_json(arena, *v));
2266 }
2267 serde_json::Value::Object(map)
2268 }
2269 Node::Intrinsic(intrinsic) => {
2270 serde_json::json!({MARKER_INTRINSIC: intrinsic_name(intrinsic)})
2271 }
2272 }
2273}
2274
2275fn intrinsic_name(intrinsic: &IntrinsicFn) -> &'static str {
2276 match intrinsic {
2277 IntrinsicFn::Ref(_) => TAG_REF,
2278 IntrinsicFn::GetAtt(_, _) => TAG_GET_ATT,
2279 IntrinsicFn::If(_, _, _) => TAG_IF,
2280 IntrinsicFn::IfExpr(_, _, _) => TAG_IF_EXPR,
2281 IntrinsicFn::FindInMap(_, _, _, _) => TAG_FIND_IN_MAP,
2282 IntrinsicFn::Sub(_, _) => TAG_SUB,
2283 IntrinsicFn::Join(_, _) => TAG_JOIN,
2284 IntrinsicFn::Select(_, _) => TAG_SELECT,
2285 IntrinsicFn::Split(_, _) => TAG_SPLIT,
2286 IntrinsicFn::Base64(_) => TAG_BASE64,
2287 IntrinsicFn::ImportValue(_) => TAG_IMPORT_VALUE,
2288 IntrinsicFn::GetStackOutput(_) => TAG_GET_STACK_OUTPUT,
2289 IntrinsicFn::Transform(_, _) => TAG_TRANSFORM,
2290 IntrinsicFn::GetAZs(_) => TAG_GET_AZS,
2291 IntrinsicFn::Cidr(_, _, _) => TAG_CIDR,
2292 IntrinsicFn::And(_) => TAG_AND,
2293 IntrinsicFn::Or(_) => TAG_OR,
2294 IntrinsicFn::Not(_) => TAG_NOT,
2295 IntrinsicFn::Equals(_, _) => TAG_EQUALS,
2296 IntrinsicFn::ToJsonString(_) => TAG_TO_JSON_STRING,
2297 IntrinsicFn::Length(_) => TAG_LENGTH,
2298 IntrinsicFn::ForEach(_, _, _, _) => TAG_FOR_EACH,
2299 IntrinsicFn::ValueOf(_, _) => TAG_VALUE_OF,
2300 IntrinsicFn::ValueOfAll(_, _) => TAG_VALUE_OF_ALL,
2301 IntrinsicFn::RefAll(_) => TAG_REF_ALL,
2302 IntrinsicFn::Contains(_, _) => TAG_CONTAINS,
2303 IntrinsicFn::EachMemberEquals(_, _) => TAG_EACH_MEMBER_EQUALS,
2304 IntrinsicFn::EachMemberIn(_, _) => TAG_EACH_MEMBER_IN,
2305 }
2306}
2307
2308#[cfg(test)]
2309mod tests {
2310 use super::*;
2311 use crate::parser;
2312
2313 #[test]
2314 fn param_number_whole_value_resolves_to_integer() {
2315 assert_eq!(param_string_to_json("30", "Number"), serde_json::json!(30));
2318 assert!(param_string_to_json("30", "Number").is_i64(), "whole number must be an integer");
2319 assert_eq!(param_string_to_json("1.5", "Number"), serde_json::json!(1.5));
2320 assert_eq!(param_string_to_json("not-a-number", "Number"), serde_json::json!("not-a-number"));
2321 }
2322
2323 #[test]
2324 fn resolve_ref_param_with_allowed_values() {
2325 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
2326 let ir = parser::parse(input.as_bytes()).unwrap();
2327 let (params, _) = extract_parameters(&ir);
2328 let resource_ids: HashSet<String> = ["R".to_string()].into_iter().collect();
2329 let (mappings, _) = extract_mappings(&ir);
2330 let no_param_overrides = HashMap::new();
2331 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2332 let mut resolver =
2333 Resolver::new(&ir.arena, ¶ms, &mappings, resource_ids, &no_param_overrides, &no_pseudo_overrides);
2334 let res_map = ir.arena.as_map(ir.resources).unwrap();
2335 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2336 let v_ref = ir.arena.map_get(props, "V").unwrap();
2337 let result = resolver.resolve_node(v_ref);
2338 match result {
2339 ResolvedValue::Enum { variants: vals } => assert_eq!(vals.len(), 2),
2340 other => panic!("Expected Enum, got {:?}", other),
2341 }
2342 }
2343
2344 #[test]
2345 fn resolve_ref_pseudo_params() {
2346 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
2347 let ir = parser::parse(input.as_bytes()).unwrap();
2348 let (params, _) = extract_parameters(&ir);
2349 let (mappings, _) = extract_mappings(&ir);
2350 let no_param_overrides = HashMap::new();
2351 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2352 let mut resolver = Resolver::new(
2353 &ir.arena,
2354 ¶ms,
2355 &mappings,
2356 ["R".to_string()].into_iter().collect(),
2357 &no_param_overrides,
2358 &no_pseudo_overrides,
2359 );
2360 let res_map = ir.arena.as_map(ir.resources).unwrap();
2361 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2362 let v_ref = ir.arena.map_get(props, "V").unwrap();
2363 let result = resolver.resolve_node(v_ref);
2364 match result {
2365 ResolvedValue::Concrete { value: JsonValue(serde_json::Value::String(s)) } => {
2366 assert_eq!(s, DEFAULT_REGION);
2367 }
2368 other => panic!("Expected Concrete(\"us-east-1\"), got {:?}", other),
2369 }
2370 }
2371
2372 #[test]
2373 fn resolve_if() {
2374 let input = r#"{"Conditions":{"C":{"Fn::Equals":["a","a"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::If":["C",100,20]}}}}}"#;
2375 let ir = parser::parse(input.as_bytes()).unwrap();
2376 let (params, _) = extract_parameters(&ir);
2377 let (mappings, _) = extract_mappings(&ir);
2378 let no_param_overrides = HashMap::new();
2379 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2380 let mut resolver = Resolver::new(
2381 &ir.arena,
2382 ¶ms,
2383 &mappings,
2384 ["R".to_string()].into_iter().collect(),
2385 &no_param_overrides,
2386 &no_pseudo_overrides,
2387 );
2388 let res_map = ir.arena.as_map(ir.resources).unwrap();
2389 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2390 let v_ref = ir.arena.map_get(props, "V").unwrap();
2391 let result = resolver.resolve_node(v_ref);
2392 assert!(matches!(result, ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ }));
2393 }
2394
2395 #[test]
2396 fn resolve_join_concrete() {
2397 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["a","b","c"]]}}}}}"#;
2398 let ir = parser::parse(input.as_bytes()).unwrap();
2399 let (params, _) = extract_parameters(&ir);
2400 let (mappings, _) = extract_mappings(&ir);
2401 let no_param_overrides = HashMap::new();
2402 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2403 let mut resolver = Resolver::new(
2404 &ir.arena,
2405 ¶ms,
2406 &mappings,
2407 ["R".to_string()].into_iter().collect(),
2408 &no_param_overrides,
2409 &no_pseudo_overrides,
2410 );
2411 let res_map = ir.arena.as_map(ir.resources).unwrap();
2412 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2413 let v_ref = ir.arena.map_get(props, "V").unwrap();
2414 let result = resolver.resolve_node(v_ref);
2415 match result {
2416 ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "a-b-c"),
2417 other => panic!("Expected Concrete, got {:?}", other),
2418 }
2419 }
2420
2421 #[test]
2422 fn resolve_ref_param_override_bypasses_allowed_values() {
2423 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
2424 let ir = parser::parse(input.as_bytes()).unwrap();
2425 let (params, _) = extract_parameters(&ir);
2426 let (mappings, _) = extract_mappings(&ir);
2427 let param_overrides = [("Env".to_string(), "staging".to_string())].into_iter().collect();
2428 let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2429 let mut resolver = Resolver::new(
2430 &ir.arena,
2431 ¶ms,
2432 &mappings,
2433 ["R".to_string()].into_iter().collect(),
2434 ¶m_overrides,
2435 &no_pseudo_overrides,
2436 );
2437 let res_map = ir.arena.as_map(ir.resources).unwrap();
2438 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2439 let v_ref = ir.arena.map_get(props, "V").unwrap();
2440 let result = resolver.resolve_node(v_ref);
2441 match result {
2442 ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "staging"),
2443 other => panic!("Expected Concrete(\"staging\"), got {:?}", other),
2444 }
2445 }
2446
2447 #[test]
2448 fn resolve_ref_pseudo_param_override() {
2449 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
2450 let ir = parser::parse(input.as_bytes()).unwrap();
2451 let (params, _) = extract_parameters(&ir);
2452 let (mappings, _) = extract_mappings(&ir);
2453 let no_param_overrides = HashMap::new();
2454 let pseudo_overrides =
2455 crate::model::PseudoParameterOverrides { region: Some("us-west-2".to_string()), ..Default::default() };
2456 let mut resolver = Resolver::new(
2457 &ir.arena,
2458 ¶ms,
2459 &mappings,
2460 ["R".to_string()].into_iter().collect(),
2461 &no_param_overrides,
2462 &pseudo_overrides,
2463 );
2464 let res_map = ir.arena.as_map(ir.resources).unwrap();
2465 let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2466 let v_ref = ir.arena.map_get(props, "V").unwrap();
2467 let result = resolver.resolve_node(v_ref);
2468 match result {
2469 ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "us-west-2"),
2470 other => panic!("Expected Concrete(\"us-west-2\"), got {:?}", other),
2471 }
2472 }
2473
2474 #[test]
2475 fn resolve_sub_concrete_interpolation() {
2476 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"prefix-${AWS::Region}-suffix"}}}}}"#;
2477 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2478 match model.resolve("R", "Properties.V") {
2479 Some(ResolvedValue::Concrete { value: v }) => {
2480 assert_eq!(v.as_str().unwrap(), "prefix-us-east-1-suffix");
2481 }
2482 other => panic!("Expected Concrete, got {:?}", other),
2483 }
2484 }
2485
2486 #[test]
2487 fn resolve_sub_with_enum_produces_enum() {
2488 let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"app-${Env}"}}}}}"#;
2489 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2490 match model.resolve("R", "Properties.V") {
2491 Some(ResolvedValue::Enum { variants: vals }) => {
2492 assert_eq!(vals.len(), 2);
2493 let strs: Vec<String> = vals
2494 .iter()
2495 .filter_map(|v| match v {
2496 ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2497 _ => None,
2498 })
2499 .collect();
2500 assert!(strs.contains(&"app-dev".to_string()));
2501 assert!(strs.contains(&"app-prod".to_string()));
2502 }
2503 other => panic!("Expected Enum, got {:?}", other),
2504 }
2505 }
2506
2507 #[test]
2508 fn resolve_sub_no_variables_is_redundant() {
2509 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"no-vars-here"}}}}}"#;
2510 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2511 let r = model.resource("R").unwrap();
2512 assert!(!r.diagnostics.redundant_subs.is_empty());
2513 }
2514
2515 #[test]
2516 fn resolve_sub_single_variable_is_simple() {
2517 let input = r#"{"Parameters":{"P":{"Type":"String","Default":"val"}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"${P}"}}}}}"#;
2518 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2519 let r = model.resource("R").unwrap();
2520 assert!(!r.diagnostics.simple_subs.is_empty());
2521 }
2522
2523 #[test]
2524 fn resolve_split_concrete() {
2525 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Split":[",","a,b,c"]}}}}}"#;
2526 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2527 match model.resolve("R", "Properties.V") {
2528 Some(ResolvedValue::Concrete { value: v }) => {
2529 let arr = v.as_array().unwrap();
2530 assert_eq!(arr.len(), 3);
2531 assert_eq!(arr[0], "a");
2532 assert_eq!(arr[1], "b");
2533 assert_eq!(arr[2], "c");
2534 }
2535 other => panic!("Expected Concrete array, got {:?}", other),
2536 }
2537 }
2538
2539 #[test]
2540 fn resolve_base64_concrete() {
2541 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Base64":"hello"}}}}}"#;
2542 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2543 match model.resolve("R", "Properties.V") {
2544 Some(ResolvedValue::Concrete { value: v }) => {
2545 assert_eq!(v.as_str().unwrap(), "aGVsbG8=");
2546 }
2547 other => panic!("Expected Concrete base64, got {:?}", other),
2548 }
2549 }
2550
2551 #[test]
2552 fn resolve_select_concrete() {
2553 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[1,["a","b","c"]]}}}}}"#;
2554 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2555 match model.resolve("R", "Properties.V") {
2556 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "b"),
2557 other => panic!("Expected Concrete(\"b\"), got {:?}", other),
2558 }
2559 }
2560
2561 #[test]
2562 fn resolve_select_out_of_bounds() {
2563 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[5,["a","b"]]}}}}}"#;
2564 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2565 match model.resolve("R", "Properties.V") {
2566 Some(ResolvedValue::Dynamic { reason: msg }) => assert!(msg.contains("out of bounds")),
2567 other => panic!("Expected Dynamic, got {:?}", other),
2568 }
2569 }
2570
2571 #[test]
2572 fn resolve_getazs_default_region() {
2573 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAZs":""}}}}}"#;
2574 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2575 match model.resolve("R", "Properties.V") {
2576 Some(ResolvedValue::Concrete { value: v }) => {
2577 let arr = v.as_array().unwrap();
2578 assert!(arr.len() >= 3);
2579 assert!(arr[0].as_str().unwrap().starts_with("us-east-1"));
2580 }
2581 other => panic!("Expected Concrete AZ array, got {:?}", other),
2582 }
2583 }
2584
2585 #[test]
2586 fn resolve_to_json_string() {
2587 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"key":"value"}}}}}}"#;
2588 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2589 match model.resolve("R", "Properties.V") {
2590 Some(ResolvedValue::Concrete { value: v }) => {
2591 let s = v.as_str().unwrap();
2592 assert!(s.contains("key"));
2593 assert!(s.contains("value"));
2594 }
2595 other => panic!("Expected Concrete JSON string, got {:?}", other),
2596 }
2597 }
2598
2599 #[test]
2600 fn resolve_length_array() {
2601 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Length":["a","b","c"]}}}}}"#;
2602 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2603 match model.resolve("R", "Properties.V") {
2604 Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_i64().unwrap(), 3),
2605 other => panic!("Expected Concrete(3), got {:?}", other),
2606 }
2607 }
2608
2609 #[test]
2610 fn resolve_cidr_concrete() {
2611 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Cidr":["10.0.0.0/16","2","8"]}}}}}"#;
2612 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2613 match model.resolve("R", "Properties.V") {
2614 Some(ResolvedValue::Concrete { value: v }) => {
2615 let arr = v.as_array().unwrap();
2616 assert_eq!(arr.len(), 2);
2617 assert!(arr[0].as_str().unwrap().contains("/24"));
2618 }
2619 other => panic!("Expected Concrete CIDR array, got {:?}", other),
2620 }
2621 }
2622
2623 #[test]
2624 fn resolve_import_value_is_typed_dynamic() {
2625 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":"StackExport"}}}}}"#;
2626 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2627 match model.resolve("R", "Properties.V") {
2628 Some(ResolvedValue::TypedDynamic { reason, param_type: t }) => {
2629 assert_eq!(t, "String");
2630 assert!(reason.contains("StackExport"), "reason should carry the export name, got {reason:?}");
2632 }
2633 other => panic!("Expected TypedDynamic, got {:?}", other),
2634 }
2635 }
2636
2637 #[test]
2638 fn resolve_import_value_distinct_exports_differ() {
2639 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{
2640 "A":{"Fn::ImportValue":"ExportOne"},
2641 "B":{"Fn::ImportValue":"ExportTwo"}
2642 }}}}"#;
2643 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2644 let a = model.resolve("R", "Properties.A").cloned();
2645 let b = model.resolve("R", "Properties.B").cloned();
2646 match (a, b) {
2647 (
2648 Some(ResolvedValue::TypedDynamic { reason: ra, .. }),
2649 Some(ResolvedValue::TypedDynamic { reason: rb, .. }),
2650 ) => assert_ne!(ra, rb, "distinct exports must produce distinct symbolic values"),
2651 other => panic!("Expected two TypedDynamic, got {:?}", other),
2652 }
2653 }
2654
2655 #[test]
2656 fn resolve_get_stack_output_carries_source_identity() {
2657 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetStackOutput":{
2658 "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2659 }}}}}}"#;
2660 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2661 match model.resolve("R", "Properties.V") {
2662 Some(ResolvedValue::Dynamic { reason }) => {
2663 for field in ["VpcStack", "ap-northeast-1", "PublicSubnetOne"] {
2665 assert!(reason.contains(field), "reason should carry {field}, got {reason:?}");
2666 }
2667 }
2668 other => panic!("Expected Dynamic, got {:?}", other),
2669 }
2670 }
2671
2672 #[test]
2673 fn resolve_get_stack_output_distinct_sources_differ() {
2674 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{
2675 "Baseline":{"Fn::GetStackOutput":{
2676 "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2677 }},
2678 "OtherStack":{"Fn::GetStackOutput":{
2679 "StackName":"OtherVpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2680 }},
2681 "OtherRegion":{"Fn::GetStackOutput":{
2682 "StackName":"VpcStack","Region":"us-east-1","OutputName":"PublicSubnetOne"
2683 }},
2684 "OtherOutput":{"Fn::GetStackOutput":{
2685 "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetTwo"
2686 }},
2687 "Reordered":{"Fn::GetStackOutput":{
2688 "OutputName":"PublicSubnetOne","StackName":"VpcStack","Region":"ap-northeast-1"
2689 }},
2690 "OtherRole":{"Fn::GetStackOutput":{
2691 "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne",
2692 "RoleArn":"arn:aws:iam::444455556666:role/Lookup"
2693 }}
2694 }}}}"#;
2695 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2696 let source_of = |property: &str| match model.resolve("R", &format!("Properties.{property}")) {
2697 Some(ResolvedValue::Dynamic { reason }) => reason.clone(),
2698 other => panic!("Expected Dynamic for {property}, got {other:?}"),
2699 };
2700
2701 let baseline = source_of("Baseline");
2702 for distinct in ["OtherStack", "OtherRegion", "OtherOutput"] {
2703 assert_ne!(baseline, source_of(distinct), "{distinct} must not collapse onto the baseline output");
2704 }
2705 assert_eq!(baseline, source_of("Reordered"), "argument order must not make the same output distinct");
2706 assert_eq!(baseline, source_of("OtherRole"), "RoleArn must not make the same output distinct");
2709 }
2710
2711 #[test]
2712 fn embedded_dynamic_reference_is_opaque() {
2713 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"prefix-{{resolve:ssm:/my/param}}"}}}}"#;
2716 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2717 match model.resolve("R", "Properties.V") {
2718 Some(ResolvedValue::TypedDynamic { reason, param_type: t }) => {
2719 assert_eq!(t, "String");
2720 assert!(
2721 reason.contains("{{resolve:ssm:/my/param}}"),
2722 "reason should carry the literal, got {reason:?}"
2723 );
2724 }
2725 other => panic!("Expected TypedDynamic, got {:?}", other),
2726 }
2727 }
2728
2729 #[test]
2730 fn sub_producing_dynamic_reference_is_opaque() {
2731 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"${AWS::Region}-{{resolve:ssm:/my/param}}"}}}}}"#;
2734 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2735 match model.resolve("R", "Properties.V") {
2736 Some(ResolvedValue::TypedDynamic { reason, .. }) => {
2737 assert!(
2738 reason.contains("{{resolve:ssm:/my/param}}"),
2739 "reason should carry the literal, got {reason:?}"
2740 );
2741 }
2742 other => panic!("Expected TypedDynamic, got {:?}", other),
2743 }
2744 }
2745
2746 #[test]
2747 fn join_producing_dynamic_reference_is_opaque() {
2748 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["prefix","{{resolve:ssm:/my/param}}"]]}}}}}"#;
2752 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2753 let resolved = model.resolve("R", "Properties.V").expect("V should resolve");
2754 assert!(
2755 crate::resolved_value::contains_dynamic_resolved(resolved),
2756 "Fn::Join embedding a dynamic reference must be opaque, got {resolved:?}"
2757 );
2758 assert!(
2759 !matches!(resolved, ResolvedValue::Concrete { .. }),
2760 "the join result must not be a concrete literal, got {resolved:?}"
2761 );
2762 }
2763
2764 #[test]
2765 fn select_producing_dynamic_reference_is_opaque() {
2766 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[1,["a","{{resolve:ssm:/my/param}}"]]}}}}}"#;
2768 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2769 match model.resolve("R", "Properties.V") {
2770 Some(ResolvedValue::TypedDynamic { reason, .. }) => {
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 nested_dynamic_reference_stays_inside_list() {
2782 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":["plain","{{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::List { items }) => {
2788 assert_eq!(items.len(), 2);
2789 assert!(matches!(items[0], ResolvedValue::Concrete { .. }));
2790 assert!(
2791 matches!(items[1], ResolvedValue::TypedDynamic { .. }),
2792 "the embedded reference element must be opaque, got {:?}",
2793 items[1]
2794 );
2795 }
2796 other => panic!("Expected List with an opaque element, got {:?}", other),
2797 }
2798 }
2799
2800 #[test]
2801 fn malformed_dynamic_reference_with_space_stays_concrete() {
2802 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"{{ resolve:ssm:/my/param}}"}}}}"#;
2806 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2807 match model.resolve("R", "Properties.V") {
2808 Some(ResolvedValue::Concrete { value: v }) => {
2809 assert_eq!(v.as_str().unwrap(), "{{ resolve:ssm:/my/param}}");
2810 }
2811 other => panic!("Expected Concrete, got {:?}", other),
2812 }
2813 }
2814
2815 #[test]
2816 fn comma_delimited_list_default_resolves_to_array() {
2817 let input = r#"{"Parameters":{"P":{"Type":"CommaDelimitedList","Default":"GET, PUT ,POST"}},
2818 "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"P"}}}}}"#;
2819 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2820 match model.resolve("R", "Properties.V") {
2821 Some(ResolvedValue::Concrete { value }) => {
2822 assert_eq!(
2823 **value,
2824 serde_json::json!(["GET", "PUT", "POST"]),
2825 "default must split into a trimmed array"
2826 );
2827 }
2828 other => panic!("Expected Concrete array, got {:?}", other),
2829 }
2830 }
2831
2832 #[test]
2833 fn resolve_no_value_is_null() {
2834 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::NoValue"}}}}}"#;
2835 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2836 match model.resolve("R", "Properties.V") {
2837 Some(ResolvedValue::Concrete { value: v }) => assert!(v.is_null()),
2838 other => panic!("Expected Concrete(null), got {:?}", other),
2839 }
2840 assert!(model.is_from_intrinsic("R", "Properties.V"));
2841 }
2842
2843 #[test]
2844 fn extract_parameters_with_constraints() {
2845 let input = r#"{"Parameters":{"P":{"Type":"String","MinLength":1,"MaxLength":100,"AllowedPattern":"^[a-z]+$","Default":"abc","NoEcho":true}},"Resources":{"R":{"Type":"T"}}}"#;
2846 let ir = parser::parse(input.as_bytes()).unwrap();
2847 let (params, _) = extract_parameters(&ir);
2848 let p = ¶ms["P"];
2849 assert_eq!(p.param_type, "String");
2850 assert_eq!(p.default.as_deref(), Some("abc"));
2851 assert_eq!(p.min_length, Some(1));
2852 assert_eq!(p.max_length, Some(100));
2853 assert_eq!(p.allowed_pattern.as_deref(), Some("^[a-z]+$"));
2854 assert!(p.no_echo);
2855 }
2856
2857 #[test]
2858 fn extract_parameters_number_type() {
2859 let input = r#"{"Parameters":{"N":{"Type":"Number","Default":"42","MinValue":0,"MaxValue":100}},"Resources":{"R":{"Type":"T"}}}"#;
2860 let ir = parser::parse(input.as_bytes()).unwrap();
2861 let (params, _) = extract_parameters(&ir);
2862 let n = ¶ms["N"];
2863 assert_eq!(n.param_type, "Number");
2864 assert_eq!(n.default.as_deref(), Some("42"));
2865 assert_eq!(n.min_value, Some(0));
2866 assert_eq!(n.max_value, Some(100));
2867 }
2868
2869 #[test]
2870 fn extract_mappings_three_level() {
2871 let input = r#"{"Mappings":{"M":{"k1":{"k2":"val"}}},"Resources":{"R":{"Type":"T"}}}"#;
2872 let ir = parser::parse(input.as_bytes()).unwrap();
2873 let (mappings, diags) = extract_mappings(&ir);
2874 assert!(diags.is_empty());
2875 assert_eq!(mappings["M"]["k1"]["k2"], serde_json::json!("val"));
2876 }
2877
2878 #[test]
2879 fn extract_mappings_invalid_structure_produces_diagnostic() {
2880 let input = r#"{"Mappings":{"M":"not-a-map"},"Resources":{"R":{"Type":"T"}}}"#;
2881 let ir = parser::parse(input.as_bytes()).unwrap();
2882 let (_, diags) = extract_mappings(&ir);
2883 assert!(!diags.is_empty());
2884 assert_eq!(diags[0].rule_id, "F0017");
2885 }
2886
2887 #[test]
2888 fn resolve_findinmap_with_enum_key() {
2889 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"]}}}}}"#;
2890 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2891 match model.resolve("R", "Properties.V") {
2892 Some(ResolvedValue::Enum { variants: vals }) => {
2893 assert_eq!(vals.len(), 2);
2894 let strs: Vec<String> = vals
2895 .iter()
2896 .filter_map(|v| match v {
2897 ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2898 _ => None,
2899 })
2900 .collect();
2901 assert!(strs.contains(&"d".to_string()));
2902 assert!(strs.contains(&"p".to_string()));
2903 }
2904 other => panic!("Expected Enum, got {:?}", other),
2905 }
2906 }
2907
2908 #[test]
2909 fn resolve_hardcoded_partition_arn_tracked() {
2910 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"arn:aws:s3:::my-bucket"}}}}}"#;
2911 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2912 let r = model.resource("R").unwrap();
2913 assert!(!r.diagnostics.hardcoded_partition_arns.is_empty());
2914 }
2915
2916 #[test]
2917 fn resolve_getatt_produces_reference() {
2918 let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAtt":["Other","Arn"]}}},"Other":{"Type":"T2"}}}"#;
2919 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2920 match model.resolve("R", "Properties.V") {
2921 Some(ResolvedValue::Reference { target: t, kind: RefKind::GetAtt { attr: a } }) => {
2922 assert_eq!(t, "Other");
2923 assert_eq!(a, "Arn");
2924 }
2925 other => panic!("Expected Reference(GetAtt), got {:?}", other),
2926 }
2927 }
2928
2929 #[test]
2930 fn findinmap_concrete_first_key_enum_second_key_produces_enum() {
2931 let input = r#"{
2932 "Parameters":{"K2":{"Type":"String","AllowedValues":["a","b"]}},
2933 "Mappings":{"M":{"x":{"a":"va","b":"vb"}}},
2934 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M","x",{"Ref":"K2"}]}}}}
2935 }"#;
2936 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2937 match model.resolve("R", "Properties.V") {
2938 Some(ResolvedValue::Enum { variants }) => {
2939 assert_eq!(variants.len(), 2);
2940 let strs: Vec<String> = variants
2941 .iter()
2942 .filter_map(|v| match v {
2943 ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2944 _ => None,
2945 })
2946 .collect();
2947 assert!(strs.contains(&"va".to_string()));
2948 assert!(strs.contains(&"vb".to_string()));
2949 }
2950 other => panic!("Expected Enum, got {:?}", other),
2951 }
2952 }
2953
2954 #[test]
2955 fn findinmap_enum_first_key_enum_second_key_produces_flattened_enum() {
2956 let input = r#"{
2957 "Parameters":{
2958 "K1":{"Type":"String","AllowedValues":["r1","r2"]},
2959 "K2":{"Type":"String","AllowedValues":["a","b"]}
2960 },
2961 "Mappings":{"M":{"r1":{"a":"r1a","b":"r1b"},"r2":{"a":"r2a","b":"r2b"}}},
2962 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M",{"Ref":"K1"},{"Ref":"K2"}]}}}}
2963 }"#;
2964 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2965 match model.resolve("R", "Properties.V") {
2966 Some(ResolvedValue::Enum { variants }) => {
2967 assert_eq!(variants.len(), 4);
2969 let strs: Vec<String> = variants
2970 .iter()
2971 .filter_map(|v| match v {
2972 ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2973 _ => None,
2974 })
2975 .collect();
2976 assert!(strs.contains(&"r1a".to_string()));
2977 assert!(strs.contains(&"r1b".to_string()));
2978 assert!(strs.contains(&"r2a".to_string()));
2979 assert!(strs.contains(&"r2b".to_string()));
2980 }
2981 other => panic!("Expected Enum with 4 variants, got {:?}", other),
2982 }
2983 }
2984
2985 #[test]
2986 fn findinmap_conditional_second_key_produces_conditional() {
2987 let input = r#"{
2988 "Conditions":{"C":{"Fn::Equals":["a","a"]}},
2989 "Mappings":{"M":{"x":{"a":"va","b":"vb"}}},
2990 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M","x",{"Fn::If":["C","a","b"]}]}}}}
2991 }"#;
2992 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2993 match model.resolve("R", "Properties.V") {
2994 Some(ResolvedValue::Conditional { .. }) => {}
2995 other => panic!("Expected Conditional, got {:?}", other),
2996 }
2997 }
2998
2999 #[test]
3000 fn select_from_list_with_mixed_items_returns_element() {
3001 let input = r#"{
3004 "Resources":{
3005 "Other":{"Type":"T2"},
3006 "R":{"Type":"T","Properties":{"V":{"Fn::Select":[0,[{"Fn::GetAZs":"us-east-1"},{"Ref":"Other"}]]}}}
3007 }
3008 }"#;
3009 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3010 match model.resolve("R", "Properties.V") {
3011 Some(ResolvedValue::Concrete { value: v }) => {
3012 let arr = v.as_array().unwrap();
3013 assert!(arr[0].as_str().unwrap().starts_with("us-east-1"));
3014 }
3015 other => panic!("Expected Concrete AZ array from Select on List, got {:?}", other),
3016 }
3017 }
3018
3019 #[test]
3020 fn select_from_list_out_of_bounds_returns_dynamic() {
3021 let input = r#"{
3022 "Resources":{
3023 "Other":{"Type":"T2"},
3024 "R":{"Type":"T","Properties":{"V":{"Fn::Select":[5,["a",{"Ref":"Other"}]]}}}
3025 }
3026 }"#;
3027 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3028 match model.resolve("R", "Properties.V") {
3029 Some(ResolvedValue::Dynamic { reason }) => {
3030 assert!(reason.contains("out of bounds"));
3031 }
3032 other => panic!("Expected Dynamic out of bounds, got {:?}", other),
3033 }
3034 }
3035
3036 #[test]
3037 fn equals_two_concrete_strings_returns_true() {
3038 let input = r#"{
3039 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":["hello","hello"]}}}}
3040 }"#;
3041 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3042 match model.resolve("R", "Properties.V") {
3043 Some(ResolvedValue::Concrete { value: v }) => {
3044 assert_eq!(v.as_bool(), Some(true));
3045 }
3046 other => panic!("Expected Concrete(true), got {:?}", other),
3047 }
3048 }
3049
3050 #[test]
3051 fn equals_two_different_strings_returns_false() {
3052 let input = r#"{
3053 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":["a","b"]}}}}
3054 }"#;
3055 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3056 match model.resolve("R", "Properties.V") {
3057 Some(ResolvedValue::Concrete { value: v }) => {
3058 assert_eq!(v.as_bool(), Some(false));
3059 }
3060 other => panic!("Expected Concrete(false), got {:?}", other),
3061 }
3062 }
3063
3064 #[test]
3065 fn equals_enum_against_concrete_produces_enum_of_bools() {
3066 let input = r#"{
3067 "Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},
3068 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":[{"Ref":"Env"},"prod"]}}}}
3069 }"#;
3070 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3071 match model.resolve("R", "Properties.V") {
3072 Some(ResolvedValue::Enum { variants }) => {
3073 assert_eq!(variants.len(), 2);
3074 let bools: Vec<bool> = variants
3075 .iter()
3076 .filter_map(|v| match v {
3077 ResolvedValue::Concrete { value: j } => j.as_bool(),
3078 _ => None,
3079 })
3080 .collect();
3081 assert!(bools.contains(&true));
3082 assert!(bools.contains(&false));
3083 }
3084 other => panic!("Expected Enum of bools, got {:?}", other),
3085 }
3086 }
3087
3088 #[test]
3089 fn and_all_true_returns_true() {
3090 let input = r#"{
3091 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":["b","b"]}]}}}}
3092 }"#;
3093 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3094 match model.resolve("R", "Properties.V") {
3095 Some(ResolvedValue::Concrete { value: v }) => {
3096 assert_eq!(v.as_bool(), Some(true));
3097 }
3098 other => panic!("Expected Concrete(true), got {:?}", other),
3099 }
3100 }
3101
3102 #[test]
3103 fn and_one_false_returns_false() {
3104 let input = r#"{
3105 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":["a","b"]}]}}}}
3106 }"#;
3107 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3108 match model.resolve("R", "Properties.V") {
3109 Some(ResolvedValue::Concrete { value: v }) => {
3110 assert_eq!(v.as_bool(), Some(false));
3111 }
3112 other => panic!("Expected Concrete(false), got {:?}", other),
3113 }
3114 }
3115
3116 #[test]
3117 fn or_one_true_returns_true() {
3118 let input = r#"{
3119 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Or":[{"Fn::Equals":["a","b"]},{"Fn::Equals":["c","c"]}]}}}}
3120 }"#;
3121 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3122 match model.resolve("R", "Properties.V") {
3123 Some(ResolvedValue::Concrete { value: v }) => {
3124 assert_eq!(v.as_bool(), Some(true));
3125 }
3126 other => panic!("Expected Concrete(true), got {:?}", other),
3127 }
3128 }
3129
3130 #[test]
3131 fn or_all_false_returns_false() {
3132 let input = r#"{
3133 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Or":[{"Fn::Equals":["a","b"]},{"Fn::Equals":["c","d"]}]}}}}
3134 }"#;
3135 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3136 match model.resolve("R", "Properties.V") {
3137 Some(ResolvedValue::Concrete { value: v }) => {
3138 assert_eq!(v.as_bool(), Some(false));
3139 }
3140 other => panic!("Expected Concrete(false), got {:?}", other),
3141 }
3142 }
3143
3144 #[test]
3145 fn not_true_returns_false() {
3146 let input = r#"{
3147 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Not":[{"Fn::Equals":["a","a"]}]}}}}
3148 }"#;
3149 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3150 match model.resolve("R", "Properties.V") {
3151 Some(ResolvedValue::Concrete { value: v }) => {
3152 assert_eq!(v.as_bool(), Some(false));
3153 }
3154 other => panic!("Expected Concrete(false), got {:?}", other),
3155 }
3156 }
3157
3158 #[test]
3159 fn and_with_dynamic_child_returns_dynamic() {
3160 let input = r#"{
3161 "Resources":{
3162 "Other":{"Type":"T2"},
3163 "R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":[{"Ref":"Other"},"x"]}]}}}
3164 }
3165 }"#;
3166 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3167 match model.resolve("R", "Properties.V") {
3168 Some(ResolvedValue::Dynamic { .. }) => {}
3169 other => panic!("Expected Dynamic, got {:?}", other),
3170 }
3171 }
3172
3173 #[test]
3174 fn sub_enum_plus_reference_produces_enum_with_dynamic_variants() {
3175 let input = r#"{
3176 "Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},
3177 "Resources":{
3178 "Bucket":{"Type":"AWS::S3::Bucket"},
3179 "R":{"Type":"T","Properties":{"V":{"Fn::Sub":"arn:${Env}:${Bucket}"}}}
3180 }
3181 }"#;
3182 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3183 match model.resolve("R", "Properties.V") {
3184 Some(ResolvedValue::Enum { variants }) => {
3185 assert_eq!(variants.len(), 2);
3186 for v in variants {
3188 match v {
3189 ResolvedValue::Dynamic { reason } => {
3190 assert!(reason.starts_with("Sub:"));
3191 }
3192 _ => panic!("Expected Dynamic variant, got {:?}", v),
3193 }
3194 }
3195 }
3196 other => panic!("Expected Enum with Dynamic variants, got {:?}", other),
3197 }
3198 }
3199
3200 #[test]
3201 fn join_list_with_mixed_concrete_and_reference_produces_partial_dynamic() {
3202 let input = r#"{
3203 "Resources":{
3204 "Other":{"Type":"T2"},
3205 "R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["prefix",{"Ref":"Other"},"suffix"]]}}}
3206 }
3207 }"#;
3208 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3209 match model.resolve("R", "Properties.V") {
3210 Some(ResolvedValue::Dynamic { reason }) => {
3211 assert!(reason.starts_with("Join:"));
3212 assert!(reason.contains("prefix"));
3213 assert!(reason.contains("suffix"));
3214 }
3215 other => panic!("Expected Dynamic with Join: prefix, got {:?}", other),
3216 }
3217 }
3218
3219 #[test]
3220 fn getazs_enum_region_produces_enum_of_az_arrays() {
3221 let input = r#"{
3222 "Parameters":{"Region":{"Type":"String","AllowedValues":["us-east-1","us-west-2"]}},
3223 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAZs":{"Ref":"Region"}}}}}
3224 }"#;
3225 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3226 match model.resolve("R", "Properties.V") {
3227 Some(ResolvedValue::Enum { variants }) => {
3228 assert_eq!(variants.len(), 2);
3229 for v in variants {
3230 match v {
3231 ResolvedValue::Concrete { value: arr } => {
3232 assert!(arr.as_array().unwrap().len() >= 2);
3233 }
3234 _ => panic!("Expected Concrete AZ arrays, got {:?}", v),
3235 }
3236 }
3237 }
3238 other => panic!("Expected Enum of AZ arrays, got {:?}", other),
3239 }
3240 }
3241
3242 #[test]
3243 fn cidr_enum_ip_block_produces_enum_of_cidr_arrays() {
3244 let input = r#"{
3245 "Parameters":{"Cidr":{"Type":"String","AllowedValues":["10.0.0.0/16","172.16.0.0/16"]}},
3246 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Cidr":[{"Ref":"Cidr"},"2","8"]}}}}
3247 }"#;
3248 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3249 match model.resolve("R", "Properties.V") {
3250 Some(ResolvedValue::Enum { variants }) => {
3251 assert_eq!(variants.len(), 2);
3252 for v in variants {
3253 match v {
3254 ResolvedValue::Concrete { value: arr } => {
3255 assert_eq!(arr.as_array().unwrap().len(), 2);
3256 }
3257 _ => panic!("Expected Concrete CIDR arrays, got {:?}", v),
3258 }
3259 }
3260 }
3261 other => panic!("Expected Enum of CIDR arrays, got {:?}", other),
3262 }
3263 }
3264
3265 #[test]
3266 fn to_json_string_enum_produces_enum_of_strings() {
3267 let input = r#"{
3268 "Parameters":{"Val":{"Type":"String","AllowedValues":["a","b"]}},
3269 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"Ref":"Val"}}}}}
3270 }"#;
3271 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3272 match model.resolve("R", "Properties.V") {
3273 Some(ResolvedValue::Enum { variants }) => {
3274 assert_eq!(variants.len(), 2);
3275 for v in variants {
3276 match v {
3277 ResolvedValue::Concrete { value: j } => {
3278 assert!(j.is_string());
3279 }
3280 _ => panic!("Expected Concrete string, got {:?}", v),
3281 }
3282 }
3283 }
3284 other => panic!("Expected Enum, got {:?}", other),
3285 }
3286 }
3287
3288 #[test]
3289 fn to_json_string_conditional_produces_conditional() {
3290 let input = r#"{
3291 "Conditions":{"C":{"Fn::Equals":["a","a"]}},
3292 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"Fn::If":["C","yes","no"]}}}}}
3293 }"#;
3294 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3295 match model.resolve("R", "Properties.V") {
3296 Some(ResolvedValue::Conditional { .. }) => {}
3297 other => panic!("Expected Conditional, got {:?}", other),
3298 }
3299 }
3300
3301 #[test]
3302 fn length_of_resolved_list_returns_count() {
3303 let input = r#"{
3305 "Resources":{
3306 "Other":{"Type":"T2"},
3307 "R":{"Type":"T","Properties":{"V":{"Fn::Length":[1,{"Ref":"Other"},3]}}}
3308 }
3309 }"#;
3310 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3311 match model.resolve("R", "Properties.V") {
3312 Some(ResolvedValue::Concrete { value: v }) => {
3313 assert_eq!(v.as_i64(), Some(3));
3314 }
3315 other => panic!("Expected Concrete(3), got {:?}", other),
3316 }
3317 }
3318
3319 #[test]
3320 fn foreach_concrete_collection_evaluates_body() {
3321 let input = r#"{
3322 "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ForEach":["Id","Item",["a","b"],{"Fn::Sub":"prefix-${Item}"}]}}}}
3323 }"#;
3324 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3325 match model.resolve("R", "Properties.V") {
3326 Some(ResolvedValue::Map { entries }) => {
3327 assert_eq!(entries.len(), 2);
3328 let values: Vec<String> = entries
3329 .iter()
3330 .filter_map(|e| match &e.value {
3331 ResolvedValue::Concrete { value: v } => v.as_str().map(|s| s.to_string()),
3332 _ => None,
3333 })
3334 .collect();
3335 assert!(values.contains(&"prefix-a".to_string()));
3336 assert!(values.contains(&"prefix-b".to_string()));
3337 }
3338 other => panic!("Expected Map with evaluated body, got {:?}", other),
3339 }
3340 }
3341
3342 #[test]
3343 fn foreach_dynamic_collection_returns_dynamic() {
3344 let input = r#"{
3345 "Resources":{
3346 "Other":{"Type":"T2"},
3347 "R":{"Type":"T","Properties":{"V":{"Fn::ForEach":["Id","Item",{"Ref":"Other"},"body"]}}}
3348 }
3349 }"#;
3350 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3351 match model.resolve("R", "Properties.V") {
3352 Some(ResolvedValue::Dynamic { .. }) => {}
3353 other => panic!("Expected Dynamic, got {:?}", other),
3354 }
3355 }
3356
3357 #[test]
3358 fn output_getatt_edge_carries_terminal_intrinsic_path() {
3359 let input = r#"{
3362 "Resources":{"R":{"Type":"T","Properties":{}}},
3363 "Outputs":{
3364 "DottedString":{"Value":{"Fn::GetAtt":["R","Arn"]}},
3365 "ListForm":{"Value":{"Fn::GetAtt":["R","Arn"]}}
3366 }
3367 }"#;
3368 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3369 let edges: Vec<_> = model.graph.edges.iter().filter(|e| e.source_resource.starts_with("__output__")).collect();
3370 assert_eq!(edges.len(), 2);
3371 for edge in &edges {
3372 assert!(
3373 edge.source_path.ends_with(".Fn::GetAtt"),
3374 "output GetAtt edge path should end with .Fn::GetAtt, got: {}",
3375 edge.source_path,
3376 );
3377 }
3378 }
3379
3380 #[test]
3381 fn output_sub_implicit_getatt_edge_carries_fn_sub_path() {
3382 let input = r#"{
3384 "Resources":{"R":{"Type":"T","Properties":{}}},
3385 "Outputs":{
3386 "SubOut":{"Value":{"Fn::Sub":"${R.Arn}"}}
3387 }
3388 }"#;
3389 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3390 let edges: Vec<_> = model
3391 .graph
3392 .edges
3393 .iter()
3394 .filter(|e| e.source_resource.starts_with("__output__"))
3395 .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3396 .collect();
3397 assert_eq!(edges.len(), 1);
3398 assert_eq!(edges[0].source_path, "Outputs/SubOut/Value.Fn::Sub");
3399 }
3400
3401 #[test]
3402 fn output_join_nested_getatt_edge_path() {
3403 let input = r#"{
3406 "Resources":{"R":{"Type":"T","Properties":{}}},
3407 "Outputs":{
3408 "JoinOut":{"Value":{"Fn::Join":["",["ignored",{"Fn::GetAtt":["R","Arn"]}]]}}
3409 }
3410 }"#;
3411 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3412 let edges: Vec<_> = model
3413 .graph
3414 .edges
3415 .iter()
3416 .filter(|e| e.source_resource == "__output__JoinOut")
3417 .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3418 .collect();
3419 assert_eq!(edges.len(), 1);
3420 assert_eq!(edges[0].source_path, "Outputs/JoinOut/Value.Fn::Join.1.1.Fn::GetAtt");
3421 }
3422
3423 #[test]
3424 fn resource_getatt_edge_path_unchanged() {
3425 let input = r#"{
3428 "Resources":{
3429 "R":{"Type":"T","Properties":{"V":{"Fn::GetAtt":["Other","Arn"]}}},
3430 "Other":{"Type":"T2","Properties":{}}
3431 }
3432 }"#;
3433 let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3434 let edges: Vec<_> = model
3435 .graph
3436 .edges
3437 .iter()
3438 .filter(|e| e.source_resource == "R")
3439 .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3440 .collect();
3441 assert_eq!(edges.len(), 1);
3442 assert_eq!(edges[0].source_path, "Properties.V");
3443 assert!(!edges[0].source_path.contains("Fn::GetAtt"));
3444 }
3445}