1use base64::Engine;
2use serde_json::{json, Value};
3use std::collections::{BTreeMap, BTreeSet};
4
5const NO_VALUE_SENTINEL_KEY: &str = "__fakecloud_aws_no_value__";
12
13#[derive(Debug, Clone, Default)]
15pub struct ParsedTemplate {
16 pub description: Option<String>,
17 pub resources: Vec<ResourceDefinition>,
18 pub outputs: Vec<TemplateOutput>,
19}
20
21#[derive(Debug, Clone)]
25pub struct TemplateOutput {
26 pub logical_id: String,
27 pub value: String,
28 pub description: Option<String>,
29 pub export_name: Option<String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct ResourceDefinition {
35 pub logical_id: String,
36 pub resource_type: String,
37 pub properties: Value,
38}
39
40const PSEUDO_REFS: &[&str] = &[
42 "AWS::AccountId",
43 "AWS::NotificationARNs",
44 "AWS::NoValue",
45 "AWS::Partition",
46 "AWS::Region",
47 "AWS::StackId",
48 "AWS::StackName",
49 "AWS::URLSuffix",
50];
51
52pub fn parse_template(
54 template_body: &str,
55 parameters: &BTreeMap<String, String>,
56) -> Result<ParsedTemplate, String> {
57 parse_template_with_physical_ids(template_body, parameters, &BTreeMap::new())
58}
59
60pub fn parse_template_with_physical_ids(
62 template_body: &str,
63 parameters: &BTreeMap<String, String>,
64 resource_physical_ids: &BTreeMap<String, String>,
65) -> Result<ParsedTemplate, String> {
66 parse_template_with_resolution(
67 template_body,
68 parameters,
69 resource_physical_ids,
70 &BTreeMap::new(),
71 )
72}
73
74pub fn parse_template_with_resolution(
78 template_body: &str,
79 parameters: &BTreeMap<String, String>,
80 resource_physical_ids: &BTreeMap<String, String>,
81 resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
82) -> Result<ParsedTemplate, String> {
83 let value: Value = if template_body.trim_start().starts_with('{') {
84 serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
85 } else {
86 serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
87 };
88
89 let value = expand_for_each(&value, &BTreeMap::new(), parameters)?;
95 let value = expand_sam(&value);
96
97 let description = value
98 .get("Description")
99 .and_then(|v| v.as_str())
100 .map(|s| s.to_string());
101
102 let conditions = evaluate_conditions(&value, parameters)?;
103 let mappings = parse_mappings(&value);
104
105 let resources_obj = value
106 .get("Resources")
107 .and_then(|v| v.as_object())
108 .ok_or("Template must contain a Resources section")?;
109
110 let mut resources = Vec::new();
111 for (logical_id, resource) in resources_obj {
112 if let Some(cond_name) = resource.get("Condition").and_then(|v| v.as_str()) {
115 if !conditions.get(cond_name).copied().unwrap_or(false) {
116 continue;
117 }
118 }
119 let resource_type = resource
120 .get("Type")
121 .and_then(|v| v.as_str())
122 .ok_or(format!("Resource {logical_id} must have a Type property"))?
123 .to_string();
124
125 let properties = resource
126 .get("Properties")
127 .cloned()
128 .unwrap_or(Value::Object(serde_json::Map::new()));
129
130 let properties = apply_mappings(&properties, parameters, &mappings, &conditions)?;
135
136 let resolved = resolve_refs_full(
138 &properties,
139 parameters,
140 resources_obj,
141 resource_physical_ids,
142 resource_attributes,
143 &BTreeMap::new(),
144 &conditions,
145 );
146 let resolved = strip_no_value(resolved);
147
148 resources.push(ResourceDefinition {
149 logical_id: logical_id.clone(),
150 resource_type,
151 properties: resolved,
152 });
153 }
154
155 let outputs = parse_outputs(
156 &value,
157 parameters,
158 resources_obj,
159 resource_physical_ids,
160 resource_attributes,
161 &BTreeMap::new(),
162 )?;
163
164 Ok(ParsedTemplate {
165 description,
166 resources,
167 outputs,
168 })
169}
170
171pub fn collect_import_value_names(
177 template: &Value,
178 parameters: &BTreeMap<String, String>,
179) -> Vec<String> {
180 let mut out: Vec<String> = Vec::new();
181 collect_imports_walk(template, parameters, &mut out);
182 out.sort();
183 out.dedup();
184 out
185}
186
187fn collect_imports_walk(
188 value: &Value,
189 parameters: &BTreeMap<String, String>,
190 out: &mut Vec<String>,
191) {
192 match value {
193 Value::Object(map) => {
194 if let Some(arg) = map.get("Fn::ImportValue") {
195 if let Some(name) = static_import_name(arg, parameters) {
196 out.push(name);
197 } else {
198 collect_imports_walk(arg, parameters, out);
200 }
201 }
202 for (k, v) in map {
203 if k == "Fn::ImportValue" {
204 continue;
205 }
206 collect_imports_walk(v, parameters, out);
207 }
208 }
209 Value::Array(arr) => {
210 for v in arr {
211 collect_imports_walk(v, parameters, out);
212 }
213 }
214 _ => {}
215 }
216}
217
218fn static_import_name(value: &Value, parameters: &BTreeMap<String, String>) -> Option<String> {
219 match value {
220 Value::String(s) => Some(s.clone()),
221 Value::Object(m) => {
222 if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
223 return parameters.get(name).cloned();
224 }
225 if let Some(s) = m.get("Fn::Sub").and_then(|v| v.as_str()) {
226 let mut result = s.to_string();
227 for (k, v) in parameters {
228 result = result.replace(&format!("${{{k}}}"), v);
229 }
230 if !result.contains("${") {
231 return Some(result);
232 }
233 }
234 None
235 }
236 _ => None,
237 }
238}
239
240pub fn parse_outputs(
244 template: &Value,
245 parameters: &BTreeMap<String, String>,
246 resources: &serde_json::Map<String, Value>,
247 resource_physical_ids: &BTreeMap<String, String>,
248 resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
249 imports: &BTreeMap<String, String>,
250) -> Result<Vec<TemplateOutput>, String> {
251 let template_owned = expand_for_each(template, &BTreeMap::new(), parameters)?;
255 let template = &template_owned;
256 let outputs_obj = match template.get("Outputs").and_then(|v| v.as_object()) {
257 Some(o) => o,
258 None => return Ok(Vec::new()),
259 };
260
261 let conditions = evaluate_conditions(template, parameters)?;
262 let mut out = Vec::new();
263 for (logical_id, body) in outputs_obj {
264 if let Some(cond_name) = body.get("Condition").and_then(|v| v.as_str()) {
267 if !conditions.get(cond_name).copied().unwrap_or(false) {
268 continue;
269 }
270 }
271 let raw_value = match body.get("Value") {
272 Some(v) => v,
273 None => continue,
274 };
275 let resolved = resolve_refs_full(
276 raw_value,
277 parameters,
278 resources,
279 resource_physical_ids,
280 resource_attributes,
281 imports,
282 &conditions,
283 );
284 let resolved = strip_no_value(resolved);
285 let value = match resolved {
286 Value::String(s) => s,
287 other => other.to_string(),
288 };
289 let description = body
290 .get("Description")
291 .and_then(|v| v.as_str())
292 .map(|s| s.to_string());
293 let export_name = body.get("Export").and_then(|e| e.get("Name")).map(|n| {
294 let resolved = resolve_refs_full(
295 n,
296 parameters,
297 resources,
298 resource_physical_ids,
299 resource_attributes,
300 imports,
301 &conditions,
302 );
303 match resolved {
304 Value::String(s) => s,
305 other => other.to_string(),
306 }
307 });
308 out.push(TemplateOutput {
309 logical_id: logical_id.clone(),
310 value,
311 description,
312 export_name,
313 });
314 }
315 Ok(out)
316}
317
318fn evaluate_conditions(
323 template: &Value,
324 parameters: &BTreeMap<String, String>,
325) -> Result<BTreeMap<String, bool>, String> {
326 let mut memo: BTreeMap<String, bool> = BTreeMap::new();
327 let Some(conds) = template.get("Conditions").and_then(|v| v.as_object()) else {
328 return Ok(memo);
329 };
330 let mut in_progress: BTreeSet<String> = BTreeSet::new();
331 let names: Vec<String> = conds.keys().cloned().collect();
332 for name in names {
333 evaluate_condition_named(&name, conds, parameters, &mut memo, &mut in_progress)?;
334 }
335 Ok(memo)
336}
337
338fn evaluate_condition_named(
342 name: &str,
343 conds: &serde_json::Map<String, Value>,
344 parameters: &BTreeMap<String, String>,
345 memo: &mut BTreeMap<String, bool>,
346 in_progress: &mut BTreeSet<String>,
347) -> Result<bool, String> {
348 if let Some(b) = memo.get(name) {
349 return Ok(*b);
350 }
351 if !in_progress.insert(name.to_string()) {
352 return Err(format!(
353 "Circular reference in Conditions: '{name}' transitively references itself"
354 ));
355 }
356 let expr = conds.get(name).ok_or_else(|| {
357 format!("Condition '{name}' is referenced but not defined in Conditions block")
358 })?;
359 let result = eval_condition_expr(expr, conds, parameters, memo, in_progress)?;
360 in_progress.remove(name);
361 memo.insert(name.to_string(), result);
362 Ok(result)
363}
364
365type Mappings = BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>;
366
367fn parse_mappings(template: &Value) -> Mappings {
371 let mut out: Mappings = BTreeMap::new();
372 let Some(maps) = template.get("Mappings").and_then(|v| v.as_object()) else {
373 return out;
374 };
375 for (map_name, top) in maps {
376 let Some(top_obj) = top.as_object() else {
377 continue;
378 };
379 let mut top_out = BTreeMap::new();
380 for (top_key, second) in top_obj {
381 let Some(second_obj) = second.as_object() else {
382 continue;
383 };
384 let mut second_out: BTreeMap<String, Value> = BTreeMap::new();
385 for (k, v) in second_obj {
386 second_out.insert(k.clone(), v.clone());
387 }
388 top_out.insert(top_key.clone(), second_out);
389 }
390 out.insert(map_name.clone(), top_out);
391 }
392 out
393}
394
395fn eval_condition_expr(
400 expr: &Value,
401 conds: &serde_json::Map<String, Value>,
402 parameters: &BTreeMap<String, String>,
403 memo: &mut BTreeMap<String, bool>,
404 in_progress: &mut BTreeSet<String>,
405) -> Result<bool, String> {
406 if let Some(b) = expr.as_bool() {
407 return Ok(b);
408 }
409 let map = expr
410 .as_object()
411 .ok_or_else(|| format!("Invalid condition expression: {expr}"))?;
412 if let Some(args) = map.get("Fn::Equals").and_then(|v| v.as_array()) {
413 if args.len() != 2 {
414 return Err("Fn::Equals requires exactly 2 arguments".to_string());
415 }
416 let a = stringify_value(&args[0], parameters);
417 let b = stringify_value(&args[1], parameters);
418 return Ok(a == b);
419 }
420 if let Some(args) = map.get("Fn::And").and_then(|v| v.as_array()) {
421 if !(1..=10).contains(&args.len()) {
422 return Err("Fn::And requires between 1 and 10 conditions".to_string());
423 }
424 for a in args {
425 if !eval_condition_expr(a, conds, parameters, memo, in_progress)? {
426 return Ok(false);
427 }
428 }
429 return Ok(true);
430 }
431 if let Some(args) = map.get("Fn::Or").and_then(|v| v.as_array()) {
432 if !(1..=10).contains(&args.len()) {
433 return Err("Fn::Or requires between 1 and 10 conditions".to_string());
434 }
435 for a in args {
436 if eval_condition_expr(a, conds, parameters, memo, in_progress)? {
437 return Ok(true);
438 }
439 }
440 return Ok(false);
441 }
442 if let Some(arr) = map.get("Fn::Not").and_then(|v| v.as_array()) {
443 if arr.len() != 1 {
444 return Err("Fn::Not requires exactly 1 argument".to_string());
445 }
446 return Ok(!eval_condition_expr(
447 &arr[0],
448 conds,
449 parameters,
450 memo,
451 in_progress,
452 )?);
453 }
454 if let Some(name) = map.get("Condition").and_then(|v| v.as_str()) {
455 return evaluate_condition_named(name, conds, parameters, memo, in_progress);
456 }
457 Err(format!("Unknown condition operator in expression: {expr}"))
458}
459
460fn stringify_value(value: &Value, parameters: &BTreeMap<String, String>) -> String {
463 match value {
464 Value::String(s) => s.clone(),
465 Value::Bool(b) => b.to_string(),
466 Value::Number(n) => n.to_string(),
467 Value::Object(m) => {
468 if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
469 if let Some(p) = parameters.get(name) {
470 return p.clone();
471 }
472 return name.to_string();
473 }
474 value.to_string()
475 }
476 _ => value.to_string(),
477 }
478}
479
480fn expand_for_each(
500 value: &Value,
501 bindings: &BTreeMap<String, String>,
502 parameters: &BTreeMap<String, String>,
503) -> Result<Value, String> {
504 match value {
505 Value::Object(map) => {
506 let mut out = serde_json::Map::with_capacity(map.len());
507 for (k, v) in map {
508 if let Some(loop_name) = k.strip_prefix("Fn::ForEach::") {
509 let arr = v.as_array().ok_or_else(|| {
510 format!("Fn::ForEach::{loop_name} requires an array argument")
511 })?;
512 if arr.len() != 3 {
513 return Err(format!(
514 "Fn::ForEach::{loop_name} requires 3 arguments (loopVar, list, template), got {}",
515 arr.len()
516 ));
517 }
518 let loop_var = arr[0].as_str().ok_or_else(|| {
519 format!("Fn::ForEach::{loop_name} loop variable must be a string")
520 })?;
521 let items_owned: Vec<Value> =
527 resolve_for_each_items(&arr[1], parameters).ok_or_else(|| {
528 format!(
529 "Fn::ForEach::{loop_name} second argument must be an array or a Ref to a CommaDelimitedList parameter"
530 )
531 })?;
532 let body = arr[2].as_object().ok_or_else(|| {
533 format!("Fn::ForEach::{loop_name} third argument must be an object")
534 })?;
535 for item in &items_owned {
536 let item_str = match item {
537 Value::String(s) => s.clone(),
538 other => other.to_string(),
539 };
540 let mut next = bindings.clone();
541 next.insert(loop_var.to_string(), item_str.clone());
542 let body_value = Value::Object(body.clone());
548 let substituted = substitute_loop_vars_in_value(&body_value, &next);
549 let expanded = expand_for_each(&substituted, &next, parameters)?;
550 if let Value::Object(emitted) = expanded {
551 for (ek, ev) in emitted {
552 out.insert(ek, ev);
553 }
554 }
555 }
556 continue;
557 }
558 out.insert(k.clone(), expand_for_each(v, bindings, parameters)?);
559 }
560 Ok(Value::Object(out))
561 }
562 Value::Array(arr) => {
563 let mut out = Vec::with_capacity(arr.len());
564 for v in arr {
565 out.push(expand_for_each(v, bindings, parameters)?);
566 }
567 Ok(Value::Array(out))
568 }
569 other => Ok(other.clone()),
570 }
571}
572
573fn expand_sam(value: &Value) -> Value {
576 let transform = value.get("Transform");
577 let has_sam = match transform {
578 Some(Value::String(s)) => s == "AWS::Serverless-2016-10-31",
579 Some(Value::Array(arr)) => arr
580 .iter()
581 .any(|v| v.as_str() == Some("AWS::Serverless-2016-10-31")),
582 _ => false,
583 };
584 if !has_sam {
585 return value.clone();
586 }
587
588 let mut value = value.clone();
589 let Some(resources) = value.get_mut("Resources") else {
590 return value;
591 };
592 let Some(resources_map) = resources.as_object_mut() else {
593 return value;
594 };
595
596 let mut new_resources = serde_json::Map::new();
597 for (logical_id, resource) in resources_map.iter() {
598 let Some(resource_obj) = resource.as_object() else {
599 new_resources.insert(logical_id.clone(), resource.clone());
600 continue;
601 };
602 let Some(ty) = resource_obj.get("Type").and_then(|v| v.as_str()) else {
603 new_resources.insert(logical_id.clone(), resource.clone());
604 continue;
605 };
606 let properties = resource_obj
607 .get("Properties")
608 .cloned()
609 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
610
611 match ty {
612 "AWS::Serverless::Function" => {
613 let mut lambda_props = if let Some(p) = properties.as_object() {
614 p.clone()
615 } else {
616 serde_json::Map::new()
617 };
618 if let Some(code_uri) = lambda_props.get("CodeUri").cloned() {
620 lambda_props.remove("CodeUri");
621 let code = if let Some(s) = code_uri.as_str() {
622 if let Some(stripped) = s.strip_prefix("s3://") {
623 let parts: Vec<&str> = stripped.splitn(2, '/').collect();
624 if parts.len() == 2 {
625 json!({"S3Bucket": parts[0], "S3Key": parts[1]})
626 } else {
627 json!({"S3Bucket": "sam", "S3Key": s})
628 }
629 } else {
630 json!({"S3Bucket": "sam", "S3Key": s})
631 }
632 } else {
633 code_uri
634 };
635 lambda_props.insert("Code".to_string(), code);
636 } else if let Some(inline) = lambda_props.get("InlineCode").cloned() {
637 lambda_props.remove("InlineCode");
638 lambda_props.insert("Code".to_string(), json!({"ZipFile": inline}));
639 }
640 let mut lambda_resource = serde_json::Map::new();
641 lambda_resource.insert("Type".to_string(), json!("AWS::Lambda::Function"));
642 lambda_resource.insert("Properties".to_string(), Value::Object(lambda_props));
643 for (k, v) in resource_obj {
644 if k != "Type" && k != "Properties" {
645 lambda_resource.insert(k.clone(), v.clone());
646 }
647 }
648 new_resources.insert(logical_id.clone(), Value::Object(lambda_resource));
649 }
650 "AWS::Serverless::Api" => {
651 let mut api_props = if let Some(p) = properties.as_object() {
652 p.clone()
653 } else {
654 serde_json::Map::new()
655 };
656 if let Some(def) = api_props.get("DefinitionBody").cloned() {
657 api_props.remove("DefinitionBody");
658 api_props.insert("Body".to_string(), def);
659 }
660 let mut api_resource = serde_json::Map::new();
661 api_resource.insert("Type".to_string(), json!("AWS::ApiGateway::RestApi"));
662 api_resource.insert("Properties".to_string(), Value::Object(api_props));
663 for (k, v) in resource_obj {
664 if k != "Type" && k != "Properties" {
665 api_resource.insert(k.clone(), v.clone());
666 }
667 }
668 new_resources.insert(logical_id.clone(), Value::Object(api_resource));
669 }
670 "AWS::Serverless::HttpApi" => {
671 let mut httpapi_resource = serde_json::Map::new();
672 httpapi_resource.insert("Type".to_string(), json!("AWS::ApiGatewayV2::Api"));
673 httpapi_resource.insert("Properties".to_string(), properties);
674 for (k, v) in resource_obj {
675 if k != "Type" && k != "Properties" {
676 httpapi_resource.insert(k.clone(), v.clone());
677 }
678 }
679 new_resources.insert(logical_id.clone(), Value::Object(httpapi_resource));
680 }
681 "AWS::Serverless::SimpleTable" => {
682 let mut table_props = if let Some(p) = properties.as_object() {
683 p.clone()
684 } else {
685 serde_json::Map::new()
686 };
687 if let Some(pk) = table_props.get("PrimaryKey") {
688 if let Some(pk_obj) = pk.as_object() {
689 let name = pk_obj.get("Name").cloned().unwrap_or_else(|| json!("id"));
690 let ty = match pk_obj.get("Type").and_then(|v| v.as_str()) {
691 Some("String") => json!("S"),
692 Some("Number") => json!("N"),
693 Some("Binary") => json!("B"),
694 Some(other) => json!(other),
695 None => json!("S"),
696 };
697 table_props.remove("PrimaryKey");
698 table_props.insert(
699 "KeySchema".to_string(),
700 json!([{"AttributeName": name.clone(), "KeyType": "HASH"}]),
701 );
702 table_props.insert(
703 "AttributeDefinitions".to_string(),
704 json!([{"AttributeName": name, "AttributeType": ty}]),
705 );
706 }
707 }
708 if !table_props.contains_key("BillingMode") {
709 table_props.insert("BillingMode".to_string(), json!("PAY_PER_REQUEST"));
710 }
711 let mut table_resource = serde_json::Map::new();
712 table_resource.insert("Type".to_string(), json!("AWS::DynamoDB::Table"));
713 table_resource.insert("Properties".to_string(), Value::Object(table_props));
714 for (k, v) in resource_obj {
715 if k != "Type" && k != "Properties" {
716 table_resource.insert(k.clone(), v.clone());
717 }
718 }
719 new_resources.insert(logical_id.clone(), Value::Object(table_resource));
720 }
721 "AWS::Serverless::LayerVersion" => {
722 let mut layer_props = if let Some(p) = properties.as_object() {
723 p.clone()
724 } else {
725 serde_json::Map::new()
726 };
727 if let Some(uri) = layer_props.get("ContentUri").cloned() {
728 layer_props.remove("ContentUri");
729 let content = if let Some(s) = uri.as_str() {
730 if let Some(stripped) = s.strip_prefix("s3://") {
731 let parts: Vec<&str> = stripped.splitn(2, '/').collect();
732 if parts.len() == 2 {
733 json!({"S3Bucket": parts[0], "S3Key": parts[1]})
734 } else {
735 json!({"S3Bucket": "sam", "S3Key": s})
736 }
737 } else {
738 json!({"S3Bucket": "sam", "S3Key": s})
739 }
740 } else {
741 uri
742 };
743 layer_props.insert("Content".to_string(), content);
744 }
745 let mut layer_resource = serde_json::Map::new();
746 layer_resource.insert("Type".to_string(), json!("AWS::Lambda::LayerVersion"));
747 layer_resource.insert("Properties".to_string(), Value::Object(layer_props));
748 for (k, v) in resource_obj {
749 if k != "Type" && k != "Properties" {
750 layer_resource.insert(k.clone(), v.clone());
751 }
752 }
753 new_resources.insert(logical_id.clone(), Value::Object(layer_resource));
754 }
755 _ => {
756 new_resources.insert(logical_id.clone(), resource.clone());
757 }
758 }
759 }
760
761 resources_map.clear();
762 for (k, v) in new_resources {
763 resources_map.insert(k, v);
764 }
765 value
766}
767
768fn resolve_for_each_items(
779 value: &Value,
780 parameters: &BTreeMap<String, String>,
781) -> Option<Vec<Value>> {
782 if let Some(arr) = value.as_array() {
783 return Some(arr.clone());
784 }
785 if let Some(map) = value.as_object() {
786 if let Some(name) = map.get("Ref").and_then(|v| v.as_str()) {
787 let raw = parameters.get(name)?;
788 return Some(
789 raw.split(',')
790 .map(|p| Value::String(p.trim().to_string()))
791 .collect(),
792 );
793 }
794 }
795 None
796}
797
798fn substitute_loop_vars(s: &str, bindings: &BTreeMap<String, String>) -> String {
806 let mut result = s.to_string();
807 for (k, v) in bindings {
808 result = result.replace(&format!("${{{k}}}"), v);
809 result = result.replace(&format!("&{{{k}}}"), v);
810 }
811 result
812}
813
814fn substitute_loop_vars_in_value(value: &Value, bindings: &BTreeMap<String, String>) -> Value {
818 match value {
819 Value::String(s) => Value::String(substitute_loop_vars(s, bindings)),
820 Value::Object(map) => {
821 let mut out = serde_json::Map::with_capacity(map.len());
822 for (k, v) in map {
823 let new_key = substitute_loop_vars(k, bindings);
824 out.insert(new_key, substitute_loop_vars_in_value(v, bindings));
825 }
826 Value::Object(out)
827 }
828 Value::Array(arr) => Value::Array(
829 arr.iter()
830 .map(|v| substitute_loop_vars_in_value(v, bindings))
831 .collect(),
832 ),
833 other => other.clone(),
834 }
835}
836
837fn apply_mappings(
849 value: &Value,
850 parameters: &BTreeMap<String, String>,
851 mappings: &Mappings,
852 conditions: &BTreeMap<String, bool>,
853) -> Result<Value, String> {
854 match value {
855 Value::Object(map) => {
856 if let Some(arr) = map.get("Fn::If").and_then(|v| v.as_array()) {
857 if arr.len() == 3 {
858 let cond_name = arr[0].as_str().unwrap_or("");
859 if let Some(picked_idx) =
860 conditions
861 .get(cond_name)
862 .copied()
863 .map(|b| if b { 1 } else { 2 })
864 {
865 let mut new_arr = arr.clone();
873 new_arr[picked_idx] =
874 apply_mappings(&arr[picked_idx], parameters, mappings, conditions)?;
875 let mut rewritten = serde_json::Map::new();
876 rewritten.insert("Fn::If".to_string(), Value::Array(new_arr));
877 return Ok(Value::Object(rewritten));
878 }
879 }
880 }
881 if let Some(arr) = map.get("Fn::FindInMap").and_then(|v| v.as_array()) {
882 return resolve_find_in_map(arr, parameters, mappings, conditions);
883 }
884 let mut new_map = serde_json::Map::new();
885 for (k, v) in map {
886 new_map.insert(
887 k.clone(),
888 apply_mappings(v, parameters, mappings, conditions)?,
889 );
890 }
891 Ok(Value::Object(new_map))
892 }
893 Value::Array(arr) => {
894 let mut out = Vec::with_capacity(arr.len());
895 for v in arr {
896 out.push(apply_mappings(v, parameters, mappings, conditions)?);
897 }
898 Ok(Value::Array(out))
899 }
900 other => Ok(other.clone()),
901 }
902}
903
904fn resolve_find_in_map(
910 arr: &[Value],
911 parameters: &BTreeMap<String, String>,
912 mappings: &Mappings,
913 conditions: &BTreeMap<String, bool>,
914) -> Result<Value, String> {
915 if arr.len() != 3 && arr.len() != 4 {
916 return Err(format!(
917 "Fn::FindInMap requires 3 or 4 arguments, got {}",
918 arr.len()
919 ));
920 }
921 let default_value: Option<Value> = if arr.len() == 4 {
922 let opts = arr[3].as_object().ok_or_else(|| {
923 "Fn::FindInMap fourth argument must be an object with a DefaultValue key".to_string()
924 })?;
925 let dv = opts.get("DefaultValue").ok_or_else(|| {
926 "Fn::FindInMap fourth argument must contain a DefaultValue key".to_string()
927 })?;
928 Some(apply_mappings(dv, parameters, mappings, conditions)?)
929 } else {
930 None
931 };
932
933 let map_name = stringify_findinmap_arg(&arr[0], parameters, mappings, conditions)?;
934 let top_key = stringify_findinmap_arg(&arr[1], parameters, mappings, conditions)?;
935 let second_key = stringify_findinmap_arg(&arr[2], parameters, mappings, conditions)?;
936
937 if let Some(top) = mappings.get(&map_name) {
938 if let Some(second) = top.get(&top_key) {
939 if let Some(leaf) = second.get(&second_key) {
940 return Ok(leaf.clone());
941 }
942 }
943 }
944
945 if let Some(dv) = default_value {
946 return Ok(dv);
947 }
948
949 Err(format!(
950 "Template error: Unable to get mapping for {map_name}::{top_key}::{second_key}"
951 ))
952}
953
954fn stringify_findinmap_arg(
955 value: &Value,
956 parameters: &BTreeMap<String, String>,
957 mappings: &Mappings,
958 conditions: &BTreeMap<String, bool>,
959) -> Result<String, String> {
960 match value {
961 Value::String(s) => Ok(s.clone()),
962 Value::Object(m) => {
963 if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
964 if let Some(p) = parameters.get(name) {
965 return Ok(p.clone());
966 }
967 if let Some(Value::String(s)) = pseudo_value(name, parameters) {
971 return Ok(s);
972 }
973 return Ok(name.to_string());
974 }
975 if let Some(arr) = m.get("Fn::FindInMap").and_then(|v| v.as_array()) {
979 let resolved = resolve_find_in_map(arr, parameters, mappings, conditions)?;
980 return Ok(match resolved {
981 Value::String(s) => s,
982 other => other.to_string(),
983 });
984 }
985 Ok(value.to_string())
986 }
987 _ => Ok(value.to_string()),
988 }
989}
990
991pub fn resolve_resource_properties(
993 resource: &ResourceDefinition,
994 template_body: &str,
995 parameters: &BTreeMap<String, String>,
996 resource_physical_ids: &BTreeMap<String, String>,
997) -> Result<ResourceDefinition, String> {
998 resolve_resource_properties_with_attrs(
999 resource,
1000 template_body,
1001 parameters,
1002 resource_physical_ids,
1003 &BTreeMap::new(),
1004 )
1005}
1006
1007pub fn resolve_resource_properties_with_attrs(
1010 resource: &ResourceDefinition,
1011 template_body: &str,
1012 parameters: &BTreeMap<String, String>,
1013 resource_physical_ids: &BTreeMap<String, String>,
1014 resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
1015) -> Result<ResourceDefinition, String> {
1016 let value: Value = if template_body.trim_start().starts_with('{') {
1017 serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
1018 } else {
1019 serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
1020 };
1021 let value = expand_for_each(&value, &BTreeMap::new(), parameters)?;
1024
1025 let resources_obj = value
1026 .get("Resources")
1027 .and_then(|v| v.as_object())
1028 .ok_or("Template must contain a Resources section")?;
1029
1030 let raw_props = resources_obj
1031 .get(&resource.logical_id)
1032 .and_then(|r| r.get("Properties"))
1033 .cloned()
1034 .unwrap_or(Value::Object(serde_json::Map::new()));
1035
1036 let conditions = evaluate_conditions(&value, parameters)?;
1041 let mappings = parse_mappings(&value);
1042 let raw_props = apply_mappings(&raw_props, parameters, &mappings, &conditions)?;
1043
1044 let resolved = resolve_refs_full(
1045 &raw_props,
1046 parameters,
1047 resources_obj,
1048 resource_physical_ids,
1049 resource_attributes,
1050 &BTreeMap::new(),
1051 &conditions,
1052 );
1053 let resolved = strip_no_value(resolved);
1054
1055 Ok(ResourceDefinition {
1056 logical_id: resource.logical_id.clone(),
1057 resource_type: resource.resource_type.clone(),
1058 properties: resolved,
1059 })
1060}
1061
1062fn pseudo_value(name: &str, parameters: &BTreeMap<String, String>) -> Option<Value> {
1067 if name == "AWS::NotificationARNs" {
1072 if let Some(raw) = parameters.get(name) {
1073 if let Ok(parsed) = serde_json::from_str::<Vec<String>>(raw) {
1074 return Some(Value::Array(
1075 parsed.into_iter().map(Value::String).collect(),
1076 ));
1077 }
1078 }
1079 return Some(Value::Array(Vec::new()));
1080 }
1081 if let Some(v) = parameters.get(name) {
1082 return Some(Value::String(v.clone()));
1083 }
1084 let region = parameters
1085 .get("AWS::Region")
1086 .map(String::as_str)
1087 .unwrap_or("us-east-1");
1088 match name {
1089 "AWS::Partition" => Some(Value::String(partition_for_region(region).to_string())),
1093 "AWS::URLSuffix" => Some(Value::String(url_suffix_for_region(region).to_string())),
1094 "AWS::Region" => Some(Value::String(region.to_string())),
1095 "AWS::NoValue" => Some(no_value_sentinel()),
1100 _ => None,
1101 }
1102}
1103
1104pub(crate) fn partition_for_region(region: &str) -> &'static str {
1109 if region.starts_with("cn-") {
1110 "aws-cn"
1111 } else if region.starts_with("us-gov-") {
1112 "aws-us-gov"
1113 } else {
1114 "aws"
1115 }
1116}
1117
1118pub(crate) fn url_suffix_for_region(region: &str) -> &'static str {
1122 if region.starts_with("cn-") {
1123 "amazonaws.com.cn"
1124 } else {
1125 "amazonaws.com"
1126 }
1127}
1128
1129fn no_value_sentinel() -> Value {
1132 let mut m = serde_json::Map::new();
1133 m.insert(NO_VALUE_SENTINEL_KEY.to_string(), Value::Bool(true));
1134 Value::Object(m)
1135}
1136
1137fn is_no_value(value: &Value) -> bool {
1140 value
1141 .as_object()
1142 .map(|m| m.len() == 1 && m.contains_key(NO_VALUE_SENTINEL_KEY))
1143 .unwrap_or(false)
1144}
1145
1146fn strip_no_value(value: Value) -> Value {
1151 match value {
1152 Value::Object(map) => {
1153 if is_no_value(&Value::Object(map.clone())) {
1154 return Value::Null;
1155 }
1156 let mut out = serde_json::Map::with_capacity(map.len());
1157 for (k, v) in map {
1158 if is_no_value(&v) {
1159 continue;
1160 }
1161 out.insert(k, strip_no_value(v));
1162 }
1163 Value::Object(out)
1164 }
1165 Value::Array(arr) => Value::Array(
1166 arr.into_iter()
1167 .filter(|v| !is_no_value(v))
1168 .map(strip_no_value)
1169 .collect(),
1170 ),
1171 other => other,
1172 }
1173}
1174
1175#[cfg(test)]
1180fn resolve_refs(
1181 value: &Value,
1182 parameters: &BTreeMap<String, String>,
1183 _resources: &serde_json::Map<String, Value>,
1184 resource_physical_ids: &BTreeMap<String, String>,
1185 resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
1186) -> Value {
1187 resolve_refs_full(
1188 value,
1189 parameters,
1190 _resources,
1191 resource_physical_ids,
1192 resource_attributes,
1193 &BTreeMap::new(),
1194 &BTreeMap::new(),
1195 )
1196}
1197
1198fn resolve_refs_full(
1201 value: &Value,
1202 parameters: &BTreeMap<String, String>,
1203 _resources: &serde_json::Map<String, Value>,
1204 resource_physical_ids: &BTreeMap<String, String>,
1205 resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
1206 imports: &BTreeMap<String, String>,
1207 conditions: &BTreeMap<String, bool>,
1208) -> Value {
1209 if let Some(map) = value.as_object() {
1213 if let Some(arr) = map.get("Fn::If").and_then(|v| v.as_array()) {
1214 if arr.len() == 3 {
1215 let cond_name = arr[0].as_str().unwrap_or("");
1216 let picked = if conditions.get(cond_name).copied().unwrap_or(false) {
1217 &arr[1]
1218 } else {
1219 &arr[2]
1220 };
1221 return resolve_refs_full(
1222 picked,
1223 parameters,
1224 _resources,
1225 resource_physical_ids,
1226 resource_attributes,
1227 imports,
1228 conditions,
1229 );
1230 }
1231 }
1232 }
1233 match value {
1234 Value::Object(map) => {
1235 if let Some(ref_val) = map.get("Ref") {
1236 if let Some(ref_name) = ref_val.as_str() {
1237 if PSEUDO_REFS.contains(&ref_name) {
1243 if let Some(v) = pseudo_value(ref_name, parameters) {
1244 return v;
1245 }
1246 return Value::String(ref_name.to_string());
1247 }
1248 if let Some(param_val) = parameters.get(ref_name) {
1250 return Value::String(param_val.clone());
1251 }
1252 if let Some(physical_id) = resource_physical_ids.get(ref_name) {
1254 return Value::String(physical_id.clone());
1255 }
1256 if _resources.contains_key(ref_name) {
1260 return Value::String(ref_name.to_string());
1261 }
1262 return Value::String(ref_name.to_string());
1264 }
1265 }
1266 if let Some(import_val) = map.get("Fn::ImportValue") {
1270 let resolved = resolve_refs_full(
1271 import_val,
1272 parameters,
1273 _resources,
1274 resource_physical_ids,
1275 resource_attributes,
1276 imports,
1277 conditions,
1278 );
1279 let key = match &resolved {
1280 Value::String(s) => s.clone(),
1281 other => other.to_string(),
1282 };
1283 if let Some(v) = imports.get(&key) {
1284 return Value::String(v.clone());
1285 }
1286 return Value::String(String::new());
1287 }
1288 if let Some(getatt_val) = map.get("Fn::GetAtt") {
1289 if let Some((logical_id, attr_name)) = parse_getatt(getatt_val) {
1290 if let Some(attrs) = resource_attributes.get(&logical_id) {
1291 if let Some(attr_value) = attrs.get(&attr_name) {
1292 return Value::String(attr_value.clone());
1293 }
1294 }
1295 return Value::String(format!("{logical_id}.{attr_name}"));
1299 }
1300 }
1301 if let Some(join_val) = map.get("Fn::Join") {
1302 if let Some(arr) = join_val.as_array() {
1303 if arr.len() == 2 {
1304 let delimiter = arr[0].as_str().unwrap_or("");
1305 if let Some(parts) = arr[1].as_array() {
1306 let resolved_parts: Vec<String> = parts
1307 .iter()
1308 .map(|p| {
1309 let resolved = resolve_refs_full(
1310 p,
1311 parameters,
1312 _resources,
1313 resource_physical_ids,
1314 resource_attributes,
1315 imports,
1316 conditions,
1317 );
1318 match resolved {
1319 Value::String(s) => s,
1320 other => other.to_string(),
1321 }
1322 })
1323 .collect();
1324 return Value::String(resolved_parts.join(delimiter));
1325 }
1326 }
1327 }
1328 }
1329 if let Some(b64_val) = map.get("Fn::Base64") {
1332 let resolved = resolve_refs_full(
1333 b64_val,
1334 parameters,
1335 _resources,
1336 resource_physical_ids,
1337 resource_attributes,
1338 imports,
1339 conditions,
1340 );
1341 let s = match &resolved {
1342 Value::String(s) => s.clone(),
1343 other => other.to_string(),
1344 };
1345 return Value::String(
1346 base64::engine::general_purpose::STANDARD.encode(s.as_bytes()),
1347 );
1348 }
1349 if let Some(len_val) = map.get("Fn::Length") {
1354 let resolved = resolve_refs_full(
1355 len_val,
1356 parameters,
1357 _resources,
1358 resource_physical_ids,
1359 resource_attributes,
1360 imports,
1361 conditions,
1362 );
1363 let n: usize = match &resolved {
1364 Value::Array(arr) => arr.len(),
1365 Value::String(s) => s.chars().count(),
1366 _ => 0,
1367 };
1368 return Value::Number(serde_json::Number::from(n));
1369 }
1370 if let Some(to_json) = map.get("Fn::ToJsonString") {
1372 let resolved = resolve_refs_full(
1373 to_json,
1374 parameters,
1375 _resources,
1376 resource_physical_ids,
1377 resource_attributes,
1378 imports,
1379 conditions,
1380 );
1381 let s = serde_json::to_string(&resolved).unwrap_or_default();
1382 return Value::String(s);
1383 }
1384 if let Some(split_val) = map.get("Fn::Split") {
1387 if let Some(arr) = split_val.as_array() {
1388 if arr.len() == 2 {
1389 let delim = arr[0].as_str().unwrap_or("");
1390 let src_resolved = resolve_refs_full(
1391 &arr[1],
1392 parameters,
1393 _resources,
1394 resource_physical_ids,
1395 resource_attributes,
1396 imports,
1397 conditions,
1398 );
1399 let src = match src_resolved {
1400 Value::String(s) => s,
1401 other => other.to_string(),
1402 };
1403 let parts: Vec<Value> = src
1404 .split(delim)
1405 .map(|p| Value::String(p.to_string()))
1406 .collect();
1407 return Value::Array(parts);
1408 }
1409 }
1410 }
1411 if let Some(sel_val) = map.get("Fn::Select") {
1414 if let Some(arr) = sel_val.as_array() {
1415 if arr.len() == 2 {
1416 let idx_val = resolve_refs_full(
1417 &arr[0],
1418 parameters,
1419 _resources,
1420 resource_physical_ids,
1421 resource_attributes,
1422 imports,
1423 conditions,
1424 );
1425 let list_val = resolve_refs_full(
1426 &arr[1],
1427 parameters,
1428 _resources,
1429 resource_physical_ids,
1430 resource_attributes,
1431 imports,
1432 conditions,
1433 );
1434 let idx: usize = match &idx_val {
1435 Value::Number(n) => n.as_u64().unwrap_or(0) as usize,
1436 Value::String(s) => s.parse().unwrap_or(0),
1437 _ => 0,
1438 };
1439 if let Some(list) = list_val.as_array() {
1440 if let Some(elt) = list.get(idx) {
1441 return elt.clone();
1442 }
1443 }
1444 return Value::Null;
1445 }
1446 }
1447 }
1448 if let Some(cidr_val) = map.get("Fn::Cidr") {
1453 if let Some(arr) = cidr_val.as_array() {
1454 if arr.len() == 3 {
1455 let block_val = resolve_refs_full(
1456 &arr[0],
1457 parameters,
1458 _resources,
1459 resource_physical_ids,
1460 resource_attributes,
1461 imports,
1462 conditions,
1463 );
1464 let count_val = resolve_refs_full(
1465 &arr[1],
1466 parameters,
1467 _resources,
1468 resource_physical_ids,
1469 resource_attributes,
1470 imports,
1471 conditions,
1472 );
1473 let bits_val = resolve_refs_full(
1474 &arr[2],
1475 parameters,
1476 _resources,
1477 resource_physical_ids,
1478 resource_attributes,
1479 imports,
1480 conditions,
1481 );
1482 let block_str = match &block_val {
1483 Value::String(s) => s.clone(),
1484 other => other.to_string(),
1485 };
1486 let count: u32 = match &count_val {
1487 Value::Number(n) => n.as_u64().unwrap_or(0) as u32,
1488 Value::String(s) => s.parse().unwrap_or(0),
1489 _ => 0,
1490 };
1491 let cidr_bits: u32 = match &bits_val {
1492 Value::Number(n) => n.as_u64().unwrap_or(0) as u32,
1493 Value::String(s) => s.parse().unwrap_or(0),
1494 _ => 0,
1495 };
1496 if let Some(sub_cidrs) = compute_cidr_subnets(&block_str, count, cidr_bits)
1497 {
1498 return Value::Array(
1499 sub_cidrs.into_iter().map(Value::String).collect(),
1500 );
1501 }
1502 }
1503 }
1504 }
1505 if let Some(sub_val) = map.get("Fn::Sub") {
1506 let (template_str, extra_vars): (Option<&str>, BTreeMap<String, String>) =
1515 if let Some(s) = sub_val.as_str() {
1516 (Some(s), BTreeMap::new())
1517 } else if let Some(arr) = sub_val.as_array() {
1518 let str_part = arr.first().and_then(|v| v.as_str());
1519 let mut bindings: BTreeMap<String, String> = BTreeMap::new();
1520 if let Some(obj) = arr.get(1).and_then(|v| v.as_object()) {
1521 for (k, v) in obj {
1522 let resolved = resolve_refs_full(
1523 v,
1524 parameters,
1525 _resources,
1526 resource_physical_ids,
1527 resource_attributes,
1528 imports,
1529 conditions,
1530 );
1531 let s = match resolved {
1532 Value::String(s) => s,
1533 other => other.to_string(),
1534 };
1535 bindings.insert(k.clone(), s);
1536 }
1537 }
1538 (str_part, bindings)
1539 } else {
1540 (None, BTreeMap::new())
1541 };
1542 if let Some(s) = template_str {
1543 let mut result = s.to_string();
1544 for (k, v) in &extra_vars {
1548 result = result.replace(&format!("${{{k}}}"), v);
1549 }
1550 for pseudo in PSEUDO_REFS {
1560 let token = format!("${{{pseudo}}}");
1561 if !result.contains(&token) {
1562 continue;
1563 }
1564 if *pseudo == "AWS::NoValue" {
1565 result = result.replace(&token, "");
1568 continue;
1569 }
1570 if let Some(v) = pseudo_value(pseudo, parameters) {
1571 let s = match v {
1572 Value::String(s) => s,
1573 other => other.to_string(),
1574 };
1575 result = result.replace(&token, &s);
1576 }
1577 }
1578 for (k, v) in parameters {
1581 result = result.replace(&format!("${{{k}}}"), v);
1582 }
1583 for (k, v) in resource_physical_ids {
1586 result = result.replace(&format!("${{{k}}}"), v);
1587 }
1588 for (logical, attrs) in resource_attributes {
1590 for (attr, value) in attrs {
1591 result = result.replace(&format!("${{{logical}.{attr}}}"), value);
1592 }
1593 }
1594 return Value::String(result);
1595 }
1596 }
1597 let mut new_map = serde_json::Map::new();
1599 for (k, v) in map {
1600 new_map.insert(
1601 k.clone(),
1602 resolve_refs_full(
1603 v,
1604 parameters,
1605 _resources,
1606 resource_physical_ids,
1607 resource_attributes,
1608 imports,
1609 conditions,
1610 ),
1611 );
1612 }
1613 Value::Object(new_map)
1614 }
1615 Value::Array(arr) => Value::Array(
1616 arr.iter()
1617 .map(|v| {
1618 resolve_refs_full(
1619 v,
1620 parameters,
1621 _resources,
1622 resource_physical_ids,
1623 resource_attributes,
1624 imports,
1625 conditions,
1626 )
1627 })
1628 .collect(),
1629 ),
1630 other => other.clone(),
1631 }
1632}
1633
1634fn compute_cidr_subnets(ip_block: &str, count: u32, cidr_bits: u32) -> Option<Vec<String>> {
1639 let (ip_str, prefix_str) = ip_block.split_once('/')?;
1640 let prefix: u32 = prefix_str.parse().ok()?;
1641 let ip: std::net::Ipv4Addr = ip_str.parse().ok()?;
1642 let base: u32 = ip.into();
1643 let new_prefix = 32u32.checked_sub(cidr_bits)?;
1646 if new_prefix <= prefix {
1647 return None;
1648 }
1649 let step: u32 = 1u32 << cidr_bits;
1650 let mut out = Vec::with_capacity(count as usize);
1651 for i in 0..count {
1652 let subnet_base = base.checked_add(step.checked_mul(i)?)?;
1653 let addr = std::net::Ipv4Addr::from(subnet_base);
1654 out.push(format!("{addr}/{new_prefix}"));
1655 }
1656 Some(out)
1657}
1658
1659fn parse_getatt(value: &Value) -> Option<(String, String)> {
1663 match value {
1664 Value::Array(arr) if arr.len() >= 2 => {
1665 let logical_id = arr[0].as_str()?.to_string();
1666 let parts: Vec<String> = arr[1..]
1667 .iter()
1668 .map(|v| match v {
1669 Value::String(s) => s.clone(),
1670 other => other.to_string(),
1671 })
1672 .collect();
1673 Some((logical_id, parts.join(".")))
1674 }
1675 Value::String(s) => {
1676 let (logical_id, attr) = s.split_once('.')?;
1677 Some((logical_id.to_string(), attr.to_string()))
1678 }
1679 _ => None,
1680 }
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685 use super::*;
1686
1687 #[test]
1688 fn parse_json_template() {
1689 let template = r#"{
1690 "Resources": {
1691 "MyQueue": {
1692 "Type": "AWS::SQS::Queue",
1693 "Properties": {
1694 "QueueName": "test-queue"
1695 }
1696 }
1697 }
1698 }"#;
1699
1700 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1701 assert_eq!(parsed.resources.len(), 1);
1702 assert_eq!(parsed.resources[0].logical_id, "MyQueue");
1703 assert_eq!(parsed.resources[0].resource_type, "AWS::SQS::Queue");
1704 }
1705
1706 #[test]
1707 fn parse_yaml_template() {
1708 let template = r#"
1709Resources:
1710 MyTopic:
1711 Type: AWS::SNS::Topic
1712 Properties:
1713 TopicName: test-topic
1714"#;
1715
1716 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1717 assert_eq!(parsed.resources.len(), 1);
1718 assert_eq!(parsed.resources[0].logical_id, "MyTopic");
1719 assert_eq!(parsed.resources[0].resource_type, "AWS::SNS::Topic");
1720 }
1721
1722 #[test]
1723 fn resolve_ref_parameters() {
1724 let template = r#"{
1725 "Resources": {
1726 "MyQueue": {
1727 "Type": "AWS::SQS::Queue",
1728 "Properties": {
1729 "QueueName": { "Ref": "QueueNameParam" }
1730 }
1731 }
1732 }
1733 }"#;
1734
1735 let mut params = BTreeMap::new();
1736 params.insert("QueueNameParam".to_string(), "resolved-queue".to_string());
1737 let parsed = parse_template(template, ¶ms).unwrap();
1738 assert_eq!(
1739 parsed.resources[0].properties["QueueName"],
1740 Value::String("resolved-queue".to_string())
1741 );
1742 }
1743
1744 #[test]
1745 fn ref_resolves_physical_id_over_logical_id() {
1746 let template = r#"{
1747 "Resources": {
1748 "MyTopic": {
1749 "Type": "AWS::SNS::Topic",
1750 "Properties": {
1751 "TopicName": "my-topic"
1752 }
1753 },
1754 "MySub": {
1755 "Type": "AWS::SNS::Subscription",
1756 "Properties": {
1757 "TopicArn": { "Ref": "MyTopic" },
1758 "Protocol": "sqs",
1759 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
1760 }
1761 }
1762 }
1763 }"#;
1764
1765 let mut physical_ids = BTreeMap::new();
1766 physical_ids.insert(
1767 "MyTopic".to_string(),
1768 "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
1769 );
1770
1771 let parsed =
1772 parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
1773 let sub = parsed
1774 .resources
1775 .iter()
1776 .find(|r| r.logical_id == "MySub")
1777 .unwrap();
1778 assert_eq!(
1779 sub.properties["TopicArn"],
1780 Value::String("arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
1781 );
1782 }
1783
1784 #[test]
1785 fn ref_without_physical_id_returns_logical_id_for_known_resource() {
1786 let template = r#"{
1787 "Resources": {
1788 "MyTopic": {
1789 "Type": "AWS::SNS::Topic",
1790 "Properties": {
1791 "TopicName": "my-topic"
1792 }
1793 },
1794 "MySub": {
1795 "Type": "AWS::SNS::Subscription",
1796 "Properties": {
1797 "TopicArn": { "Ref": "MyTopic" },
1798 "Protocol": "sqs",
1799 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
1800 }
1801 }
1802 }
1803 }"#;
1804
1805 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1807 let sub = parsed
1808 .resources
1809 .iter()
1810 .find(|r| r.logical_id == "MySub")
1811 .unwrap();
1812 assert_eq!(
1813 sub.properties["TopicArn"],
1814 Value::String("MyTopic".to_string())
1815 );
1816 }
1817
1818 #[test]
1819 fn pseudo_ref_substitutes_when_param_provided() {
1820 let template = r#"{
1821 "Resources": {
1822 "MyQueue": {
1823 "Type": "AWS::SQS::Queue",
1824 "Properties": {
1825 "QueueArn": {
1826 "Fn::Join": ["", [
1827 "arn:", {"Ref": "AWS::Partition"}, ":sqs:",
1828 {"Ref": "AWS::Region"}, ":", {"Ref": "AWS::AccountId"},
1829 ":", {"Ref": "AWS::StackName"}, "-q"
1830 ]]
1831 }
1832 }
1833 }
1834 }
1835 }"#;
1836 let mut params = BTreeMap::new();
1837 params.insert("AWS::Region".to_string(), "us-west-2".to_string());
1838 params.insert("AWS::AccountId".to_string(), "111122223333".to_string());
1839 params.insert("AWS::Partition".to_string(), "aws".to_string());
1840 params.insert("AWS::StackName".to_string(), "demo".to_string());
1841
1842 let parsed = parse_template(template, ¶ms).unwrap();
1843 assert_eq!(
1844 parsed.resources[0].properties["QueueArn"],
1845 Value::String("arn:aws:sqs:us-west-2:111122223333:demo-q".to_string())
1846 );
1847 }
1848
1849 #[test]
1850 fn pseudo_ref_partition_default_when_unset() {
1851 let template = r#"{
1852 "Resources": {
1853 "MyQueue": {
1854 "Type": "AWS::SQS::Queue",
1855 "Properties": {
1856 "Partition": {"Ref": "AWS::Partition"},
1857 "Suffix": {"Ref": "AWS::URLSuffix"}
1858 }
1859 }
1860 }
1861 }"#;
1862 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1863 assert_eq!(
1864 parsed.resources[0].properties["Partition"],
1865 Value::String("aws".to_string())
1866 );
1867 assert_eq!(
1868 parsed.resources[0].properties["Suffix"],
1869 Value::String("amazonaws.com".to_string())
1870 );
1871 }
1872
1873 #[test]
1874 fn pseudo_ref_passes_through() {
1875 let template = r#"{
1876 "Resources": {
1877 "MyQueue": {
1878 "Type": "AWS::SQS::Queue",
1879 "Properties": {
1880 "QueueName": { "Ref": "AWS::StackName" }
1881 }
1882 }
1883 }
1884 }"#;
1885
1886 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1887 assert_eq!(
1888 parsed.resources[0].properties["QueueName"],
1889 Value::String("AWS::StackName".to_string())
1890 );
1891 }
1892
1893 #[test]
1896 fn bb6_ref_aws_region_returns_seeded_region() {
1897 let template = r#"{
1898 "Resources": {
1899 "Q": {
1900 "Type": "AWS::SQS::Queue",
1901 "Properties": {"Region": {"Ref": "AWS::Region"}}
1902 }
1903 }
1904 }"#;
1905 let mut params = BTreeMap::new();
1906 params.insert("AWS::Region".to_string(), "us-east-1".to_string());
1907 let parsed = parse_template(template, ¶ms).unwrap();
1908 assert_eq!(
1909 parsed.resources[0].properties["Region"],
1910 Value::String("us-east-1".to_string())
1911 );
1912 }
1913
1914 #[test]
1915 fn bb6_fn_sub_substitutes_aws_account_id() {
1916 let template = r#"{
1917 "Resources": {
1918 "Q": {
1919 "Type": "AWS::SQS::Queue",
1920 "Properties": {
1921 "Owner": {"Fn::Sub": "owner-${AWS::AccountId}"}
1922 }
1923 }
1924 }
1925 }"#;
1926 let mut params = BTreeMap::new();
1927 params.insert("AWS::AccountId".to_string(), "123456789012".to_string());
1928 let parsed = parse_template(template, ¶ms).unwrap();
1929 assert_eq!(
1930 parsed.resources[0].properties["Owner"],
1931 Value::String("owner-123456789012".to_string())
1932 );
1933 }
1934
1935 #[test]
1936 fn bb6_partition_for_china_region_is_aws_cn() {
1937 let template = r#"{
1940 "Resources": {
1941 "Q": {
1942 "Type": "AWS::SQS::Queue",
1943 "Properties": {"P": {"Ref": "AWS::Partition"}}
1944 }
1945 }
1946 }"#;
1947 let mut params = BTreeMap::new();
1948 params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
1949 let parsed = parse_template(template, ¶ms).unwrap();
1950 assert_eq!(
1951 parsed.resources[0].properties["P"],
1952 Value::String("aws-cn".to_string())
1953 );
1954 }
1955
1956 #[test]
1957 fn bb6_partition_for_govcloud_region_is_aws_us_gov() {
1958 let template = r#"{
1959 "Resources": {
1960 "Q": {
1961 "Type": "AWS::SQS::Queue",
1962 "Properties": {"P": {"Ref": "AWS::Partition"}}
1963 }
1964 }
1965 }"#;
1966 let mut params = BTreeMap::new();
1967 params.insert("AWS::Region".to_string(), "us-gov-west-1".to_string());
1968 let parsed = parse_template(template, ¶ms).unwrap();
1969 assert_eq!(
1970 parsed.resources[0].properties["P"],
1971 Value::String("aws-us-gov".to_string())
1972 );
1973 }
1974
1975 #[test]
1976 fn bb6_url_suffix_for_china_is_amazonaws_com_cn() {
1977 let template = r#"{
1978 "Resources": {
1979 "Q": {
1980 "Type": "AWS::SQS::Queue",
1981 "Properties": {"S": {"Ref": "AWS::URLSuffix"}}
1982 }
1983 }
1984 }"#;
1985 let mut params = BTreeMap::new();
1986 params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
1987 let parsed = parse_template(template, ¶ms).unwrap();
1988 assert_eq!(
1989 parsed.resources[0].properties["S"],
1990 Value::String("amazonaws.com.cn".to_string())
1991 );
1992 }
1993
1994 #[test]
1995 fn bb6_url_suffix_for_govcloud_stays_amazonaws_com() {
1996 let template = r#"{
1998 "Resources": {
1999 "Q": {
2000 "Type": "AWS::SQS::Queue",
2001 "Properties": {"S": {"Ref": "AWS::URLSuffix"}}
2002 }
2003 }
2004 }"#;
2005 let mut params = BTreeMap::new();
2006 params.insert("AWS::Region".to_string(), "us-gov-east-1".to_string());
2007 let parsed = parse_template(template, ¶ms).unwrap();
2008 assert_eq!(
2009 parsed.resources[0].properties["S"],
2010 Value::String("amazonaws.com".to_string())
2011 );
2012 }
2013
2014 #[test]
2015 fn bb6_no_value_omits_property_from_resource_input() {
2016 let template = r#"{
2019 "Resources": {
2020 "Q": {
2021 "Type": "AWS::SQS::Queue",
2022 "Properties": {
2023 "QueueName": "q",
2024 "OptionalProp": {"Ref": "AWS::NoValue"}
2025 }
2026 }
2027 }
2028 }"#;
2029 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2030 let props = parsed.resources[0].properties.as_object().unwrap();
2031 assert!(
2032 !props.contains_key("OptionalProp"),
2033 "OptionalProp should be omitted, got: {props:?}"
2034 );
2035 assert_eq!(
2036 props.get("QueueName"),
2037 Some(&Value::String("q".to_string()))
2038 );
2039 }
2040
2041 #[test]
2042 fn bb6_notification_arns_returns_seeded_array() {
2043 let template = r#"{
2047 "Resources": {
2048 "Q": {
2049 "Type": "AWS::SQS::Queue",
2050 "Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
2051 }
2052 }
2053 }"#;
2054 let mut params = BTreeMap::new();
2055 params.insert(
2056 "AWS::NotificationARNs".to_string(),
2057 r#"["arn:aws:sns:us-east-1:111122223333:topic"]"#.to_string(),
2058 );
2059 let parsed = parse_template(template, ¶ms).unwrap();
2060 assert_eq!(
2061 parsed.resources[0].properties["Targets"],
2062 serde_json::json!(["arn:aws:sns:us-east-1:111122223333:topic"])
2063 );
2064 }
2065
2066 #[test]
2067 fn bb6_notification_arns_defaults_to_empty_array() {
2068 let template = r#"{
2069 "Resources": {
2070 "Q": {
2071 "Type": "AWS::SQS::Queue",
2072 "Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
2073 }
2074 }
2075 }"#;
2076 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2077 assert_eq!(
2078 parsed.resources[0].properties["Targets"],
2079 serde_json::json!([])
2080 );
2081 }
2082
2083 #[test]
2084 fn bb6_fn_sub_array_form_substitutes_extra_vars() {
2085 let template = r#"{
2088 "Resources": {
2089 "Q": {
2090 "Type": "AWS::SQS::Queue",
2091 "Properties": {
2092 "Path": {"Fn::Sub": ["${AWS::Region}/${Suffix}", {"Suffix": "tail"}]}
2093 }
2094 }
2095 }
2096 }"#;
2097 let mut params = BTreeMap::new();
2098 params.insert("AWS::Region".to_string(), "eu-west-1".to_string());
2099 let parsed = parse_template(template, ¶ms).unwrap();
2100 assert_eq!(
2101 parsed.resources[0].properties["Path"],
2102 Value::String("eu-west-1/tail".to_string())
2103 );
2104 }
2105
2106 #[test]
2107 fn bb6_partition_helper_classifies_regions() {
2108 assert_eq!(partition_for_region("us-east-1"), "aws");
2109 assert_eq!(partition_for_region("eu-central-1"), "aws");
2110 assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
2111 assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
2112 assert_eq!(partition_for_region("us-gov-west-1"), "aws-us-gov");
2113 assert_eq!(partition_for_region("us-gov-east-1"), "aws-us-gov");
2114 }
2115
2116 #[test]
2117 fn bb6_url_suffix_helper_classifies_regions() {
2118 assert_eq!(url_suffix_for_region("us-east-1"), "amazonaws.com");
2119 assert_eq!(url_suffix_for_region("us-gov-west-1"), "amazonaws.com");
2120 assert_eq!(url_suffix_for_region("cn-north-1"), "amazonaws.com.cn");
2121 }
2122
2123 #[test]
2124 fn fn_sub_resolves_physical_ids() {
2125 let template = r#"{
2126 "Resources": {
2127 "MyTopic": {
2128 "Type": "AWS::SNS::Topic",
2129 "Properties": {
2130 "TopicName": "my-topic"
2131 }
2132 },
2133 "MyParam": {
2134 "Type": "AWS::SSM::Parameter",
2135 "Properties": {
2136 "Name": "/app/topic",
2137 "Type": "String",
2138 "Value": { "Fn::Sub": "Topic is ${MyTopic}" }
2139 }
2140 }
2141 }
2142 }"#;
2143
2144 let mut physical_ids = BTreeMap::new();
2145 physical_ids.insert(
2146 "MyTopic".to_string(),
2147 "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
2148 );
2149
2150 let parsed =
2151 parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
2152 let param = parsed
2153 .resources
2154 .iter()
2155 .find(|r| r.logical_id == "MyParam")
2156 .unwrap();
2157 assert_eq!(
2158 param.properties["Value"],
2159 Value::String("Topic is arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
2160 );
2161 }
2162
2163 #[test]
2166 fn parse_template_invalid_json_errors() {
2167 let params = BTreeMap::new();
2168 let result = parse_template("{not-json}", ¶ms);
2169 assert!(result.is_err());
2170 }
2171
2172 #[test]
2173 fn parse_template_missing_resources_errors() {
2174 let params = BTreeMap::new();
2175 let result = parse_template(r#"{"Description":"no resources"}"#, ¶ms);
2176 assert!(result.is_err());
2177 }
2178
2179 #[test]
2180 fn parse_template_resources_not_object_errors() {
2181 let params = BTreeMap::new();
2182 let result = parse_template(r#"{"Resources": []}"#, ¶ms);
2183 assert!(result.is_err());
2184 }
2185
2186 #[test]
2187 fn parse_template_missing_type_errors() {
2188 let params = BTreeMap::new();
2189 let result = parse_template(r#"{"Resources":{"R":{"Properties":{}}}}"#, ¶ms);
2190 assert!(result.is_err());
2191 }
2192
2193 #[test]
2196 fn fn_getatt_resolves_attribute_in_array_form() {
2197 let template = r#"{
2198 "Resources": {
2199 "MyQueue": {
2200 "Type": "AWS::SQS::Queue",
2201 "Properties": { "QueueName": "q1" }
2202 },
2203 "MyTopic": {
2204 "Type": "AWS::SNS::Topic",
2205 "Properties": {
2206 "TopicName": "t1",
2207 "DataProtectionPolicy": {
2208 "Fn::GetAtt": ["MyQueue", "Arn"]
2209 }
2210 }
2211 }
2212 }
2213 }"#;
2214
2215 let mut attrs = BTreeMap::new();
2216 let mut q_attrs = BTreeMap::new();
2217 q_attrs.insert(
2218 "Arn".to_string(),
2219 "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
2220 );
2221 attrs.insert("MyQueue".to_string(), q_attrs);
2222
2223 let parsed =
2224 parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
2225 .unwrap();
2226 let topic = parsed
2227 .resources
2228 .iter()
2229 .find(|r| r.logical_id == "MyTopic")
2230 .unwrap();
2231 assert_eq!(
2232 topic.properties["DataProtectionPolicy"],
2233 Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
2234 );
2235 }
2236
2237 #[test]
2238 fn fn_getatt_resolves_attribute_in_short_string_form() {
2239 let template = r#"{
2240 "Resources": {
2241 "MyTopic": {
2242 "Type": "AWS::SNS::Topic",
2243 "Properties": {
2244 "TopicName": "t1",
2245 "PolicyArn": { "Fn::GetAtt": "MyQueue.Arn" }
2246 }
2247 }
2248 }
2249 }"#;
2250
2251 let mut attrs = BTreeMap::new();
2252 let mut q_attrs = BTreeMap::new();
2253 q_attrs.insert(
2254 "Arn".to_string(),
2255 "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
2256 );
2257 attrs.insert("MyQueue".to_string(), q_attrs);
2258
2259 let parsed =
2260 parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
2261 .unwrap();
2262 assert_eq!(
2263 parsed.resources[0].properties["PolicyArn"],
2264 Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
2265 );
2266 }
2267
2268 #[test]
2269 fn fn_getatt_unknown_resource_returns_placeholder() {
2270 let template = r#"{
2271 "Resources": {
2272 "MyTopic": {
2273 "Type": "AWS::SNS::Topic",
2274 "Properties": {
2275 "TopicName": { "Fn::GetAtt": ["MyQueue", "Arn"] }
2276 }
2277 }
2278 }
2279 }"#;
2280
2281 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2282 assert_eq!(
2285 parsed.resources[0].properties["TopicName"],
2286 Value::String("MyQueue.Arn".to_string())
2287 );
2288 }
2289
2290 #[test]
2291 fn fn_getatt_inside_fn_join_resolves() {
2292 let template = r#"{
2293 "Resources": {
2294 "MyParam": {
2295 "Type": "AWS::SSM::Parameter",
2296 "Properties": {
2297 "Name": "/app/q",
2298 "Type": "String",
2299 "Value": {
2300 "Fn::Join": [":", ["queue", { "Fn::GetAtt": ["MyQueue", "Arn"] }]]
2301 }
2302 }
2303 }
2304 }
2305 }"#;
2306
2307 let mut attrs = BTreeMap::new();
2308 let mut q_attrs = BTreeMap::new();
2309 q_attrs.insert(
2310 "Arn".to_string(),
2311 "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
2312 );
2313 attrs.insert("MyQueue".to_string(), q_attrs);
2314
2315 let parsed =
2316 parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
2317 .unwrap();
2318 assert_eq!(
2319 parsed.resources[0].properties["Value"],
2320 Value::String("queue:arn:aws:sqs:us-east-1:123456789012:q1".to_string())
2321 );
2322 }
2323
2324 #[test]
2325 fn fn_sub_resolves_getatt_style_substitution() {
2326 let template = r#"{
2327 "Resources": {
2328 "MyParam": {
2329 "Type": "AWS::SSM::Parameter",
2330 "Properties": {
2331 "Name": "/app/q",
2332 "Type": "String",
2333 "Value": { "Fn::Sub": "Queue arn is ${MyQueue.Arn}" }
2334 }
2335 }
2336 }
2337 }"#;
2338
2339 let mut attrs = BTreeMap::new();
2340 let mut q_attrs = BTreeMap::new();
2341 q_attrs.insert(
2342 "Arn".to_string(),
2343 "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
2344 );
2345 attrs.insert("MyQueue".to_string(), q_attrs);
2346
2347 let parsed =
2348 parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
2349 .unwrap();
2350 assert_eq!(
2351 parsed.resources[0].properties["Value"],
2352 Value::String("Queue arn is arn:aws:sqs:us-east-1:123456789012:q1".to_string())
2353 );
2354 }
2355
2356 #[test]
2357 fn parse_template_with_description() {
2358 let params = BTreeMap::new();
2359 let parsed = parse_template(
2360 r#"{"Description":"My template","Resources":{"R":{"Type":"AWS::SQS::Queue"}}}"#,
2361 ¶ms,
2362 )
2363 .unwrap();
2364 assert_eq!(parsed.description.as_deref(), Some("My template"));
2365 assert_eq!(parsed.resources.len(), 1);
2366 }
2367
2368 type EmptyCtx = (
2369 BTreeMap<String, String>,
2370 serde_json::Map<String, Value>,
2371 BTreeMap<String, String>,
2372 BTreeMap<String, BTreeMap<String, String>>,
2373 );
2374
2375 fn empty() -> EmptyCtx {
2376 (
2377 BTreeMap::new(),
2378 serde_json::Map::new(),
2379 BTreeMap::new(),
2380 BTreeMap::new(),
2381 )
2382 }
2383
2384 #[test]
2385 fn fn_base64_encodes_string() {
2386 let (p, r, ids, attrs) = empty();
2387 let v: Value = serde_json::from_str(r#"{"Fn::Base64": "hello"}"#).unwrap();
2388 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2389 assert_eq!(resolved, Value::String("aGVsbG8=".to_string()));
2390 }
2391
2392 #[test]
2393 fn fn_split_emits_array() {
2394 let (p, r, ids, attrs) = empty();
2395 let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "a,b,c"]}"#).unwrap();
2396 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2397 assert_eq!(resolved, serde_json::json!(["a", "b", "c"]));
2398 }
2399
2400 #[test]
2401 fn fn_select_picks_index() {
2402 let (p, r, ids, attrs) = empty();
2403 let v: Value =
2404 serde_json::from_str(r#"{"Fn::Select": [1, {"Fn::Split": [",", "a,b,c"]}]}"#).unwrap();
2405 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2406 assert_eq!(resolved, Value::String("b".to_string()));
2407 }
2408
2409 #[test]
2410 fn fn_length_counts_array() {
2411 let (p, r, ids, attrs) = empty();
2412 let v: Value = serde_json::from_str(r#"{"Fn::Length": [1,2,3,4]}"#).unwrap();
2413 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2414 assert_eq!(resolved, Value::Number(4.into()));
2415 }
2416
2417 #[test]
2418 fn fn_to_json_string_serializes() {
2419 let (p, r, ids, attrs) = empty();
2420 let v: Value =
2421 serde_json::from_str(r#"{"Fn::ToJsonString": {"a": 1, "b": [2, 3]}}"#).unwrap();
2422 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2423 let s = resolved.as_str().unwrap();
2424 let parsed: Value = serde_json::from_str(s).unwrap();
2426 assert_eq!(parsed["a"], serde_json::json!(1));
2427 assert_eq!(parsed["b"], serde_json::json!([2, 3]));
2428 }
2429
2430 #[test]
2431 fn fn_cidr_carves_subnets() {
2432 let (p, r, ids, attrs) = empty();
2433 let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 4, 8]}"#).unwrap();
2435 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2436 assert_eq!(
2437 resolved,
2438 serde_json::json!(["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",])
2439 );
2440 }
2441
2442 #[test]
2443 fn condition_skips_resource_when_false() {
2444 let template = r#"{
2445 "Parameters": {"Env": {"Type": "String"}},
2446 "Conditions": {
2447 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
2448 },
2449 "Resources": {
2450 "ProdQueue": {
2451 "Type": "AWS::SQS::Queue",
2452 "Condition": "IsProd",
2453 "Properties": {"QueueName": "prod-q"}
2454 },
2455 "AlwaysQueue": {
2456 "Type": "AWS::SQS::Queue",
2457 "Properties": {"QueueName": "always-q"}
2458 }
2459 }
2460 }"#;
2461 let mut params = BTreeMap::new();
2462 params.insert("Env".to_string(), "dev".to_string());
2463 let parsed = parse_template(template, ¶ms).unwrap();
2464 let names: Vec<&str> = parsed
2465 .resources
2466 .iter()
2467 .map(|r| r.logical_id.as_str())
2468 .collect();
2469 assert!(names.contains(&"AlwaysQueue"));
2470 assert!(!names.contains(&"ProdQueue"));
2471 }
2472
2473 #[test]
2474 fn condition_includes_resource_when_true() {
2475 let template = r#"{
2476 "Parameters": {"Env": {"Type": "String"}},
2477 "Conditions": {
2478 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
2479 },
2480 "Resources": {
2481 "ProdQueue": {
2482 "Type": "AWS::SQS::Queue",
2483 "Condition": "IsProd",
2484 "Properties": {"QueueName": "prod-q"}
2485 }
2486 }
2487 }"#;
2488 let mut params = BTreeMap::new();
2489 params.insert("Env".to_string(), "prod".to_string());
2490 let parsed = parse_template(template, ¶ms).unwrap();
2491 assert_eq!(parsed.resources.len(), 1);
2492 }
2493
2494 #[test]
2495 fn fn_if_picks_branch_based_on_condition() {
2496 let template = r#"{
2497 "Parameters": {"Env": {"Type": "String"}},
2498 "Conditions": {
2499 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
2500 },
2501 "Resources": {
2502 "Q": {
2503 "Type": "AWS::SQS::Queue",
2504 "Properties": {
2505 "QueueName": {"Fn::If": ["IsProd", "prod-q", "dev-q"]}
2506 }
2507 }
2508 }
2509 }"#;
2510 let mut params = BTreeMap::new();
2511 params.insert("Env".to_string(), "dev".to_string());
2512 let parsed = parse_template(template, ¶ms).unwrap();
2513 assert_eq!(
2514 parsed.resources[0].properties["QueueName"],
2515 Value::String("dev-q".to_string())
2516 );
2517 }
2518
2519 #[test]
2520 fn fn_and_or_not_combine_conditions() {
2521 let template = r#"{
2522 "Parameters": {"Env": {"Type": "String"}, "Region": {"Type": "String"}},
2523 "Conditions": {
2524 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
2525 "IsUsEast": {"Fn::Equals": [{"Ref": "Region"}, "us-east-1"]},
2526 "IsProdInUsEast": {"Fn::And": [{"Condition": "IsProd"}, {"Condition": "IsUsEast"}]},
2527 "IsNotProd": {"Fn::Not": [{"Condition": "IsProd"}]},
2528 "IsAny": {"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "IsNotProd"}]}
2529 },
2530 "Resources": {
2531 "Q": {
2532 "Type": "AWS::SQS::Queue",
2533 "Properties": {
2534 "P1": {"Fn::If": ["IsProdInUsEast", "yes", "no"]},
2535 "P2": {"Fn::If": ["IsNotProd", "yes", "no"]},
2536 "P3": {"Fn::If": ["IsAny", "yes", "no"]}
2537 }
2538 }
2539 }
2540 }"#;
2541 let mut params = BTreeMap::new();
2542 params.insert("Env".to_string(), "prod".to_string());
2543 params.insert("Region".to_string(), "us-east-1".to_string());
2544 let parsed = parse_template(template, ¶ms).unwrap();
2545 let p = &parsed.resources[0].properties;
2546 assert_eq!(p["P1"], Value::String("yes".to_string()));
2547 assert_eq!(p["P2"], Value::String("no".to_string()));
2548 assert_eq!(p["P3"], Value::String("yes".to_string()));
2549 }
2550
2551 #[test]
2552 fn fn_find_in_map_resolves_leaf_value() {
2553 let template = r#"{
2554 "Mappings": {
2555 "RegionMap": {
2556 "us-east-1": {"AMI": "ami-east"},
2557 "us-west-2": {"AMI": "ami-west"}
2558 }
2559 },
2560 "Resources": {
2561 "Inst": {
2562 "Type": "AWS::EC2::Instance",
2563 "Properties": {
2564 "ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
2565 }
2566 }
2567 }
2568 }"#;
2569 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2570 assert_eq!(
2571 parsed.resources[0].properties["ImageId"],
2572 Value::String("ami-east".to_string())
2573 );
2574 }
2575
2576 #[test]
2577 fn fn_find_in_map_resolves_keys_via_ref() {
2578 let template = r#"{
2579 "Parameters": {"Region": {"Type": "String"}},
2580 "Mappings": {
2581 "RegionMap": {
2582 "us-east-1": {"AMI": "ami-east"},
2583 "us-west-2": {"AMI": "ami-west"}
2584 }
2585 },
2586 "Resources": {
2587 "Inst": {
2588 "Type": "AWS::EC2::Instance",
2589 "Properties": {
2590 "ImageId": {"Fn::FindInMap": ["RegionMap", {"Ref": "Region"}, "AMI"]}
2591 }
2592 }
2593 }
2594 }"#;
2595 let mut params = BTreeMap::new();
2596 params.insert("Region".to_string(), "us-west-2".to_string());
2597 let parsed = parse_template(template, ¶ms).unwrap();
2598 assert_eq!(
2599 parsed.resources[0].properties["ImageId"],
2600 Value::String("ami-west".to_string())
2601 );
2602 }
2603
2604 #[test]
2605 fn fn_find_in_map_unknown_keys_returns_error() {
2606 let template = r#"{
2607 "Mappings": {
2608 "RegionMap": {
2609 "us-east-1": {"AMI": "ami-east"}
2610 }
2611 },
2612 "Resources": {
2613 "Inst": {
2614 "Type": "AWS::EC2::Instance",
2615 "Properties": {
2616 "ImageId": {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]}
2617 }
2618 }
2619 }
2620 }"#;
2621 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2622 assert!(
2623 err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
2624 "got: {err}"
2625 );
2626 }
2627
2628 #[test]
2629 fn fn_find_in_map_four_arg_returns_default_when_missing() {
2630 let template = r#"{
2631 "Mappings": {
2632 "RegionMap": {
2633 "us-east-1": {"AMI": "ami-east"}
2634 }
2635 },
2636 "Resources": {
2637 "Inst": {
2638 "Type": "AWS::EC2::Instance",
2639 "Properties": {
2640 "ImageId": {"Fn::FindInMap": [
2641 "RegionMap",
2642 "ap-south-1",
2643 "AMI",
2644 {"DefaultValue": "ami-fallback"}
2645 ]}
2646 }
2647 }
2648 }
2649 }"#;
2650 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2651 assert_eq!(
2652 parsed.resources[0].properties["ImageId"],
2653 Value::String("ami-fallback".to_string())
2654 );
2655 }
2656
2657 #[test]
2658 fn fn_find_in_map_four_arg_prefers_match_over_default() {
2659 let template = r#"{
2660 "Mappings": {
2661 "RegionMap": {
2662 "us-east-1": {"AMI": "ami-east"}
2663 }
2664 },
2665 "Resources": {
2666 "Inst": {
2667 "Type": "AWS::EC2::Instance",
2668 "Properties": {
2669 "ImageId": {"Fn::FindInMap": [
2670 "RegionMap",
2671 "us-east-1",
2672 "AMI",
2673 {"DefaultValue": "ami-fallback"}
2674 ]}
2675 }
2676 }
2677 }
2678 }"#;
2679 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2680 assert_eq!(
2681 parsed.resources[0].properties["ImageId"],
2682 Value::String("ami-east".to_string())
2683 );
2684 }
2685
2686 #[test]
2687 fn fn_find_in_map_default_value_is_resolved_intrinsic() {
2688 let template = r#"{
2689 "Parameters": {"Fallback": {"Type": "String"}},
2690 "Mappings": {
2691 "RegionMap": {
2692 "us-east-1": {"AMI": "ami-east"}
2693 }
2694 },
2695 "Resources": {
2696 "Inst": {
2697 "Type": "AWS::EC2::Instance",
2698 "Properties": {
2699 "ImageId": {"Fn::FindInMap": [
2700 "RegionMap",
2701 "ap-south-1",
2702 "AMI",
2703 {"DefaultValue": {"Ref": "Fallback"}}
2704 ]}
2705 }
2706 }
2707 }
2708 }"#;
2709 let mut params = BTreeMap::new();
2710 params.insert("Fallback".to_string(), "ami-default".to_string());
2711 let parsed = parse_template(template, ¶ms).unwrap();
2712 assert_eq!(
2713 parsed.resources[0].properties["ImageId"],
2714 Value::String("ami-default".to_string())
2715 );
2716 }
2717
2718 #[test]
2719 fn fn_find_in_map_unknown_map_name_errors() {
2720 let template = r#"{
2721 "Mappings": {
2722 "RegionMap": {
2723 "us-east-1": {"AMI": "ami-east"}
2724 }
2725 },
2726 "Resources": {
2727 "Inst": {
2728 "Type": "AWS::EC2::Instance",
2729 "Properties": {
2730 "ImageId": {"Fn::FindInMap": ["DoesNotExist", "us-east-1", "AMI"]}
2731 }
2732 }
2733 }
2734 }"#;
2735 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2736 assert!(
2737 err.contains("Unable to get mapping for DoesNotExist::us-east-1::AMI"),
2738 "got: {err}"
2739 );
2740 }
2741
2742 #[test]
2743 fn fn_find_in_map_wrong_arg_count_errors() {
2744 let template = r#"{
2745 "Mappings": {"M": {"a": {"b": "c"}}},
2746 "Resources": {
2747 "Q": {
2748 "Type": "AWS::SQS::Queue",
2749 "Properties": {
2750 "QueueName": {"Fn::FindInMap": ["M", "a"]}
2751 }
2752 }
2753 }
2754 }"#;
2755 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2756 assert!(
2757 err.contains("Fn::FindInMap requires 3 or 4 arguments"),
2758 "got: {err}"
2759 );
2760 }
2761
2762 #[test]
2763 fn fn_find_in_map_resolves_via_pseudo_region() {
2764 let template = r#"{
2765 "Mappings": {
2766 "RegionMap": {
2767 "us-east-1": {"AMI": "ami-east"},
2768 "us-west-2": {"AMI": "ami-west"}
2769 }
2770 },
2771 "Resources": {
2772 "Inst": {
2773 "Type": "AWS::EC2::Instance",
2774 "Properties": {
2775 "ImageId": {"Fn::FindInMap": [
2776 "RegionMap",
2777 {"Ref": "AWS::Region"},
2778 "AMI"
2779 ]}
2780 }
2781 }
2782 }
2783 }"#;
2784 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2787 assert_eq!(
2788 parsed.resources[0].properties["ImageId"],
2789 Value::String("ami-east".to_string())
2790 );
2791 }
2792
2793 #[test]
2794 fn fn_find_in_map_in_unused_if_branch_does_not_error() {
2795 let template = r#"{
2801 "Parameters": {"WantAlt": {"Type": "String"}},
2802 "Conditions": {
2803 "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
2804 },
2805 "Mappings": {
2806 "RegionMap": {
2807 "us-east-1": {"AMI": "ami-east"}
2808 }
2809 },
2810 "Resources": {
2811 "Inst": {
2812 "Type": "AWS::EC2::Instance",
2813 "Properties": {
2814 "ImageId": {"Fn::If": [
2815 "UseAlt",
2816 {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
2817 {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
2818 ]}
2819 }
2820 }
2821 }
2822 }"#;
2823 let mut params = BTreeMap::new();
2824 params.insert("WantAlt".to_string(), "no".to_string());
2825 let parsed = parse_template(template, ¶ms).unwrap();
2826 assert_eq!(
2827 parsed.resources[0].properties["ImageId"],
2828 Value::String("ami-east".to_string())
2829 );
2830 }
2831
2832 #[test]
2833 fn fn_find_in_map_in_active_if_branch_still_errors_on_miss() {
2834 let template = r#"{
2837 "Parameters": {"WantAlt": {"Type": "String"}},
2838 "Conditions": {
2839 "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
2840 },
2841 "Mappings": {
2842 "RegionMap": {
2843 "us-east-1": {"AMI": "ami-east"}
2844 }
2845 },
2846 "Resources": {
2847 "Inst": {
2848 "Type": "AWS::EC2::Instance",
2849 "Properties": {
2850 "ImageId": {"Fn::If": [
2851 "UseAlt",
2852 {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
2853 {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
2854 ]}
2855 }
2856 }
2857 }
2858 }"#;
2859 let mut params = BTreeMap::new();
2860 params.insert("WantAlt".to_string(), "yes".to_string());
2861 let err = parse_template(template, ¶ms).unwrap_err();
2862 assert!(
2863 err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
2864 "got: {err}"
2865 );
2866 }
2867
2868 #[test]
2869 fn fn_find_in_map_alongside_ref_and_sub_still_resolve() {
2870 let template = r#"{
2871 "Parameters": {"Env": {"Type": "String"}},
2872 "Mappings": {
2873 "EnvMap": {
2874 "prod": {"Suffix": "live"},
2875 "dev": {"Suffix": "test"}
2876 }
2877 },
2878 "Resources": {
2879 "Q": {
2880 "Type": "AWS::SQS::Queue",
2881 "Properties": {
2882 "QueueName": {"Fn::FindInMap": ["EnvMap", {"Ref": "Env"}, "Suffix"]},
2883 "Tags": [
2884 {"Key": "EnvRef", "Value": {"Ref": "Env"}},
2885 {"Key": "Subbed", "Value": {"Fn::Sub": "env-${Env}"}}
2886 ]
2887 }
2888 }
2889 }
2890 }"#;
2891 let mut params = BTreeMap::new();
2892 params.insert("Env".to_string(), "prod".to_string());
2893 let parsed = parse_template(template, ¶ms).unwrap();
2894 let p = &parsed.resources[0].properties;
2895 assert_eq!(p["QueueName"], Value::String("live".to_string()));
2896 assert_eq!(p["Tags"][0]["Value"], Value::String("prod".to_string()));
2897 assert_eq!(p["Tags"][1]["Value"], Value::String("env-prod".to_string()));
2898 }
2899
2900 #[test]
2903 fn cyclic_conditions_self_reference_errors() {
2904 let template = r#"{
2905 "Conditions": {
2906 "A": {"Condition": "A"}
2907 },
2908 "Resources": {
2909 "Q": {
2910 "Type": "AWS::SQS::Queue",
2911 "Condition": "A",
2912 "Properties": {"QueueName": "q"}
2913 }
2914 }
2915 }"#;
2916 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2917 assert!(err.contains("Circular reference"), "got: {err}");
2918 assert!(err.contains("'A'"), "got: {err}");
2919 }
2920
2921 #[test]
2922 fn cyclic_conditions_two_step_errors() {
2923 let template = r#"{
2924 "Conditions": {
2925 "A": {"Condition": "B"},
2926 "B": {"Condition": "A"}
2927 },
2928 "Resources": {
2929 "Q": {
2930 "Type": "AWS::SQS::Queue",
2931 "Condition": "A",
2932 "Properties": {"QueueName": "q"}
2933 }
2934 }
2935 }"#;
2936 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2937 assert!(err.contains("Circular reference"), "got: {err}");
2938 }
2939
2940 #[test]
2941 fn condition_referencing_undefined_name_errors() {
2942 let template = r#"{
2943 "Conditions": {
2944 "A": {"Condition": "DoesNotExist"}
2945 },
2946 "Resources": {
2947 "Q": {
2948 "Type": "AWS::SQS::Queue",
2949 "Condition": "A",
2950 "Properties": {"QueueName": "q"}
2951 }
2952 }
2953 }"#;
2954 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2955 assert!(err.contains("DoesNotExist"), "got: {err}");
2956 }
2957
2958 #[test]
2959 fn fn_if_no_value_removes_property_from_parent_map() {
2960 let template = r#"{
2961 "Parameters": {"WantTags": {"Type": "String"}},
2962 "Conditions": {
2963 "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
2964 },
2965 "Resources": {
2966 "Q": {
2967 "Type": "AWS::SQS::Queue",
2968 "Properties": {
2969 "QueueName": "q",
2970 "Tags": {"Fn::If": [
2971 "HasTags",
2972 [{"Key": "a", "Value": "b"}],
2973 {"Ref": "AWS::NoValue"}
2974 ]}
2975 }
2976 }
2977 }
2978 }"#;
2979 let mut params = BTreeMap::new();
2980 params.insert("WantTags".to_string(), "no".to_string());
2981 let parsed = parse_template(template, ¶ms).unwrap();
2982 let props = parsed.resources[0].properties.as_object().unwrap();
2983 assert!(
2984 !props.contains_key("Tags"),
2985 "Tags should be omitted when AWS::NoValue picked, got: {props:?}"
2986 );
2987 assert_eq!(
2988 props.get("QueueName"),
2989 Some(&Value::String("q".to_string()))
2990 );
2991 }
2992
2993 #[test]
2994 fn fn_if_no_value_keeps_property_when_branch_concrete() {
2995 let template = r#"{
2996 "Parameters": {"WantTags": {"Type": "String"}},
2997 "Conditions": {
2998 "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
2999 },
3000 "Resources": {
3001 "Q": {
3002 "Type": "AWS::SQS::Queue",
3003 "Properties": {
3004 "QueueName": "q",
3005 "Tags": {"Fn::If": [
3006 "HasTags",
3007 [{"Key": "a", "Value": "b"}],
3008 {"Ref": "AWS::NoValue"}
3009 ]}
3010 }
3011 }
3012 }
3013 }"#;
3014 let mut params = BTreeMap::new();
3015 params.insert("WantTags".to_string(), "yes".to_string());
3016 let parsed = parse_template(template, ¶ms).unwrap();
3017 let tags = &parsed.resources[0].properties["Tags"];
3018 assert_eq!(
3019 tags,
3020 &serde_json::json!([{"Key": "a", "Value": "b"}]),
3021 "tags should be the true branch's array"
3022 );
3023 }
3024
3025 #[test]
3026 fn fn_if_no_value_in_array_drops_element() {
3027 let template = r#"{
3028 "Parameters": {"Extra": {"Type": "String"}},
3029 "Conditions": {
3030 "HasExtra": {"Fn::Equals": [{"Ref": "Extra"}, "yes"]}
3031 },
3032 "Resources": {
3033 "Q": {
3034 "Type": "AWS::SQS::Queue",
3035 "Properties": {
3036 "Items": [
3037 "first",
3038 {"Fn::If": ["HasExtra", "second", {"Ref": "AWS::NoValue"}]},
3039 "third"
3040 ]
3041 }
3042 }
3043 }
3044 }"#;
3045 let mut params = BTreeMap::new();
3046 params.insert("Extra".to_string(), "no".to_string());
3047 let parsed = parse_template(template, ¶ms).unwrap();
3048 assert_eq!(
3049 parsed.resources[0].properties["Items"],
3050 serde_json::json!(["first", "third"])
3051 );
3052 }
3053
3054 #[test]
3055 fn condition_skips_output_when_false() {
3056 let template = r#"{
3057 "Parameters": {"Env": {"Type": "String"}},
3058 "Conditions": {
3059 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
3060 },
3061 "Resources": {
3062 "Q": {
3063 "Type": "AWS::SQS::Queue",
3064 "Properties": {"QueueName": "q"}
3065 }
3066 },
3067 "Outputs": {
3068 "ProdName": {
3069 "Condition": "IsProd",
3070 "Value": "prod-only"
3071 },
3072 "Always": {
3073 "Value": "shown"
3074 }
3075 }
3076 }"#;
3077 let mut params = BTreeMap::new();
3078 params.insert("Env".to_string(), "dev".to_string());
3079 let parsed = parse_template(template, ¶ms).unwrap();
3080 let names: Vec<&str> = parsed
3081 .outputs
3082 .iter()
3083 .map(|o| o.logical_id.as_str())
3084 .collect();
3085 assert!(names.contains(&"Always"));
3086 assert!(!names.contains(&"ProdName"));
3087 }
3088
3089 #[test]
3090 fn fn_and_short_circuits_on_false() {
3091 let template = r#"{
3092 "Parameters": {"Env": {"Type": "String"}},
3093 "Conditions": {
3094 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
3095 "Combined": {"Fn::And": [
3096 {"Condition": "IsProd"},
3097 {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
3098 ]}
3099 },
3100 "Resources": {
3101 "Q": {
3102 "Type": "AWS::SQS::Queue",
3103 "Condition": "Combined",
3104 "Properties": {"QueueName": "q"}
3105 }
3106 }
3107 }"#;
3108 let mut params = BTreeMap::new();
3109 params.insert("Env".to_string(), "dev".to_string());
3110 let parsed = parse_template(template, ¶ms).unwrap();
3111 assert_eq!(parsed.resources.len(), 0);
3112 }
3113
3114 #[test]
3115 fn fn_or_short_circuits_on_true() {
3116 let template = r#"{
3117 "Parameters": {"Env": {"Type": "String"}},
3118 "Conditions": {
3119 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
3120 "AnyEnv": {"Fn::Or": [
3121 {"Condition": "IsProd"},
3122 {"Fn::Equals": [{"Ref": "Env"}, "dev"]},
3123 {"Fn::Equals": [{"Ref": "Env"}, "stage"]}
3124 ]}
3125 },
3126 "Resources": {
3127 "Q": {
3128 "Type": "AWS::SQS::Queue",
3129 "Condition": "AnyEnv",
3130 "Properties": {"QueueName": "q"}
3131 }
3132 }
3133 }"#;
3134 let mut params = BTreeMap::new();
3135 params.insert("Env".to_string(), "stage".to_string());
3136 let parsed = parse_template(template, ¶ms).unwrap();
3137 assert_eq!(parsed.resources.len(), 1);
3138 }
3139
3140 #[test]
3141 fn fn_and_rejects_arity_outside_1_to_10() {
3142 let template = r#"{
3143 "Conditions": {
3144 "Empty": {"Fn::And": []}
3145 },
3146 "Resources": {
3147 "Q": {
3148 "Type": "AWS::SQS::Queue",
3149 "Condition": "Empty",
3150 "Properties": {"QueueName": "q"}
3151 }
3152 }
3153 }"#;
3154 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
3155 assert!(err.contains("Fn::And"), "got: {err}");
3156 }
3157
3158 #[test]
3159 fn condition_evaluation_memoizes_complex_expression() {
3160 let template = r#"{
3165 "Parameters": {"Env": {"Type": "String"}},
3166 "Conditions": {
3167 "Inner": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
3168 "OuterA": {"Fn::And": [{"Condition": "Inner"}, {"Condition": "Inner"}]},
3169 "OuterB": {"Fn::Or": [{"Condition": "Inner"}, {"Condition": "OuterA"}]}
3170 },
3171 "Resources": {
3172 "Q": {
3173 "Type": "AWS::SQS::Queue",
3174 "Condition": "OuterB",
3175 "Properties": {"QueueName": "q"}
3176 }
3177 }
3178 }"#;
3179 let mut params = BTreeMap::new();
3180 params.insert("Env".to_string(), "prod".to_string());
3181 let parsed = parse_template(template, ¶ms).unwrap();
3182 assert_eq!(parsed.resources.len(), 1);
3183 }
3184
3185 #[test]
3186 fn fn_not_rejects_multiple_arguments() {
3187 let template = r#"{
3188 "Parameters": {"Env": {"Type": "String"}},
3189 "Conditions": {
3190 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
3191 "Bad": {"Fn::Not": [
3192 {"Condition": "IsProd"},
3193 {"Condition": "IsProd"}
3194 ]}
3195 },
3196 "Resources": {
3197 "Q": {
3198 "Type": "AWS::SQS::Queue",
3199 "Condition": "Bad",
3200 "Properties": {"QueueName": "q"}
3201 }
3202 }
3203 }"#;
3204 let mut params = BTreeMap::new();
3205 params.insert("Env".to_string(), "prod".to_string());
3206 let err = parse_template(template, ¶ms).unwrap_err();
3207 assert!(err.contains("Fn::Not"), "got: {err}");
3208 }
3209
3210 #[test]
3211 fn fn_not_rejects_zero_arguments() {
3212 let template = r#"{
3213 "Conditions": {
3214 "Bad": {"Fn::Not": []}
3215 },
3216 "Resources": {
3217 "Q": {
3218 "Type": "AWS::SQS::Queue",
3219 "Condition": "Bad",
3220 "Properties": {"QueueName": "q"}
3221 }
3222 }
3223 }"#;
3224 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
3225 assert!(err.contains("Fn::Not"), "got: {err}");
3226 }
3227
3228 #[test]
3229 fn resolve_resource_properties_strips_no_value_at_provision_time() {
3230 let template = r#"{
3235 "Parameters": {"WantTags": {"Type": "String"}},
3236 "Conditions": {
3237 "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
3238 },
3239 "Resources": {
3240 "Q": {
3241 "Type": "AWS::SQS::Queue",
3242 "Properties": {
3243 "QueueName": "q",
3244 "Tags": {"Fn::If": [
3245 "HasTags",
3246 [{"Key": "a", "Value": "b"}],
3247 {"Ref": "AWS::NoValue"}
3248 ]}
3249 }
3250 }
3251 }
3252 }"#;
3253 let mut params = BTreeMap::new();
3254 params.insert("WantTags".to_string(), "no".to_string());
3255 let parsed = parse_template(template, ¶ms).unwrap();
3256 let resource = parsed
3257 .resources
3258 .iter()
3259 .find(|r| r.logical_id == "Q")
3260 .unwrap();
3261 assert!(!resource
3263 .properties
3264 .as_object()
3265 .unwrap()
3266 .contains_key("Tags"));
3267
3268 let reresolved = resolve_resource_properties_with_attrs(
3272 resource,
3273 template,
3274 ¶ms,
3275 &BTreeMap::new(),
3276 &BTreeMap::new(),
3277 )
3278 .unwrap();
3279 let props = reresolved.properties.as_object().unwrap();
3280 assert!(
3281 !props.contains_key("Tags"),
3282 "Tags should be stripped on re-resolve, got: {props:?}"
3283 );
3284 let serialized = serde_json::to_string(&reresolved.properties).unwrap();
3286 assert!(
3287 !serialized.contains(NO_VALUE_SENTINEL_KEY),
3288 "sentinel leaked: {serialized}"
3289 );
3290 }
3291
3292 #[test]
3295 fn fn_select_string_index_resolves() {
3296 let (p, r, ids, attrs) = empty();
3299 let v: Value = serde_json::from_str(r#"{"Fn::Select": ["2", ["a", "b", "c", "d"]]}"#)
3300 .expect("static fixture parses");
3301 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3302 assert_eq!(resolved, Value::String("c".to_string()));
3303 }
3304
3305 #[test]
3306 fn fn_select_out_of_range_returns_null() {
3307 let (p, r, ids, attrs) = empty();
3308 let v: Value = serde_json::from_str(r#"{"Fn::Select": [10, ["a", "b"]]}"#)
3309 .expect("static fixture parses");
3310 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3311 assert_eq!(resolved, Value::Null);
3312 }
3313
3314 #[test]
3315 fn fn_select_resolves_ref_inside_list() {
3316 let template = r#"{
3317 "Parameters": {"AZs": {"Type": "CommaDelimitedList"}},
3318 "Resources": {
3319 "Q": {
3320 "Type": "AWS::SQS::Queue",
3321 "Properties": {
3322 "QueueName": {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "AZs"}]}]}
3323 }
3324 }
3325 }
3326 }"#;
3327 let mut params = BTreeMap::new();
3328 params.insert(
3329 "AZs".to_string(),
3330 "us-east-1a,us-east-1b,us-east-1c".to_string(),
3331 );
3332 let parsed = parse_template(template, ¶ms).unwrap();
3333 assert_eq!(
3334 parsed.resources[0].properties["QueueName"],
3335 Value::String("us-east-1a".to_string())
3336 );
3337 }
3338
3339 #[test]
3340 fn fn_split_empty_delimiter_returns_full_string_split_per_char() {
3341 let (p, r, ids, attrs) = empty();
3342 let v: Value =
3343 serde_json::from_str(r#"{"Fn::Split": ["", "abc"]}"#).expect("static fixture parses");
3344 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3345 assert!(resolved.is_array());
3349 }
3350
3351 #[test]
3352 fn fn_split_no_match_returns_single_element_array() {
3353 let (p, r, ids, attrs) = empty();
3354 let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "no-commas-here"]}"#)
3355 .expect("static fixture parses");
3356 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3357 assert_eq!(resolved, serde_json::json!(["no-commas-here"]));
3358 }
3359
3360 #[test]
3361 fn fn_base64_encodes_unicode() {
3362 let (p, r, ids, attrs) = empty();
3363 let v: Value =
3364 serde_json::from_str(r#"{"Fn::Base64": "héllo"}"#).expect("static fixture parses");
3365 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3366 assert_eq!(resolved, Value::String("aMOpbGxv".to_string()));
3368 }
3369
3370 #[test]
3371 fn fn_base64_resolves_nested_intrinsic() {
3372 let template = r#"{
3373 "Parameters": {"Greeting": {"Type": "String"}},
3374 "Resources": {
3375 "Q": {
3376 "Type": "AWS::SQS::Queue",
3377 "Properties": {
3378 "QueueName": {"Fn::Base64": {"Ref": "Greeting"}}
3379 }
3380 }
3381 }
3382 }"#;
3383 let mut params = BTreeMap::new();
3384 params.insert("Greeting".to_string(), "hello".to_string());
3385 let parsed = parse_template(template, ¶ms).unwrap();
3386 assert_eq!(
3387 parsed.resources[0].properties["QueueName"],
3388 Value::String("aGVsbG8=".to_string())
3389 );
3390 }
3391
3392 #[test]
3393 fn fn_length_counts_string_chars() {
3394 let (p, r, ids, attrs) = empty();
3395 let v: Value =
3396 serde_json::from_str(r#"{"Fn::Length": "héllo"}"#).expect("static fixture parses");
3397 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3398 assert_eq!(resolved, Value::Number(5.into()));
3400 }
3401
3402 #[test]
3403 fn fn_length_resolves_nested_split() {
3404 let (p, r, ids, attrs) = empty();
3405 let v: Value = serde_json::from_str(r#"{"Fn::Length": {"Fn::Split": [",", "a,b,c,d,e"]}}"#)
3406 .expect("static fixture parses");
3407 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3408 assert_eq!(resolved, Value::Number(5.into()));
3409 }
3410
3411 #[test]
3412 fn fn_to_json_string_serializes_array() {
3413 let (p, r, ids, attrs) = empty();
3414 let v: Value = serde_json::from_str(r#"{"Fn::ToJsonString": ["a", "b", "c"]}"#)
3415 .expect("static fixture parses");
3416 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3417 assert_eq!(resolved, Value::String(r#"["a","b","c"]"#.to_string()));
3418 }
3419
3420 #[test]
3421 fn fn_to_json_string_resolves_inner_ref() {
3422 let template = r#"{
3423 "Parameters": {"Name": {"Type": "String"}},
3424 "Resources": {
3425 "Q": {
3426 "Type": "AWS::SQS::Queue",
3427 "Properties": {
3428 "QueueName": {
3429 "Fn::ToJsonString": {"k": {"Ref": "Name"}}
3430 }
3431 }
3432 }
3433 }
3434 }"#;
3435 let mut params = BTreeMap::new();
3436 params.insert("Name".to_string(), "abc".to_string());
3437 let parsed = parse_template(template, ¶ms).unwrap();
3438 assert_eq!(
3439 parsed.resources[0].properties["QueueName"],
3440 Value::String(r#"{"k":"abc"}"#.to_string())
3441 );
3442 }
3443
3444 #[test]
3445 fn fn_cidr_count_matches_request() {
3446 let (p, r, ids, attrs) = empty();
3449 let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 2, 8]}"#)
3450 .expect("static fixture parses");
3451 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3452 assert_eq!(resolved, serde_json::json!(["10.0.0.0/24", "10.0.1.0/24"]));
3453 }
3454
3455 #[test]
3456 fn fn_cidr_resolves_via_ref() {
3457 let template = r#"{
3458 "Parameters": {"Vpc": {"Type": "String"}},
3459 "Resources": {
3460 "Q": {
3461 "Type": "AWS::SQS::Queue",
3462 "Properties": {
3463 "QueueName": {"Fn::Select": [
3464 0,
3465 {"Fn::Cidr": [{"Ref": "Vpc"}, 4, 8]}
3466 ]}
3467 }
3468 }
3469 }
3470 }"#;
3471 let mut params = BTreeMap::new();
3472 params.insert("Vpc".to_string(), "172.16.0.0/16".to_string());
3473 let parsed = parse_template(template, ¶ms).unwrap();
3474 assert_eq!(
3475 parsed.resources[0].properties["QueueName"],
3476 Value::String("172.16.0.0/24".to_string())
3477 );
3478 }
3479
3480 #[test]
3481 fn fn_for_each_expands_resources() {
3482 let template = r#"{
3483 "Resources": {
3484 "Fn::ForEach::TopicLoop": [
3485 "TopicName",
3486 ["alpha", "beta", "gamma"],
3487 {
3488 "${TopicName}Topic": {
3489 "Type": "AWS::SNS::Topic",
3490 "Properties": {"TopicName": "${TopicName}-topic"}
3491 }
3492 }
3493 ]
3494 }
3495 }"#;
3496 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3497 let names: Vec<&str> = parsed
3498 .resources
3499 .iter()
3500 .map(|r| r.logical_id.as_str())
3501 .collect();
3502 assert!(names.contains(&"alphaTopic"), "got: {names:?}");
3503 assert!(names.contains(&"betaTopic"), "got: {names:?}");
3504 assert!(names.contains(&"gammaTopic"), "got: {names:?}");
3505 let alpha = parsed
3506 .resources
3507 .iter()
3508 .find(|r| r.logical_id == "alphaTopic")
3509 .unwrap();
3510 assert_eq!(
3511 alpha.properties["TopicName"],
3512 Value::String("alpha-topic".to_string())
3513 );
3514 }
3515
3516 #[test]
3517 fn fn_for_each_substitutes_in_nested_values() {
3518 let template = r#"{
3519 "Resources": {
3520 "Fn::ForEach::Q": [
3521 "QName",
3522 ["one", "two"],
3523 {
3524 "${QName}Queue": {
3525 "Type": "AWS::SQS::Queue",
3526 "Properties": {
3527 "QueueName": "${QName}",
3528 "Tags": [
3529 {"Key": "name", "Value": "${QName}"}
3530 ]
3531 }
3532 }
3533 }
3534 ]
3535 }
3536 }"#;
3537 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3538 let one = parsed
3539 .resources
3540 .iter()
3541 .find(|r| r.logical_id == "oneQueue")
3542 .unwrap();
3543 assert_eq!(
3544 one.properties["QueueName"],
3545 Value::String("one".to_string())
3546 );
3547 assert_eq!(
3548 one.properties["Tags"][0]["Value"],
3549 Value::String("one".to_string())
3550 );
3551 }
3552
3553 #[test]
3554 fn fn_for_each_nested_loops_expand_cartesian() {
3555 let template = r#"{
3556 "Resources": {
3557 "Fn::ForEach::Outer": [
3558 "Env",
3559 ["dev", "prod"],
3560 {
3561 "Fn::ForEach::Inner": [
3562 "Region",
3563 ["us-east-1", "eu-west-1"],
3564 {
3565 "${Env}${Region}Q": {
3566 "Type": "AWS::SQS::Queue",
3567 "Properties": {"QueueName": "${Env}-${Region}"}
3568 }
3569 }
3570 ]
3571 }
3572 ]
3573 }
3574 }"#;
3575 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3576 let names: Vec<&str> = parsed
3577 .resources
3578 .iter()
3579 .map(|r| r.logical_id.as_str())
3580 .collect();
3581 for env in ["dev", "prod"] {
3582 for region in ["us-east-1", "eu-west-1"] {
3583 let expected = format!("{env}{region}Q");
3584 assert!(
3585 names.contains(&expected.as_str()),
3586 "missing {expected} in {names:?}"
3587 );
3588 }
3589 }
3590 let dev_us = parsed
3591 .resources
3592 .iter()
3593 .find(|r| r.logical_id == "devus-east-1Q")
3594 .unwrap();
3595 assert_eq!(
3596 dev_us.properties["QueueName"],
3597 Value::String("dev-us-east-1".to_string())
3598 );
3599 }
3600
3601 #[test]
3602 fn fn_for_each_keeps_other_resources_untouched() {
3603 let template = r#"{
3604 "Resources": {
3605 "Static": {
3606 "Type": "AWS::SQS::Queue",
3607 "Properties": {"QueueName": "static-q"}
3608 },
3609 "Fn::ForEach::Loop": [
3610 "I",
3611 ["a", "b"],
3612 {
3613 "${I}Topic": {
3614 "Type": "AWS::SNS::Topic",
3615 "Properties": {"TopicName": "${I}"}
3616 }
3617 }
3618 ]
3619 }
3620 }"#;
3621 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3622 let names: Vec<&str> = parsed
3623 .resources
3624 .iter()
3625 .map(|r| r.logical_id.as_str())
3626 .collect();
3627 assert!(names.contains(&"Static"));
3628 assert!(names.contains(&"aTopic"));
3629 assert!(names.contains(&"bTopic"));
3630 assert_eq!(parsed.resources.len(), 3);
3631 }
3632
3633 #[test]
3634 fn fn_for_each_invalid_arity_errors() {
3635 let template = r#"{
3636 "Resources": {
3637 "Fn::ForEach::Bad": [
3638 "Var",
3639 ["a"]
3640 ]
3641 }
3642 }"#;
3643 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
3644 assert!(err.contains("Fn::ForEach"), "got: {err}");
3645 }
3646
3647 #[test]
3648 fn fn_for_each_resolves_intrinsics_in_emitted_resources() {
3649 let template = r#"{
3653 "Parameters": {"Env": {"Type": "String"}},
3654 "Resources": {
3655 "Fn::ForEach::Q": [
3656 "Name",
3657 ["alpha", "beta"],
3658 {
3659 "${Name}Queue": {
3660 "Type": "AWS::SQS::Queue",
3661 "Properties": {
3662 "QueueName": {"Fn::Sub": "${Env}-${Name}"}
3663 }
3664 }
3665 }
3666 ]
3667 }
3668 }"#;
3669 let mut params = BTreeMap::new();
3670 params.insert("Env".to_string(), "prod".to_string());
3671 let parsed = parse_template(template, ¶ms).unwrap();
3672 let alpha = parsed
3675 .resources
3676 .iter()
3677 .find(|r| r.logical_id == "alphaQueue")
3678 .unwrap();
3679 assert_eq!(
3680 alpha.properties["QueueName"],
3681 Value::String("prod-alpha".to_string())
3682 );
3683 }
3684
3685 #[test]
3686 fn fn_for_each_re_resolves_at_provision_time() {
3687 let template = r#"{
3691 "Resources": {
3692 "Fn::ForEach::Q": [
3693 "Name",
3694 ["alpha"],
3695 {
3696 "${Name}Queue": {
3697 "Type": "AWS::SQS::Queue",
3698 "Properties": {"QueueName": "${Name}-q"}
3699 }
3700 }
3701 ]
3702 }
3703 }"#;
3704 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3705 let resource = parsed
3706 .resources
3707 .iter()
3708 .find(|r| r.logical_id == "alphaQueue")
3709 .unwrap();
3710 let reresolved = resolve_resource_properties_with_attrs(
3711 resource,
3712 template,
3713 &BTreeMap::new(),
3714 &BTreeMap::new(),
3715 &BTreeMap::new(),
3716 )
3717 .unwrap();
3718 assert_eq!(
3719 reresolved.properties["QueueName"],
3720 Value::String("alpha-q".to_string())
3721 );
3722 }
3723
3724 #[test]
3725 fn fn_for_each_resolves_ref_to_comma_delimited_list_param() {
3726 let template = r#"{
3730 "Parameters": {"Names": {"Type": "CommaDelimitedList"}},
3731 "Resources": {
3732 "Fn::ForEach::Q": [
3733 "N",
3734 {"Ref": "Names"},
3735 {
3736 "${N}Queue": {
3737 "Type": "AWS::SQS::Queue",
3738 "Properties": {"QueueName": "${N}-q"}
3739 }
3740 }
3741 ]
3742 }
3743 }"#;
3744 let mut params = BTreeMap::new();
3745 params.insert("Names".to_string(), "alpha,beta,gamma".to_string());
3746 let parsed = parse_template(template, ¶ms).unwrap();
3747 let names: Vec<&str> = parsed
3748 .resources
3749 .iter()
3750 .map(|r| r.logical_id.as_str())
3751 .collect();
3752 for v in ["alphaQueue", "betaQueue", "gammaQueue"] {
3753 assert!(names.contains(&v), "missing {v} in {names:?}");
3754 }
3755 }
3756
3757 #[test]
3758 fn fn_for_each_ampersand_substitution_form() {
3759 let template = r#"{
3763 "Resources": {
3764 "Fn::ForEach::Q": [
3765 "Name",
3766 ["alpha", "beta"],
3767 {
3768 "&{Name}Queue": {
3769 "Type": "AWS::SQS::Queue",
3770 "Properties": {"QueueName": "&{Name}"}
3771 }
3772 }
3773 ]
3774 }
3775 }"#;
3776 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3777 let names: Vec<&str> = parsed
3778 .resources
3779 .iter()
3780 .map(|r| r.logical_id.as_str())
3781 .collect();
3782 assert!(names.contains(&"alphaQueue"), "got: {names:?}");
3783 assert!(names.contains(&"betaQueue"), "got: {names:?}");
3784 let alpha = parsed
3785 .resources
3786 .iter()
3787 .find(|r| r.logical_id == "alphaQueue")
3788 .unwrap();
3789 assert_eq!(
3790 alpha.properties["QueueName"],
3791 Value::String("alpha".to_string())
3792 );
3793 }
3794
3795 #[test]
3796 fn fn_for_each_in_outputs_expands() {
3797 let template = r#"{
3798 "Resources": {
3799 "Q": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "q"}}
3800 },
3801 "Outputs": {
3802 "Fn::ForEach::OutputLoop": [
3803 "I",
3804 ["one", "two"],
3805 {
3806 "${I}Out": {"Value": "${I}-value"}
3807 }
3808 ]
3809 }
3810 }"#;
3811 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3812 let names: Vec<&str> = parsed
3813 .outputs
3814 .iter()
3815 .map(|o| o.logical_id.as_str())
3816 .collect();
3817 assert!(names.contains(&"oneOut"), "got: {names:?}");
3818 assert!(names.contains(&"twoOut"), "got: {names:?}");
3819 let one = parsed
3820 .outputs
3821 .iter()
3822 .find(|o| o.logical_id == "oneOut")
3823 .unwrap();
3824 assert_eq!(one.value, "one-value");
3825 }
3826}