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
52type Mappings = BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>;
53
54pub(crate) fn partition_for_region(region: &str) -> &'static str {
59 if region.starts_with("cn-") {
60 "aws-cn"
61 } else if region.starts_with("us-gov-") {
62 "aws-us-gov"
63 } else {
64 "aws"
65 }
66}
67
68pub(crate) fn url_suffix_for_region(region: &str) -> &'static str {
72 if region.starts_with("cn-") {
73 "amazonaws.com.cn"
74 } else {
75 "amazonaws.com"
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82
83 #[test]
84 fn parse_json_template() {
85 let template = r#"{
86 "Resources": {
87 "MyQueue": {
88 "Type": "AWS::SQS::Queue",
89 "Properties": {
90 "QueueName": "test-queue"
91 }
92 }
93 }
94 }"#;
95
96 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
97 assert_eq!(parsed.resources.len(), 1);
98 assert_eq!(parsed.resources[0].logical_id, "MyQueue");
99 assert_eq!(parsed.resources[0].resource_type, "AWS::SQS::Queue");
100 }
101
102 #[test]
103 fn parse_yaml_template() {
104 let template = r#"
105Resources:
106 MyTopic:
107 Type: AWS::SNS::Topic
108 Properties:
109 TopicName: test-topic
110"#;
111
112 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
113 assert_eq!(parsed.resources.len(), 1);
114 assert_eq!(parsed.resources[0].logical_id, "MyTopic");
115 assert_eq!(parsed.resources[0].resource_type, "AWS::SNS::Topic");
116 }
117
118 #[test]
119 fn resolve_ref_parameters() {
120 let template = r#"{
121 "Resources": {
122 "MyQueue": {
123 "Type": "AWS::SQS::Queue",
124 "Properties": {
125 "QueueName": { "Ref": "QueueNameParam" }
126 }
127 }
128 }
129 }"#;
130
131 let mut params = BTreeMap::new();
132 params.insert("QueueNameParam".to_string(), "resolved-queue".to_string());
133 let parsed = parse_template(template, ¶ms).unwrap();
134 assert_eq!(
135 parsed.resources[0].properties["QueueName"],
136 Value::String("resolved-queue".to_string())
137 );
138 }
139
140 #[test]
141 fn ref_resolves_physical_id_over_logical_id() {
142 let template = r#"{
143 "Resources": {
144 "MyTopic": {
145 "Type": "AWS::SNS::Topic",
146 "Properties": {
147 "TopicName": "my-topic"
148 }
149 },
150 "MySub": {
151 "Type": "AWS::SNS::Subscription",
152 "Properties": {
153 "TopicArn": { "Ref": "MyTopic" },
154 "Protocol": "sqs",
155 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
156 }
157 }
158 }
159 }"#;
160
161 let mut physical_ids = BTreeMap::new();
162 physical_ids.insert(
163 "MyTopic".to_string(),
164 "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
165 );
166
167 let parsed =
168 parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
169 let sub = parsed
170 .resources
171 .iter()
172 .find(|r| r.logical_id == "MySub")
173 .unwrap();
174 assert_eq!(
175 sub.properties["TopicArn"],
176 Value::String("arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
177 );
178 }
179
180 #[test]
181 fn ref_without_physical_id_returns_logical_id_for_known_resource() {
182 let template = r#"{
183 "Resources": {
184 "MyTopic": {
185 "Type": "AWS::SNS::Topic",
186 "Properties": {
187 "TopicName": "my-topic"
188 }
189 },
190 "MySub": {
191 "Type": "AWS::SNS::Subscription",
192 "Properties": {
193 "TopicArn": { "Ref": "MyTopic" },
194 "Protocol": "sqs",
195 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
196 }
197 }
198 }
199 }"#;
200
201 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
203 let sub = parsed
204 .resources
205 .iter()
206 .find(|r| r.logical_id == "MySub")
207 .unwrap();
208 assert_eq!(
209 sub.properties["TopicArn"],
210 Value::String("MyTopic".to_string())
211 );
212 }
213
214 #[test]
215 fn pseudo_ref_substitutes_when_param_provided() {
216 let template = r#"{
217 "Resources": {
218 "MyQueue": {
219 "Type": "AWS::SQS::Queue",
220 "Properties": {
221 "QueueArn": {
222 "Fn::Join": ["", [
223 "arn:", {"Ref": "AWS::Partition"}, ":sqs:",
224 {"Ref": "AWS::Region"}, ":", {"Ref": "AWS::AccountId"},
225 ":", {"Ref": "AWS::StackName"}, "-q"
226 ]]
227 }
228 }
229 }
230 }
231 }"#;
232 let mut params = BTreeMap::new();
233 params.insert("AWS::Region".to_string(), "us-west-2".to_string());
234 params.insert("AWS::AccountId".to_string(), "111122223333".to_string());
235 params.insert("AWS::Partition".to_string(), "aws".to_string());
236 params.insert("AWS::StackName".to_string(), "demo".to_string());
237
238 let parsed = parse_template(template, ¶ms).unwrap();
239 assert_eq!(
240 parsed.resources[0].properties["QueueArn"],
241 Value::String("arn:aws:sqs:us-west-2:111122223333:demo-q".to_string())
242 );
243 }
244
245 #[test]
246 fn pseudo_ref_partition_default_when_unset() {
247 let template = r#"{
248 "Resources": {
249 "MyQueue": {
250 "Type": "AWS::SQS::Queue",
251 "Properties": {
252 "Partition": {"Ref": "AWS::Partition"},
253 "Suffix": {"Ref": "AWS::URLSuffix"}
254 }
255 }
256 }
257 }"#;
258 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
259 assert_eq!(
260 parsed.resources[0].properties["Partition"],
261 Value::String("aws".to_string())
262 );
263 assert_eq!(
264 parsed.resources[0].properties["Suffix"],
265 Value::String("amazonaws.com".to_string())
266 );
267 }
268
269 #[test]
270 fn pseudo_ref_passes_through() {
271 let template = r#"{
272 "Resources": {
273 "MyQueue": {
274 "Type": "AWS::SQS::Queue",
275 "Properties": {
276 "QueueName": { "Ref": "AWS::StackName" }
277 }
278 }
279 }
280 }"#;
281
282 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
283 assert_eq!(
284 parsed.resources[0].properties["QueueName"],
285 Value::String("AWS::StackName".to_string())
286 );
287 }
288
289 #[test]
292 fn bb6_ref_aws_region_returns_seeded_region() {
293 let template = r#"{
294 "Resources": {
295 "Q": {
296 "Type": "AWS::SQS::Queue",
297 "Properties": {"Region": {"Ref": "AWS::Region"}}
298 }
299 }
300 }"#;
301 let mut params = BTreeMap::new();
302 params.insert("AWS::Region".to_string(), "us-east-1".to_string());
303 let parsed = parse_template(template, ¶ms).unwrap();
304 assert_eq!(
305 parsed.resources[0].properties["Region"],
306 Value::String("us-east-1".to_string())
307 );
308 }
309
310 #[test]
311 fn bb6_fn_sub_substitutes_aws_account_id() {
312 let template = r#"{
313 "Resources": {
314 "Q": {
315 "Type": "AWS::SQS::Queue",
316 "Properties": {
317 "Owner": {"Fn::Sub": "owner-${AWS::AccountId}"}
318 }
319 }
320 }
321 }"#;
322 let mut params = BTreeMap::new();
323 params.insert("AWS::AccountId".to_string(), "123456789012".to_string());
324 let parsed = parse_template(template, ¶ms).unwrap();
325 assert_eq!(
326 parsed.resources[0].properties["Owner"],
327 Value::String("owner-123456789012".to_string())
328 );
329 }
330
331 #[test]
332 fn bb6_partition_for_china_region_is_aws_cn() {
333 let template = r#"{
336 "Resources": {
337 "Q": {
338 "Type": "AWS::SQS::Queue",
339 "Properties": {"P": {"Ref": "AWS::Partition"}}
340 }
341 }
342 }"#;
343 let mut params = BTreeMap::new();
344 params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
345 let parsed = parse_template(template, ¶ms).unwrap();
346 assert_eq!(
347 parsed.resources[0].properties["P"],
348 Value::String("aws-cn".to_string())
349 );
350 }
351
352 #[test]
353 fn bb6_partition_for_govcloud_region_is_aws_us_gov() {
354 let template = r#"{
355 "Resources": {
356 "Q": {
357 "Type": "AWS::SQS::Queue",
358 "Properties": {"P": {"Ref": "AWS::Partition"}}
359 }
360 }
361 }"#;
362 let mut params = BTreeMap::new();
363 params.insert("AWS::Region".to_string(), "us-gov-west-1".to_string());
364 let parsed = parse_template(template, ¶ms).unwrap();
365 assert_eq!(
366 parsed.resources[0].properties["P"],
367 Value::String("aws-us-gov".to_string())
368 );
369 }
370
371 #[test]
372 fn bb6_url_suffix_for_china_is_amazonaws_com_cn() {
373 let template = r#"{
374 "Resources": {
375 "Q": {
376 "Type": "AWS::SQS::Queue",
377 "Properties": {"S": {"Ref": "AWS::URLSuffix"}}
378 }
379 }
380 }"#;
381 let mut params = BTreeMap::new();
382 params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
383 let parsed = parse_template(template, ¶ms).unwrap();
384 assert_eq!(
385 parsed.resources[0].properties["S"],
386 Value::String("amazonaws.com.cn".to_string())
387 );
388 }
389
390 #[test]
391 fn bb6_url_suffix_for_govcloud_stays_amazonaws_com() {
392 let template = r#"{
394 "Resources": {
395 "Q": {
396 "Type": "AWS::SQS::Queue",
397 "Properties": {"S": {"Ref": "AWS::URLSuffix"}}
398 }
399 }
400 }"#;
401 let mut params = BTreeMap::new();
402 params.insert("AWS::Region".to_string(), "us-gov-east-1".to_string());
403 let parsed = parse_template(template, ¶ms).unwrap();
404 assert_eq!(
405 parsed.resources[0].properties["S"],
406 Value::String("amazonaws.com".to_string())
407 );
408 }
409
410 #[test]
411 fn bb6_no_value_omits_property_from_resource_input() {
412 let template = r#"{
415 "Resources": {
416 "Q": {
417 "Type": "AWS::SQS::Queue",
418 "Properties": {
419 "QueueName": "q",
420 "OptionalProp": {"Ref": "AWS::NoValue"}
421 }
422 }
423 }
424 }"#;
425 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
426 let props = parsed.resources[0].properties.as_object().unwrap();
427 assert!(
428 !props.contains_key("OptionalProp"),
429 "OptionalProp should be omitted, got: {props:?}"
430 );
431 assert_eq!(
432 props.get("QueueName"),
433 Some(&Value::String("q".to_string()))
434 );
435 }
436
437 #[test]
438 fn bb6_notification_arns_returns_seeded_array() {
439 let template = r#"{
443 "Resources": {
444 "Q": {
445 "Type": "AWS::SQS::Queue",
446 "Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
447 }
448 }
449 }"#;
450 let mut params = BTreeMap::new();
451 params.insert(
452 "AWS::NotificationARNs".to_string(),
453 r#"["arn:aws:sns:us-east-1:111122223333:topic"]"#.to_string(),
454 );
455 let parsed = parse_template(template, ¶ms).unwrap();
456 assert_eq!(
457 parsed.resources[0].properties["Targets"],
458 serde_json::json!(["arn:aws:sns:us-east-1:111122223333:topic"])
459 );
460 }
461
462 #[test]
463 fn bb6_notification_arns_defaults_to_empty_array() {
464 let template = r#"{
465 "Resources": {
466 "Q": {
467 "Type": "AWS::SQS::Queue",
468 "Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
469 }
470 }
471 }"#;
472 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
473 assert_eq!(
474 parsed.resources[0].properties["Targets"],
475 serde_json::json!([])
476 );
477 }
478
479 #[test]
480 fn bb6_fn_sub_array_form_substitutes_extra_vars() {
481 let template = r#"{
484 "Resources": {
485 "Q": {
486 "Type": "AWS::SQS::Queue",
487 "Properties": {
488 "Path": {"Fn::Sub": ["${AWS::Region}/${Suffix}", {"Suffix": "tail"}]}
489 }
490 }
491 }
492 }"#;
493 let mut params = BTreeMap::new();
494 params.insert("AWS::Region".to_string(), "eu-west-1".to_string());
495 let parsed = parse_template(template, ¶ms).unwrap();
496 assert_eq!(
497 parsed.resources[0].properties["Path"],
498 Value::String("eu-west-1/tail".to_string())
499 );
500 }
501
502 #[test]
503 fn bb6_partition_helper_classifies_regions() {
504 assert_eq!(partition_for_region("us-east-1"), "aws");
505 assert_eq!(partition_for_region("eu-central-1"), "aws");
506 assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
507 assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
508 assert_eq!(partition_for_region("us-gov-west-1"), "aws-us-gov");
509 assert_eq!(partition_for_region("us-gov-east-1"), "aws-us-gov");
510 }
511
512 #[test]
513 fn bb6_url_suffix_helper_classifies_regions() {
514 assert_eq!(url_suffix_for_region("us-east-1"), "amazonaws.com");
515 assert_eq!(url_suffix_for_region("us-gov-west-1"), "amazonaws.com");
516 assert_eq!(url_suffix_for_region("cn-north-1"), "amazonaws.com.cn");
517 }
518
519 #[test]
520 fn fn_sub_resolves_physical_ids() {
521 let template = r#"{
522 "Resources": {
523 "MyTopic": {
524 "Type": "AWS::SNS::Topic",
525 "Properties": {
526 "TopicName": "my-topic"
527 }
528 },
529 "MyParam": {
530 "Type": "AWS::SSM::Parameter",
531 "Properties": {
532 "Name": "/app/topic",
533 "Type": "String",
534 "Value": { "Fn::Sub": "Topic is ${MyTopic}" }
535 }
536 }
537 }
538 }"#;
539
540 let mut physical_ids = BTreeMap::new();
541 physical_ids.insert(
542 "MyTopic".to_string(),
543 "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
544 );
545
546 let parsed =
547 parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
548 let param = parsed
549 .resources
550 .iter()
551 .find(|r| r.logical_id == "MyParam")
552 .unwrap();
553 assert_eq!(
554 param.properties["Value"],
555 Value::String("Topic is arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
556 );
557 }
558
559 #[test]
562 fn parse_template_invalid_json_errors() {
563 let params = BTreeMap::new();
564 let result = parse_template("{not-json}", ¶ms);
565 assert!(result.is_err());
566 }
567
568 #[test]
569 fn parse_template_missing_resources_errors() {
570 let params = BTreeMap::new();
571 let result = parse_template(r#"{"Description":"no resources"}"#, ¶ms);
572 assert!(result.is_err());
573 }
574
575 #[test]
576 fn parse_template_resources_not_object_errors() {
577 let params = BTreeMap::new();
578 let result = parse_template(r#"{"Resources": []}"#, ¶ms);
579 assert!(result.is_err());
580 }
581
582 #[test]
583 fn parse_template_missing_type_errors() {
584 let params = BTreeMap::new();
585 let result = parse_template(r#"{"Resources":{"R":{"Properties":{}}}}"#, ¶ms);
586 assert!(result.is_err());
587 }
588
589 #[test]
592 fn fn_getatt_resolves_attribute_in_array_form() {
593 let template = r#"{
594 "Resources": {
595 "MyQueue": {
596 "Type": "AWS::SQS::Queue",
597 "Properties": { "QueueName": "q1" }
598 },
599 "MyTopic": {
600 "Type": "AWS::SNS::Topic",
601 "Properties": {
602 "TopicName": "t1",
603 "DataProtectionPolicy": {
604 "Fn::GetAtt": ["MyQueue", "Arn"]
605 }
606 }
607 }
608 }
609 }"#;
610
611 let mut attrs = BTreeMap::new();
612 let mut q_attrs = BTreeMap::new();
613 q_attrs.insert(
614 "Arn".to_string(),
615 "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
616 );
617 attrs.insert("MyQueue".to_string(), q_attrs);
618
619 let parsed =
620 parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
621 .unwrap();
622 let topic = parsed
623 .resources
624 .iter()
625 .find(|r| r.logical_id == "MyTopic")
626 .unwrap();
627 assert_eq!(
628 topic.properties["DataProtectionPolicy"],
629 Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
630 );
631 }
632
633 #[test]
634 fn fn_getatt_resolves_attribute_in_short_string_form() {
635 let template = r#"{
636 "Resources": {
637 "MyTopic": {
638 "Type": "AWS::SNS::Topic",
639 "Properties": {
640 "TopicName": "t1",
641 "PolicyArn": { "Fn::GetAtt": "MyQueue.Arn" }
642 }
643 }
644 }
645 }"#;
646
647 let mut attrs = BTreeMap::new();
648 let mut q_attrs = BTreeMap::new();
649 q_attrs.insert(
650 "Arn".to_string(),
651 "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
652 );
653 attrs.insert("MyQueue".to_string(), q_attrs);
654
655 let parsed =
656 parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
657 .unwrap();
658 assert_eq!(
659 parsed.resources[0].properties["PolicyArn"],
660 Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
661 );
662 }
663
664 #[test]
665 fn fn_getatt_unknown_resource_returns_placeholder() {
666 let template = r#"{
667 "Resources": {
668 "MyTopic": {
669 "Type": "AWS::SNS::Topic",
670 "Properties": {
671 "TopicName": { "Fn::GetAtt": ["MyQueue", "Arn"] }
672 }
673 }
674 }
675 }"#;
676
677 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
678 assert_eq!(
681 parsed.resources[0].properties["TopicName"],
682 Value::String("MyQueue.Arn".to_string())
683 );
684 }
685
686 #[test]
687 fn fn_getatt_inside_fn_join_resolves() {
688 let template = r#"{
689 "Resources": {
690 "MyParam": {
691 "Type": "AWS::SSM::Parameter",
692 "Properties": {
693 "Name": "/app/q",
694 "Type": "String",
695 "Value": {
696 "Fn::Join": [":", ["queue", { "Fn::GetAtt": ["MyQueue", "Arn"] }]]
697 }
698 }
699 }
700 }
701 }"#;
702
703 let mut attrs = BTreeMap::new();
704 let mut q_attrs = BTreeMap::new();
705 q_attrs.insert(
706 "Arn".to_string(),
707 "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
708 );
709 attrs.insert("MyQueue".to_string(), q_attrs);
710
711 let parsed =
712 parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
713 .unwrap();
714 assert_eq!(
715 parsed.resources[0].properties["Value"],
716 Value::String("queue:arn:aws:sqs:us-east-1:123456789012:q1".to_string())
717 );
718 }
719
720 #[test]
721 fn fn_sub_resolves_getatt_style_substitution() {
722 let template = r#"{
723 "Resources": {
724 "MyParam": {
725 "Type": "AWS::SSM::Parameter",
726 "Properties": {
727 "Name": "/app/q",
728 "Type": "String",
729 "Value": { "Fn::Sub": "Queue arn is ${MyQueue.Arn}" }
730 }
731 }
732 }
733 }"#;
734
735 let mut attrs = BTreeMap::new();
736 let mut q_attrs = BTreeMap::new();
737 q_attrs.insert(
738 "Arn".to_string(),
739 "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
740 );
741 attrs.insert("MyQueue".to_string(), q_attrs);
742
743 let parsed =
744 parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
745 .unwrap();
746 assert_eq!(
747 parsed.resources[0].properties["Value"],
748 Value::String("Queue arn is arn:aws:sqs:us-east-1:123456789012:q1".to_string())
749 );
750 }
751
752 #[test]
753 fn parse_template_with_description() {
754 let params = BTreeMap::new();
755 let parsed = parse_template(
756 r#"{"Description":"My template","Resources":{"R":{"Type":"AWS::SQS::Queue"}}}"#,
757 ¶ms,
758 )
759 .unwrap();
760 assert_eq!(parsed.description.as_deref(), Some("My template"));
761 assert_eq!(parsed.resources.len(), 1);
762 }
763
764 type EmptyCtx = (
765 BTreeMap<String, String>,
766 serde_json::Map<String, Value>,
767 BTreeMap<String, String>,
768 BTreeMap<String, BTreeMap<String, String>>,
769 );
770
771 fn empty() -> EmptyCtx {
772 (
773 BTreeMap::new(),
774 serde_json::Map::new(),
775 BTreeMap::new(),
776 BTreeMap::new(),
777 )
778 }
779
780 #[test]
781 fn fn_base64_encodes_string() {
782 let (p, r, ids, attrs) = empty();
783 let v: Value = serde_json::from_str(r#"{"Fn::Base64": "hello"}"#).unwrap();
784 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
785 assert_eq!(resolved, Value::String("aGVsbG8=".to_string()));
786 }
787
788 #[test]
789 fn fn_split_emits_array() {
790 let (p, r, ids, attrs) = empty();
791 let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "a,b,c"]}"#).unwrap();
792 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
793 assert_eq!(resolved, serde_json::json!(["a", "b", "c"]));
794 }
795
796 #[test]
797 fn fn_select_picks_index() {
798 let (p, r, ids, attrs) = empty();
799 let v: Value =
800 serde_json::from_str(r#"{"Fn::Select": [1, {"Fn::Split": [",", "a,b,c"]}]}"#).unwrap();
801 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
802 assert_eq!(resolved, Value::String("b".to_string()));
803 }
804
805 #[test]
806 fn fn_length_counts_array() {
807 let (p, r, ids, attrs) = empty();
808 let v: Value = serde_json::from_str(r#"{"Fn::Length": [1,2,3,4]}"#).unwrap();
809 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
810 assert_eq!(resolved, Value::Number(4.into()));
811 }
812
813 #[test]
814 fn fn_to_json_string_serializes() {
815 let (p, r, ids, attrs) = empty();
816 let v: Value =
817 serde_json::from_str(r#"{"Fn::ToJsonString": {"a": 1, "b": [2, 3]}}"#).unwrap();
818 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
819 let s = resolved.as_str().unwrap();
820 let parsed: Value = serde_json::from_str(s).unwrap();
822 assert_eq!(parsed["a"], serde_json::json!(1));
823 assert_eq!(parsed["b"], serde_json::json!([2, 3]));
824 }
825
826 #[test]
827 fn fn_cidr_carves_subnets() {
828 let (p, r, ids, attrs) = empty();
829 let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 4, 8]}"#).unwrap();
831 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
832 assert_eq!(
833 resolved,
834 serde_json::json!(["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",])
835 );
836 }
837
838 #[test]
839 fn condition_skips_resource_when_false() {
840 let template = r#"{
841 "Parameters": {"Env": {"Type": "String"}},
842 "Conditions": {
843 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
844 },
845 "Resources": {
846 "ProdQueue": {
847 "Type": "AWS::SQS::Queue",
848 "Condition": "IsProd",
849 "Properties": {"QueueName": "prod-q"}
850 },
851 "AlwaysQueue": {
852 "Type": "AWS::SQS::Queue",
853 "Properties": {"QueueName": "always-q"}
854 }
855 }
856 }"#;
857 let mut params = BTreeMap::new();
858 params.insert("Env".to_string(), "dev".to_string());
859 let parsed = parse_template(template, ¶ms).unwrap();
860 let names: Vec<&str> = parsed
861 .resources
862 .iter()
863 .map(|r| r.logical_id.as_str())
864 .collect();
865 assert!(names.contains(&"AlwaysQueue"));
866 assert!(!names.contains(&"ProdQueue"));
867 }
868
869 #[test]
870 fn condition_includes_resource_when_true() {
871 let template = r#"{
872 "Parameters": {"Env": {"Type": "String"}},
873 "Conditions": {
874 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
875 },
876 "Resources": {
877 "ProdQueue": {
878 "Type": "AWS::SQS::Queue",
879 "Condition": "IsProd",
880 "Properties": {"QueueName": "prod-q"}
881 }
882 }
883 }"#;
884 let mut params = BTreeMap::new();
885 params.insert("Env".to_string(), "prod".to_string());
886 let parsed = parse_template(template, ¶ms).unwrap();
887 assert_eq!(parsed.resources.len(), 1);
888 }
889
890 #[test]
891 fn fn_if_picks_branch_based_on_condition() {
892 let template = r#"{
893 "Parameters": {"Env": {"Type": "String"}},
894 "Conditions": {
895 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
896 },
897 "Resources": {
898 "Q": {
899 "Type": "AWS::SQS::Queue",
900 "Properties": {
901 "QueueName": {"Fn::If": ["IsProd", "prod-q", "dev-q"]}
902 }
903 }
904 }
905 }"#;
906 let mut params = BTreeMap::new();
907 params.insert("Env".to_string(), "dev".to_string());
908 let parsed = parse_template(template, ¶ms).unwrap();
909 assert_eq!(
910 parsed.resources[0].properties["QueueName"],
911 Value::String("dev-q".to_string())
912 );
913 }
914
915 #[test]
916 fn fn_and_or_not_combine_conditions() {
917 let template = r#"{
918 "Parameters": {"Env": {"Type": "String"}, "Region": {"Type": "String"}},
919 "Conditions": {
920 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
921 "IsUsEast": {"Fn::Equals": [{"Ref": "Region"}, "us-east-1"]},
922 "IsProdInUsEast": {"Fn::And": [{"Condition": "IsProd"}, {"Condition": "IsUsEast"}]},
923 "IsNotProd": {"Fn::Not": [{"Condition": "IsProd"}]},
924 "IsAny": {"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "IsNotProd"}]}
925 },
926 "Resources": {
927 "Q": {
928 "Type": "AWS::SQS::Queue",
929 "Properties": {
930 "P1": {"Fn::If": ["IsProdInUsEast", "yes", "no"]},
931 "P2": {"Fn::If": ["IsNotProd", "yes", "no"]},
932 "P3": {"Fn::If": ["IsAny", "yes", "no"]}
933 }
934 }
935 }
936 }"#;
937 let mut params = BTreeMap::new();
938 params.insert("Env".to_string(), "prod".to_string());
939 params.insert("Region".to_string(), "us-east-1".to_string());
940 let parsed = parse_template(template, ¶ms).unwrap();
941 let p = &parsed.resources[0].properties;
942 assert_eq!(p["P1"], Value::String("yes".to_string()));
943 assert_eq!(p["P2"], Value::String("no".to_string()));
944 assert_eq!(p["P3"], Value::String("yes".to_string()));
945 }
946
947 #[test]
948 fn fn_find_in_map_resolves_leaf_value() {
949 let template = r#"{
950 "Mappings": {
951 "RegionMap": {
952 "us-east-1": {"AMI": "ami-east"},
953 "us-west-2": {"AMI": "ami-west"}
954 }
955 },
956 "Resources": {
957 "Inst": {
958 "Type": "AWS::EC2::Instance",
959 "Properties": {
960 "ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
961 }
962 }
963 }
964 }"#;
965 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
966 assert_eq!(
967 parsed.resources[0].properties["ImageId"],
968 Value::String("ami-east".to_string())
969 );
970 }
971
972 #[test]
973 fn fn_find_in_map_resolves_keys_via_ref() {
974 let template = r#"{
975 "Parameters": {"Region": {"Type": "String"}},
976 "Mappings": {
977 "RegionMap": {
978 "us-east-1": {"AMI": "ami-east"},
979 "us-west-2": {"AMI": "ami-west"}
980 }
981 },
982 "Resources": {
983 "Inst": {
984 "Type": "AWS::EC2::Instance",
985 "Properties": {
986 "ImageId": {"Fn::FindInMap": ["RegionMap", {"Ref": "Region"}, "AMI"]}
987 }
988 }
989 }
990 }"#;
991 let mut params = BTreeMap::new();
992 params.insert("Region".to_string(), "us-west-2".to_string());
993 let parsed = parse_template(template, ¶ms).unwrap();
994 assert_eq!(
995 parsed.resources[0].properties["ImageId"],
996 Value::String("ami-west".to_string())
997 );
998 }
999
1000 #[test]
1001 fn fn_find_in_map_unknown_keys_returns_error() {
1002 let template = r#"{
1003 "Mappings": {
1004 "RegionMap": {
1005 "us-east-1": {"AMI": "ami-east"}
1006 }
1007 },
1008 "Resources": {
1009 "Inst": {
1010 "Type": "AWS::EC2::Instance",
1011 "Properties": {
1012 "ImageId": {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]}
1013 }
1014 }
1015 }
1016 }"#;
1017 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1018 assert!(
1019 err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
1020 "got: {err}"
1021 );
1022 }
1023
1024 #[test]
1025 fn fn_find_in_map_four_arg_returns_default_when_missing() {
1026 let template = r#"{
1027 "Mappings": {
1028 "RegionMap": {
1029 "us-east-1": {"AMI": "ami-east"}
1030 }
1031 },
1032 "Resources": {
1033 "Inst": {
1034 "Type": "AWS::EC2::Instance",
1035 "Properties": {
1036 "ImageId": {"Fn::FindInMap": [
1037 "RegionMap",
1038 "ap-south-1",
1039 "AMI",
1040 {"DefaultValue": "ami-fallback"}
1041 ]}
1042 }
1043 }
1044 }
1045 }"#;
1046 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1047 assert_eq!(
1048 parsed.resources[0].properties["ImageId"],
1049 Value::String("ami-fallback".to_string())
1050 );
1051 }
1052
1053 #[test]
1054 fn fn_find_in_map_four_arg_prefers_match_over_default() {
1055 let template = r#"{
1056 "Mappings": {
1057 "RegionMap": {
1058 "us-east-1": {"AMI": "ami-east"}
1059 }
1060 },
1061 "Resources": {
1062 "Inst": {
1063 "Type": "AWS::EC2::Instance",
1064 "Properties": {
1065 "ImageId": {"Fn::FindInMap": [
1066 "RegionMap",
1067 "us-east-1",
1068 "AMI",
1069 {"DefaultValue": "ami-fallback"}
1070 ]}
1071 }
1072 }
1073 }
1074 }"#;
1075 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1076 assert_eq!(
1077 parsed.resources[0].properties["ImageId"],
1078 Value::String("ami-east".to_string())
1079 );
1080 }
1081
1082 #[test]
1083 fn fn_find_in_map_default_value_is_resolved_intrinsic() {
1084 let template = r#"{
1085 "Parameters": {"Fallback": {"Type": "String"}},
1086 "Mappings": {
1087 "RegionMap": {
1088 "us-east-1": {"AMI": "ami-east"}
1089 }
1090 },
1091 "Resources": {
1092 "Inst": {
1093 "Type": "AWS::EC2::Instance",
1094 "Properties": {
1095 "ImageId": {"Fn::FindInMap": [
1096 "RegionMap",
1097 "ap-south-1",
1098 "AMI",
1099 {"DefaultValue": {"Ref": "Fallback"}}
1100 ]}
1101 }
1102 }
1103 }
1104 }"#;
1105 let mut params = BTreeMap::new();
1106 params.insert("Fallback".to_string(), "ami-default".to_string());
1107 let parsed = parse_template(template, ¶ms).unwrap();
1108 assert_eq!(
1109 parsed.resources[0].properties["ImageId"],
1110 Value::String("ami-default".to_string())
1111 );
1112 }
1113
1114 #[test]
1115 fn fn_find_in_map_unknown_map_name_errors() {
1116 let template = r#"{
1117 "Mappings": {
1118 "RegionMap": {
1119 "us-east-1": {"AMI": "ami-east"}
1120 }
1121 },
1122 "Resources": {
1123 "Inst": {
1124 "Type": "AWS::EC2::Instance",
1125 "Properties": {
1126 "ImageId": {"Fn::FindInMap": ["DoesNotExist", "us-east-1", "AMI"]}
1127 }
1128 }
1129 }
1130 }"#;
1131 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1132 assert!(
1133 err.contains("Unable to get mapping for DoesNotExist::us-east-1::AMI"),
1134 "got: {err}"
1135 );
1136 }
1137
1138 #[test]
1139 fn fn_find_in_map_wrong_arg_count_errors() {
1140 let template = r#"{
1141 "Mappings": {"M": {"a": {"b": "c"}}},
1142 "Resources": {
1143 "Q": {
1144 "Type": "AWS::SQS::Queue",
1145 "Properties": {
1146 "QueueName": {"Fn::FindInMap": ["M", "a"]}
1147 }
1148 }
1149 }
1150 }"#;
1151 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1152 assert!(
1153 err.contains("Fn::FindInMap requires 3 or 4 arguments"),
1154 "got: {err}"
1155 );
1156 }
1157
1158 #[test]
1159 fn fn_find_in_map_resolves_via_pseudo_region() {
1160 let template = r#"{
1161 "Mappings": {
1162 "RegionMap": {
1163 "us-east-1": {"AMI": "ami-east"},
1164 "us-west-2": {"AMI": "ami-west"}
1165 }
1166 },
1167 "Resources": {
1168 "Inst": {
1169 "Type": "AWS::EC2::Instance",
1170 "Properties": {
1171 "ImageId": {"Fn::FindInMap": [
1172 "RegionMap",
1173 {"Ref": "AWS::Region"},
1174 "AMI"
1175 ]}
1176 }
1177 }
1178 }
1179 }"#;
1180 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1183 assert_eq!(
1184 parsed.resources[0].properties["ImageId"],
1185 Value::String("ami-east".to_string())
1186 );
1187 }
1188
1189 #[test]
1190 fn fn_find_in_map_in_unused_if_branch_does_not_error() {
1191 let template = r#"{
1197 "Parameters": {"WantAlt": {"Type": "String"}},
1198 "Conditions": {
1199 "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
1200 },
1201 "Mappings": {
1202 "RegionMap": {
1203 "us-east-1": {"AMI": "ami-east"}
1204 }
1205 },
1206 "Resources": {
1207 "Inst": {
1208 "Type": "AWS::EC2::Instance",
1209 "Properties": {
1210 "ImageId": {"Fn::If": [
1211 "UseAlt",
1212 {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
1213 {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
1214 ]}
1215 }
1216 }
1217 }
1218 }"#;
1219 let mut params = BTreeMap::new();
1220 params.insert("WantAlt".to_string(), "no".to_string());
1221 let parsed = parse_template(template, ¶ms).unwrap();
1222 assert_eq!(
1223 parsed.resources[0].properties["ImageId"],
1224 Value::String("ami-east".to_string())
1225 );
1226 }
1227
1228 #[test]
1229 fn fn_find_in_map_in_active_if_branch_still_errors_on_miss() {
1230 let template = r#"{
1233 "Parameters": {"WantAlt": {"Type": "String"}},
1234 "Conditions": {
1235 "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
1236 },
1237 "Mappings": {
1238 "RegionMap": {
1239 "us-east-1": {"AMI": "ami-east"}
1240 }
1241 },
1242 "Resources": {
1243 "Inst": {
1244 "Type": "AWS::EC2::Instance",
1245 "Properties": {
1246 "ImageId": {"Fn::If": [
1247 "UseAlt",
1248 {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
1249 {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
1250 ]}
1251 }
1252 }
1253 }
1254 }"#;
1255 let mut params = BTreeMap::new();
1256 params.insert("WantAlt".to_string(), "yes".to_string());
1257 let err = parse_template(template, ¶ms).unwrap_err();
1258 assert!(
1259 err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
1260 "got: {err}"
1261 );
1262 }
1263
1264 #[test]
1265 fn fn_find_in_map_alongside_ref_and_sub_still_resolve() {
1266 let template = r#"{
1267 "Parameters": {"Env": {"Type": "String"}},
1268 "Mappings": {
1269 "EnvMap": {
1270 "prod": {"Suffix": "live"},
1271 "dev": {"Suffix": "test"}
1272 }
1273 },
1274 "Resources": {
1275 "Q": {
1276 "Type": "AWS::SQS::Queue",
1277 "Properties": {
1278 "QueueName": {"Fn::FindInMap": ["EnvMap", {"Ref": "Env"}, "Suffix"]},
1279 "Tags": [
1280 {"Key": "EnvRef", "Value": {"Ref": "Env"}},
1281 {"Key": "Subbed", "Value": {"Fn::Sub": "env-${Env}"}}
1282 ]
1283 }
1284 }
1285 }
1286 }"#;
1287 let mut params = BTreeMap::new();
1288 params.insert("Env".to_string(), "prod".to_string());
1289 let parsed = parse_template(template, ¶ms).unwrap();
1290 let p = &parsed.resources[0].properties;
1291 assert_eq!(p["QueueName"], Value::String("live".to_string()));
1292 assert_eq!(p["Tags"][0]["Value"], Value::String("prod".to_string()));
1293 assert_eq!(p["Tags"][1]["Value"], Value::String("env-prod".to_string()));
1294 }
1295
1296 #[test]
1299 fn cyclic_conditions_self_reference_errors() {
1300 let template = r#"{
1301 "Conditions": {
1302 "A": {"Condition": "A"}
1303 },
1304 "Resources": {
1305 "Q": {
1306 "Type": "AWS::SQS::Queue",
1307 "Condition": "A",
1308 "Properties": {"QueueName": "q"}
1309 }
1310 }
1311 }"#;
1312 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1313 assert!(err.contains("Circular reference"), "got: {err}");
1314 assert!(err.contains("'A'"), "got: {err}");
1315 }
1316
1317 #[test]
1318 fn cyclic_conditions_two_step_errors() {
1319 let template = r#"{
1320 "Conditions": {
1321 "A": {"Condition": "B"},
1322 "B": {"Condition": "A"}
1323 },
1324 "Resources": {
1325 "Q": {
1326 "Type": "AWS::SQS::Queue",
1327 "Condition": "A",
1328 "Properties": {"QueueName": "q"}
1329 }
1330 }
1331 }"#;
1332 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1333 assert!(err.contains("Circular reference"), "got: {err}");
1334 }
1335
1336 #[test]
1337 fn condition_referencing_undefined_name_errors() {
1338 let template = r#"{
1339 "Conditions": {
1340 "A": {"Condition": "DoesNotExist"}
1341 },
1342 "Resources": {
1343 "Q": {
1344 "Type": "AWS::SQS::Queue",
1345 "Condition": "A",
1346 "Properties": {"QueueName": "q"}
1347 }
1348 }
1349 }"#;
1350 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1351 assert!(err.contains("DoesNotExist"), "got: {err}");
1352 }
1353
1354 #[test]
1355 fn fn_if_no_value_removes_property_from_parent_map() {
1356 let template = r#"{
1357 "Parameters": {"WantTags": {"Type": "String"}},
1358 "Conditions": {
1359 "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1360 },
1361 "Resources": {
1362 "Q": {
1363 "Type": "AWS::SQS::Queue",
1364 "Properties": {
1365 "QueueName": "q",
1366 "Tags": {"Fn::If": [
1367 "HasTags",
1368 [{"Key": "a", "Value": "b"}],
1369 {"Ref": "AWS::NoValue"}
1370 ]}
1371 }
1372 }
1373 }
1374 }"#;
1375 let mut params = BTreeMap::new();
1376 params.insert("WantTags".to_string(), "no".to_string());
1377 let parsed = parse_template(template, ¶ms).unwrap();
1378 let props = parsed.resources[0].properties.as_object().unwrap();
1379 assert!(
1380 !props.contains_key("Tags"),
1381 "Tags should be omitted when AWS::NoValue picked, got: {props:?}"
1382 );
1383 assert_eq!(
1384 props.get("QueueName"),
1385 Some(&Value::String("q".to_string()))
1386 );
1387 }
1388
1389 #[test]
1390 fn fn_if_no_value_keeps_property_when_branch_concrete() {
1391 let template = r#"{
1392 "Parameters": {"WantTags": {"Type": "String"}},
1393 "Conditions": {
1394 "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1395 },
1396 "Resources": {
1397 "Q": {
1398 "Type": "AWS::SQS::Queue",
1399 "Properties": {
1400 "QueueName": "q",
1401 "Tags": {"Fn::If": [
1402 "HasTags",
1403 [{"Key": "a", "Value": "b"}],
1404 {"Ref": "AWS::NoValue"}
1405 ]}
1406 }
1407 }
1408 }
1409 }"#;
1410 let mut params = BTreeMap::new();
1411 params.insert("WantTags".to_string(), "yes".to_string());
1412 let parsed = parse_template(template, ¶ms).unwrap();
1413 let tags = &parsed.resources[0].properties["Tags"];
1414 assert_eq!(
1415 tags,
1416 &serde_json::json!([{"Key": "a", "Value": "b"}]),
1417 "tags should be the true branch's array"
1418 );
1419 }
1420
1421 #[test]
1422 fn fn_if_no_value_in_array_drops_element() {
1423 let template = r#"{
1424 "Parameters": {"Extra": {"Type": "String"}},
1425 "Conditions": {
1426 "HasExtra": {"Fn::Equals": [{"Ref": "Extra"}, "yes"]}
1427 },
1428 "Resources": {
1429 "Q": {
1430 "Type": "AWS::SQS::Queue",
1431 "Properties": {
1432 "Items": [
1433 "first",
1434 {"Fn::If": ["HasExtra", "second", {"Ref": "AWS::NoValue"}]},
1435 "third"
1436 ]
1437 }
1438 }
1439 }
1440 }"#;
1441 let mut params = BTreeMap::new();
1442 params.insert("Extra".to_string(), "no".to_string());
1443 let parsed = parse_template(template, ¶ms).unwrap();
1444 assert_eq!(
1445 parsed.resources[0].properties["Items"],
1446 serde_json::json!(["first", "third"])
1447 );
1448 }
1449
1450 #[test]
1451 fn condition_skips_output_when_false() {
1452 let template = r#"{
1453 "Parameters": {"Env": {"Type": "String"}},
1454 "Conditions": {
1455 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
1456 },
1457 "Resources": {
1458 "Q": {
1459 "Type": "AWS::SQS::Queue",
1460 "Properties": {"QueueName": "q"}
1461 }
1462 },
1463 "Outputs": {
1464 "ProdName": {
1465 "Condition": "IsProd",
1466 "Value": "prod-only"
1467 },
1468 "Always": {
1469 "Value": "shown"
1470 }
1471 }
1472 }"#;
1473 let mut params = BTreeMap::new();
1474 params.insert("Env".to_string(), "dev".to_string());
1475 let parsed = parse_template(template, ¶ms).unwrap();
1476 let names: Vec<&str> = parsed
1477 .outputs
1478 .iter()
1479 .map(|o| o.logical_id.as_str())
1480 .collect();
1481 assert!(names.contains(&"Always"));
1482 assert!(!names.contains(&"ProdName"));
1483 }
1484
1485 #[test]
1486 fn fn_and_short_circuits_on_false() {
1487 let template = r#"{
1488 "Parameters": {"Env": {"Type": "String"}},
1489 "Conditions": {
1490 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1491 "Combined": {"Fn::And": [
1492 {"Condition": "IsProd"},
1493 {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
1494 ]}
1495 },
1496 "Resources": {
1497 "Q": {
1498 "Type": "AWS::SQS::Queue",
1499 "Condition": "Combined",
1500 "Properties": {"QueueName": "q"}
1501 }
1502 }
1503 }"#;
1504 let mut params = BTreeMap::new();
1505 params.insert("Env".to_string(), "dev".to_string());
1506 let parsed = parse_template(template, ¶ms).unwrap();
1507 assert_eq!(parsed.resources.len(), 0);
1508 }
1509
1510 #[test]
1511 fn fn_or_short_circuits_on_true() {
1512 let template = r#"{
1513 "Parameters": {"Env": {"Type": "String"}},
1514 "Conditions": {
1515 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1516 "AnyEnv": {"Fn::Or": [
1517 {"Condition": "IsProd"},
1518 {"Fn::Equals": [{"Ref": "Env"}, "dev"]},
1519 {"Fn::Equals": [{"Ref": "Env"}, "stage"]}
1520 ]}
1521 },
1522 "Resources": {
1523 "Q": {
1524 "Type": "AWS::SQS::Queue",
1525 "Condition": "AnyEnv",
1526 "Properties": {"QueueName": "q"}
1527 }
1528 }
1529 }"#;
1530 let mut params = BTreeMap::new();
1531 params.insert("Env".to_string(), "stage".to_string());
1532 let parsed = parse_template(template, ¶ms).unwrap();
1533 assert_eq!(parsed.resources.len(), 1);
1534 }
1535
1536 #[test]
1537 fn fn_and_rejects_arity_outside_1_to_10() {
1538 let template = r#"{
1539 "Conditions": {
1540 "Empty": {"Fn::And": []}
1541 },
1542 "Resources": {
1543 "Q": {
1544 "Type": "AWS::SQS::Queue",
1545 "Condition": "Empty",
1546 "Properties": {"QueueName": "q"}
1547 }
1548 }
1549 }"#;
1550 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1551 assert!(err.contains("Fn::And"), "got: {err}");
1552 }
1553
1554 #[test]
1555 fn condition_evaluation_memoizes_complex_expression() {
1556 let template = r#"{
1561 "Parameters": {"Env": {"Type": "String"}},
1562 "Conditions": {
1563 "Inner": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1564 "OuterA": {"Fn::And": [{"Condition": "Inner"}, {"Condition": "Inner"}]},
1565 "OuterB": {"Fn::Or": [{"Condition": "Inner"}, {"Condition": "OuterA"}]}
1566 },
1567 "Resources": {
1568 "Q": {
1569 "Type": "AWS::SQS::Queue",
1570 "Condition": "OuterB",
1571 "Properties": {"QueueName": "q"}
1572 }
1573 }
1574 }"#;
1575 let mut params = BTreeMap::new();
1576 params.insert("Env".to_string(), "prod".to_string());
1577 let parsed = parse_template(template, ¶ms).unwrap();
1578 assert_eq!(parsed.resources.len(), 1);
1579 }
1580
1581 #[test]
1582 fn fn_not_rejects_multiple_arguments() {
1583 let template = r#"{
1584 "Parameters": {"Env": {"Type": "String"}},
1585 "Conditions": {
1586 "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1587 "Bad": {"Fn::Not": [
1588 {"Condition": "IsProd"},
1589 {"Condition": "IsProd"}
1590 ]}
1591 },
1592 "Resources": {
1593 "Q": {
1594 "Type": "AWS::SQS::Queue",
1595 "Condition": "Bad",
1596 "Properties": {"QueueName": "q"}
1597 }
1598 }
1599 }"#;
1600 let mut params = BTreeMap::new();
1601 params.insert("Env".to_string(), "prod".to_string());
1602 let err = parse_template(template, ¶ms).unwrap_err();
1603 assert!(err.contains("Fn::Not"), "got: {err}");
1604 }
1605
1606 #[test]
1607 fn fn_not_rejects_zero_arguments() {
1608 let template = r#"{
1609 "Conditions": {
1610 "Bad": {"Fn::Not": []}
1611 },
1612 "Resources": {
1613 "Q": {
1614 "Type": "AWS::SQS::Queue",
1615 "Condition": "Bad",
1616 "Properties": {"QueueName": "q"}
1617 }
1618 }
1619 }"#;
1620 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1621 assert!(err.contains("Fn::Not"), "got: {err}");
1622 }
1623
1624 #[test]
1625 fn resolve_resource_properties_strips_no_value_at_provision_time() {
1626 let template = r#"{
1631 "Parameters": {"WantTags": {"Type": "String"}},
1632 "Conditions": {
1633 "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1634 },
1635 "Resources": {
1636 "Q": {
1637 "Type": "AWS::SQS::Queue",
1638 "Properties": {
1639 "QueueName": "q",
1640 "Tags": {"Fn::If": [
1641 "HasTags",
1642 [{"Key": "a", "Value": "b"}],
1643 {"Ref": "AWS::NoValue"}
1644 ]}
1645 }
1646 }
1647 }
1648 }"#;
1649 let mut params = BTreeMap::new();
1650 params.insert("WantTags".to_string(), "no".to_string());
1651 let parsed = parse_template(template, ¶ms).unwrap();
1652 let resource = parsed
1653 .resources
1654 .iter()
1655 .find(|r| r.logical_id == "Q")
1656 .unwrap();
1657 assert!(!resource
1659 .properties
1660 .as_object()
1661 .unwrap()
1662 .contains_key("Tags"));
1663
1664 let reresolved = resolve_resource_properties_with_attrs(
1668 resource,
1669 template,
1670 ¶ms,
1671 &BTreeMap::new(),
1672 &BTreeMap::new(),
1673 )
1674 .unwrap();
1675 let props = reresolved.properties.as_object().unwrap();
1676 assert!(
1677 !props.contains_key("Tags"),
1678 "Tags should be stripped on re-resolve, got: {props:?}"
1679 );
1680 let serialized = serde_json::to_string(&reresolved.properties).unwrap();
1682 assert!(
1683 !serialized.contains(NO_VALUE_SENTINEL_KEY),
1684 "sentinel leaked: {serialized}"
1685 );
1686 }
1687
1688 #[test]
1691 fn fn_select_string_index_resolves() {
1692 let (p, r, ids, attrs) = empty();
1695 let v: Value = serde_json::from_str(r#"{"Fn::Select": ["2", ["a", "b", "c", "d"]]}"#)
1696 .expect("static fixture parses");
1697 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1698 assert_eq!(resolved, Value::String("c".to_string()));
1699 }
1700
1701 #[test]
1702 fn fn_select_out_of_range_returns_null() {
1703 let (p, r, ids, attrs) = empty();
1704 let v: Value = serde_json::from_str(r#"{"Fn::Select": [10, ["a", "b"]]}"#)
1705 .expect("static fixture parses");
1706 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1707 assert_eq!(resolved, Value::Null);
1708 }
1709
1710 #[test]
1711 fn fn_select_resolves_ref_inside_list() {
1712 let template = r#"{
1713 "Parameters": {"AZs": {"Type": "CommaDelimitedList"}},
1714 "Resources": {
1715 "Q": {
1716 "Type": "AWS::SQS::Queue",
1717 "Properties": {
1718 "QueueName": {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "AZs"}]}]}
1719 }
1720 }
1721 }
1722 }"#;
1723 let mut params = BTreeMap::new();
1724 params.insert(
1725 "AZs".to_string(),
1726 "us-east-1a,us-east-1b,us-east-1c".to_string(),
1727 );
1728 let parsed = parse_template(template, ¶ms).unwrap();
1729 assert_eq!(
1730 parsed.resources[0].properties["QueueName"],
1731 Value::String("us-east-1a".to_string())
1732 );
1733 }
1734
1735 #[test]
1736 fn fn_split_empty_delimiter_returns_full_string_split_per_char() {
1737 let (p, r, ids, attrs) = empty();
1738 let v: Value =
1739 serde_json::from_str(r#"{"Fn::Split": ["", "abc"]}"#).expect("static fixture parses");
1740 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1741 assert!(resolved.is_array());
1745 }
1746
1747 #[test]
1748 fn fn_split_no_match_returns_single_element_array() {
1749 let (p, r, ids, attrs) = empty();
1750 let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "no-commas-here"]}"#)
1751 .expect("static fixture parses");
1752 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1753 assert_eq!(resolved, serde_json::json!(["no-commas-here"]));
1754 }
1755
1756 #[test]
1757 fn fn_base64_encodes_unicode() {
1758 let (p, r, ids, attrs) = empty();
1759 let v: Value =
1760 serde_json::from_str(r#"{"Fn::Base64": "héllo"}"#).expect("static fixture parses");
1761 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1762 assert_eq!(resolved, Value::String("aMOpbGxv".to_string()));
1764 }
1765
1766 #[test]
1767 fn fn_base64_resolves_nested_intrinsic() {
1768 let template = r#"{
1769 "Parameters": {"Greeting": {"Type": "String"}},
1770 "Resources": {
1771 "Q": {
1772 "Type": "AWS::SQS::Queue",
1773 "Properties": {
1774 "QueueName": {"Fn::Base64": {"Ref": "Greeting"}}
1775 }
1776 }
1777 }
1778 }"#;
1779 let mut params = BTreeMap::new();
1780 params.insert("Greeting".to_string(), "hello".to_string());
1781 let parsed = parse_template(template, ¶ms).unwrap();
1782 assert_eq!(
1783 parsed.resources[0].properties["QueueName"],
1784 Value::String("aGVsbG8=".to_string())
1785 );
1786 }
1787
1788 #[test]
1789 fn fn_length_counts_string_chars() {
1790 let (p, r, ids, attrs) = empty();
1791 let v: Value =
1792 serde_json::from_str(r#"{"Fn::Length": "héllo"}"#).expect("static fixture parses");
1793 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1794 assert_eq!(resolved, Value::Number(5.into()));
1796 }
1797
1798 #[test]
1799 fn fn_length_resolves_nested_split() {
1800 let (p, r, ids, attrs) = empty();
1801 let v: Value = serde_json::from_str(r#"{"Fn::Length": {"Fn::Split": [",", "a,b,c,d,e"]}}"#)
1802 .expect("static fixture parses");
1803 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1804 assert_eq!(resolved, Value::Number(5.into()));
1805 }
1806
1807 #[test]
1808 fn fn_to_json_string_serializes_array() {
1809 let (p, r, ids, attrs) = empty();
1810 let v: Value = serde_json::from_str(r#"{"Fn::ToJsonString": ["a", "b", "c"]}"#)
1811 .expect("static fixture parses");
1812 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1813 assert_eq!(resolved, Value::String(r#"["a","b","c"]"#.to_string()));
1814 }
1815
1816 #[test]
1817 fn fn_to_json_string_resolves_inner_ref() {
1818 let template = r#"{
1819 "Parameters": {"Name": {"Type": "String"}},
1820 "Resources": {
1821 "Q": {
1822 "Type": "AWS::SQS::Queue",
1823 "Properties": {
1824 "QueueName": {
1825 "Fn::ToJsonString": {"k": {"Ref": "Name"}}
1826 }
1827 }
1828 }
1829 }
1830 }"#;
1831 let mut params = BTreeMap::new();
1832 params.insert("Name".to_string(), "abc".to_string());
1833 let parsed = parse_template(template, ¶ms).unwrap();
1834 assert_eq!(
1835 parsed.resources[0].properties["QueueName"],
1836 Value::String(r#"{"k":"abc"}"#.to_string())
1837 );
1838 }
1839
1840 #[test]
1841 fn fn_cidr_count_matches_request() {
1842 let (p, r, ids, attrs) = empty();
1845 let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 2, 8]}"#)
1846 .expect("static fixture parses");
1847 let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1848 assert_eq!(resolved, serde_json::json!(["10.0.0.0/24", "10.0.1.0/24"]));
1849 }
1850
1851 #[test]
1852 fn fn_cidr_resolves_via_ref() {
1853 let template = r#"{
1854 "Parameters": {"Vpc": {"Type": "String"}},
1855 "Resources": {
1856 "Q": {
1857 "Type": "AWS::SQS::Queue",
1858 "Properties": {
1859 "QueueName": {"Fn::Select": [
1860 0,
1861 {"Fn::Cidr": [{"Ref": "Vpc"}, 4, 8]}
1862 ]}
1863 }
1864 }
1865 }
1866 }"#;
1867 let mut params = BTreeMap::new();
1868 params.insert("Vpc".to_string(), "172.16.0.0/16".to_string());
1869 let parsed = parse_template(template, ¶ms).unwrap();
1870 assert_eq!(
1871 parsed.resources[0].properties["QueueName"],
1872 Value::String("172.16.0.0/24".to_string())
1873 );
1874 }
1875
1876 #[test]
1877 fn fn_for_each_expands_resources() {
1878 let template = r#"{
1879 "Resources": {
1880 "Fn::ForEach::TopicLoop": [
1881 "TopicName",
1882 ["alpha", "beta", "gamma"],
1883 {
1884 "${TopicName}Topic": {
1885 "Type": "AWS::SNS::Topic",
1886 "Properties": {"TopicName": "${TopicName}-topic"}
1887 }
1888 }
1889 ]
1890 }
1891 }"#;
1892 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1893 let names: Vec<&str> = parsed
1894 .resources
1895 .iter()
1896 .map(|r| r.logical_id.as_str())
1897 .collect();
1898 assert!(names.contains(&"alphaTopic"), "got: {names:?}");
1899 assert!(names.contains(&"betaTopic"), "got: {names:?}");
1900 assert!(names.contains(&"gammaTopic"), "got: {names:?}");
1901 let alpha = parsed
1902 .resources
1903 .iter()
1904 .find(|r| r.logical_id == "alphaTopic")
1905 .unwrap();
1906 assert_eq!(
1907 alpha.properties["TopicName"],
1908 Value::String("alpha-topic".to_string())
1909 );
1910 }
1911
1912 #[test]
1913 fn fn_for_each_substitutes_in_nested_values() {
1914 let template = r#"{
1915 "Resources": {
1916 "Fn::ForEach::Q": [
1917 "QName",
1918 ["one", "two"],
1919 {
1920 "${QName}Queue": {
1921 "Type": "AWS::SQS::Queue",
1922 "Properties": {
1923 "QueueName": "${QName}",
1924 "Tags": [
1925 {"Key": "name", "Value": "${QName}"}
1926 ]
1927 }
1928 }
1929 }
1930 ]
1931 }
1932 }"#;
1933 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1934 let one = parsed
1935 .resources
1936 .iter()
1937 .find(|r| r.logical_id == "oneQueue")
1938 .unwrap();
1939 assert_eq!(
1940 one.properties["QueueName"],
1941 Value::String("one".to_string())
1942 );
1943 assert_eq!(
1944 one.properties["Tags"][0]["Value"],
1945 Value::String("one".to_string())
1946 );
1947 }
1948
1949 #[test]
1950 fn fn_for_each_nested_loops_expand_cartesian() {
1951 let template = r#"{
1952 "Resources": {
1953 "Fn::ForEach::Outer": [
1954 "Env",
1955 ["dev", "prod"],
1956 {
1957 "Fn::ForEach::Inner": [
1958 "Region",
1959 ["us-east-1", "eu-west-1"],
1960 {
1961 "${Env}${Region}Q": {
1962 "Type": "AWS::SQS::Queue",
1963 "Properties": {"QueueName": "${Env}-${Region}"}
1964 }
1965 }
1966 ]
1967 }
1968 ]
1969 }
1970 }"#;
1971 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1972 let names: Vec<&str> = parsed
1973 .resources
1974 .iter()
1975 .map(|r| r.logical_id.as_str())
1976 .collect();
1977 for env in ["dev", "prod"] {
1978 for region in ["us-east-1", "eu-west-1"] {
1979 let expected = format!("{env}{region}Q");
1980 assert!(
1981 names.contains(&expected.as_str()),
1982 "missing {expected} in {names:?}"
1983 );
1984 }
1985 }
1986 let dev_us = parsed
1987 .resources
1988 .iter()
1989 .find(|r| r.logical_id == "devus-east-1Q")
1990 .unwrap();
1991 assert_eq!(
1992 dev_us.properties["QueueName"],
1993 Value::String("dev-us-east-1".to_string())
1994 );
1995 }
1996
1997 #[test]
1998 fn fn_for_each_keeps_other_resources_untouched() {
1999 let template = r#"{
2000 "Resources": {
2001 "Static": {
2002 "Type": "AWS::SQS::Queue",
2003 "Properties": {"QueueName": "static-q"}
2004 },
2005 "Fn::ForEach::Loop": [
2006 "I",
2007 ["a", "b"],
2008 {
2009 "${I}Topic": {
2010 "Type": "AWS::SNS::Topic",
2011 "Properties": {"TopicName": "${I}"}
2012 }
2013 }
2014 ]
2015 }
2016 }"#;
2017 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2018 let names: Vec<&str> = parsed
2019 .resources
2020 .iter()
2021 .map(|r| r.logical_id.as_str())
2022 .collect();
2023 assert!(names.contains(&"Static"));
2024 assert!(names.contains(&"aTopic"));
2025 assert!(names.contains(&"bTopic"));
2026 assert_eq!(parsed.resources.len(), 3);
2027 }
2028
2029 #[test]
2030 fn fn_for_each_invalid_arity_errors() {
2031 let template = r#"{
2032 "Resources": {
2033 "Fn::ForEach::Bad": [
2034 "Var",
2035 ["a"]
2036 ]
2037 }
2038 }"#;
2039 let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2040 assert!(err.contains("Fn::ForEach"), "got: {err}");
2041 }
2042
2043 #[test]
2044 fn fn_for_each_resolves_intrinsics_in_emitted_resources() {
2045 let template = r#"{
2049 "Parameters": {"Env": {"Type": "String"}},
2050 "Resources": {
2051 "Fn::ForEach::Q": [
2052 "Name",
2053 ["alpha", "beta"],
2054 {
2055 "${Name}Queue": {
2056 "Type": "AWS::SQS::Queue",
2057 "Properties": {
2058 "QueueName": {"Fn::Sub": "${Env}-${Name}"}
2059 }
2060 }
2061 }
2062 ]
2063 }
2064 }"#;
2065 let mut params = BTreeMap::new();
2066 params.insert("Env".to_string(), "prod".to_string());
2067 let parsed = parse_template(template, ¶ms).unwrap();
2068 let alpha = parsed
2071 .resources
2072 .iter()
2073 .find(|r| r.logical_id == "alphaQueue")
2074 .unwrap();
2075 assert_eq!(
2076 alpha.properties["QueueName"],
2077 Value::String("prod-alpha".to_string())
2078 );
2079 }
2080
2081 #[test]
2082 fn fn_for_each_re_resolves_at_provision_time() {
2083 let template = r#"{
2087 "Resources": {
2088 "Fn::ForEach::Q": [
2089 "Name",
2090 ["alpha"],
2091 {
2092 "${Name}Queue": {
2093 "Type": "AWS::SQS::Queue",
2094 "Properties": {"QueueName": "${Name}-q"}
2095 }
2096 }
2097 ]
2098 }
2099 }"#;
2100 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2101 let resource = parsed
2102 .resources
2103 .iter()
2104 .find(|r| r.logical_id == "alphaQueue")
2105 .unwrap();
2106 let reresolved = resolve_resource_properties_with_attrs(
2107 resource,
2108 template,
2109 &BTreeMap::new(),
2110 &BTreeMap::new(),
2111 &BTreeMap::new(),
2112 )
2113 .unwrap();
2114 assert_eq!(
2115 reresolved.properties["QueueName"],
2116 Value::String("alpha-q".to_string())
2117 );
2118 }
2119
2120 #[test]
2121 fn fn_for_each_resolves_ref_to_comma_delimited_list_param() {
2122 let template = r#"{
2126 "Parameters": {"Names": {"Type": "CommaDelimitedList"}},
2127 "Resources": {
2128 "Fn::ForEach::Q": [
2129 "N",
2130 {"Ref": "Names"},
2131 {
2132 "${N}Queue": {
2133 "Type": "AWS::SQS::Queue",
2134 "Properties": {"QueueName": "${N}-q"}
2135 }
2136 }
2137 ]
2138 }
2139 }"#;
2140 let mut params = BTreeMap::new();
2141 params.insert("Names".to_string(), "alpha,beta,gamma".to_string());
2142 let parsed = parse_template(template, ¶ms).unwrap();
2143 let names: Vec<&str> = parsed
2144 .resources
2145 .iter()
2146 .map(|r| r.logical_id.as_str())
2147 .collect();
2148 for v in ["alphaQueue", "betaQueue", "gammaQueue"] {
2149 assert!(names.contains(&v), "missing {v} in {names:?}");
2150 }
2151 }
2152
2153 #[test]
2154 fn fn_for_each_ampersand_substitution_form() {
2155 let template = r#"{
2159 "Resources": {
2160 "Fn::ForEach::Q": [
2161 "Name",
2162 ["alpha", "beta"],
2163 {
2164 "&{Name}Queue": {
2165 "Type": "AWS::SQS::Queue",
2166 "Properties": {"QueueName": "&{Name}"}
2167 }
2168 }
2169 ]
2170 }
2171 }"#;
2172 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2173 let names: Vec<&str> = parsed
2174 .resources
2175 .iter()
2176 .map(|r| r.logical_id.as_str())
2177 .collect();
2178 assert!(names.contains(&"alphaQueue"), "got: {names:?}");
2179 assert!(names.contains(&"betaQueue"), "got: {names:?}");
2180 let alpha = parsed
2181 .resources
2182 .iter()
2183 .find(|r| r.logical_id == "alphaQueue")
2184 .unwrap();
2185 assert_eq!(
2186 alpha.properties["QueueName"],
2187 Value::String("alpha".to_string())
2188 );
2189 }
2190
2191 #[test]
2192 fn fn_for_each_in_outputs_expands() {
2193 let template = r#"{
2194 "Resources": {
2195 "Q": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "q"}}
2196 },
2197 "Outputs": {
2198 "Fn::ForEach::OutputLoop": [
2199 "I",
2200 ["one", "two"],
2201 {
2202 "${I}Out": {"Value": "${I}-value"}
2203 }
2204 ]
2205 }
2206 }"#;
2207 let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2208 let names: Vec<&str> = parsed
2209 .outputs
2210 .iter()
2211 .map(|o| o.logical_id.as_str())
2212 .collect();
2213 assert!(names.contains(&"oneOut"), "got: {names:?}");
2214 assert!(names.contains(&"twoOut"), "got: {names:?}");
2215 let one = parsed
2216 .outputs
2217 .iter()
2218 .find(|o| o.logical_id == "oneOut")
2219 .unwrap();
2220 assert_eq!(one.value, "one-value");
2221 }
2222}
2223
2224mod conditions;
2225mod for_each;
2226mod intrinsics;
2227mod mappings;
2228mod parser;
2229mod resolution;
2230use conditions::*;
2231use for_each::*;
2232use intrinsics::*;
2233use mappings::*;
2234
2235pub use parser::{
2236 collect_import_value_names, parse_outputs, parse_template, parse_template_with_physical_ids,
2237 parse_template_with_resolution,
2238};
2239pub use resolution::{resolve_resource_properties, resolve_resource_properties_with_attrs};