1use serde_json::{Map, Value as Json};
15use serde_yaml::value::TaggedValue;
16use serde_yaml::Value as Yaml;
17
18const FN_TAGS: &[&str] = &[
23 "And",
24 "Base64",
25 "Cidr",
26 "Contains",
27 "EachMemberEquals",
28 "EachMemberIn",
29 "Equals",
30 "FindInMap",
31 "GetAZs",
32 "GetAtt",
33 "If",
34 "ImportValue",
35 "Join",
36 "Length",
37 "Not",
38 "Or",
39 "RefAll",
40 "Select",
41 "Split",
42 "Sub",
43 "ToJsonString",
44 "Transform",
45 "ValueOf",
46 "ValueOfAll",
47];
48
49const BARE_TAGS: &[&str] = &["Ref", "Condition"];
52
53pub fn parse_template_body(body: &str) -> Result<Json, String> {
64 let body = strip_bom(body);
65 if body.trim_start().starts_with('{') {
66 return match serde_json::from_str(body) {
67 Ok(value) => Ok(value),
68 Err(json_err) => match serde_yaml::from_str::<Yaml>(body) {
69 Ok(yaml) => yaml_to_json(yaml).map_err(|e| format!("Invalid YAML template: {e}")),
75 Err(_) => Err(format!("Invalid JSON template: {json_err}")),
78 },
79 };
80 }
81 parse_yaml(body)
82}
83
84fn parse_yaml(body: &str) -> Result<Json, String> {
85 let yaml: Yaml =
86 serde_yaml::from_str(body).map_err(|e| format!("Invalid YAML template: {e}"))?;
87 yaml_to_json(yaml).map_err(|e| format!("Invalid YAML template: {e}"))
88}
89
90fn strip_bom(body: &str) -> &str {
95 body.strip_prefix('\u{FEFF}').unwrap_or(body)
96}
97
98pub fn is_template_document(body: &str) -> bool {
112 let body = strip_bom(body);
113 if let Ok(value) = parse_template_body(body) {
114 return !matches!(value, Json::String(_) | Json::Null);
135 }
136 looks_like_template_text(body)
137}
138
139const TEMPLATE_SECTIONS: &[&str] = &[
143 "AWSTemplateFormatVersion",
144 "Conditions",
145 "Mappings",
146 "Metadata",
147 "Outputs",
148 "Parameters",
149 "Resources",
150 "Rules",
151 "Transform",
152];
153
154fn looks_like_template_text(body: &str) -> bool {
159 if body.trim_start().starts_with('{') {
160 return true;
168 }
169 body.lines().any(declares_template_section)
173}
174
175fn declares_template_section(line: &str) -> bool {
176 let unquoted = line
177 .strip_prefix('"')
178 .or_else(|| line.strip_prefix('\''))
179 .unwrap_or(line);
180 TEMPLATE_SECTIONS.iter().any(|section| {
185 let Some(rest) = unquoted.strip_prefix(section) else {
186 return false;
187 };
188 let rest = rest
189 .strip_prefix('"')
190 .or_else(|| rest.strip_prefix('\''))
191 .unwrap_or(rest);
192 rest.trim_start().starts_with(':')
197 })
198}
199
200pub fn parse_template_object(body: &str) -> Option<Json> {
203 parse_template_body(body).ok().filter(Json::is_object)
204}
205
206fn yaml_to_json(value: Yaml) -> Result<Json, String> {
207 Ok(match value {
208 Yaml::Null => Json::Null,
209 Yaml::Bool(b) => Json::Bool(b),
210 Yaml::Number(n) => number_to_json(&n)?,
211 Yaml::String(s) => Json::String(s),
212 Yaml::Sequence(seq) => Json::Array(
213 seq.into_iter()
214 .map(yaml_to_json)
215 .collect::<Result<Vec<_>, _>>()?,
216 ),
217 Yaml::Mapping(map) => {
218 let mut obj = Map::new();
219 for (k, v) in map {
220 obj.insert(mapping_key(k)?, yaml_to_json(v)?);
221 }
222 Json::Object(obj)
223 }
224 Yaml::Tagged(tagged) => tagged_to_json(*tagged)?,
225 })
226}
227
228fn mapping_key(key: Yaml) -> Result<String, String> {
232 Ok(match yaml_to_json(key)? {
233 Json::String(s) => s,
234 other => other.to_string(),
235 })
236}
237
238fn number_to_json(n: &serde_yaml::Number) -> Result<Json, String> {
239 if let Some(i) = n.as_i64() {
240 return Ok(Json::Number(i.into()));
241 }
242 if let Some(u) = n.as_u64() {
243 return Ok(Json::Number(u.into()));
244 }
245 n.as_f64()
250 .and_then(serde_json::Number::from_f64)
251 .map(Json::Number)
252 .ok_or_else(|| format!("number {n} has no JSON representation"))
253}
254
255fn tagged_to_json(tagged: TaggedValue) -> Result<Json, String> {
256 let rendered = tagged.tag.to_string();
257 let name = rendered.strip_prefix('!').unwrap_or(&rendered);
258 let arg = yaml_to_json(tagged.value)?;
259 let key = if BARE_TAGS.contains(&name) {
260 name.to_string()
261 } else if FN_TAGS.contains(&name) {
262 format!("Fn::{name}")
263 } else {
264 return Err(format!("unknown intrinsic tag !{name}"));
271 };
272 let mut obj = Map::new();
273 obj.insert(key, arg);
274 Ok(Json::Object(obj))
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use serde_json::json;
281
282 #[test]
283 fn short_form_ref_becomes_long_form() {
284 let parsed = parse_template_body(
285 r"
286Resources:
287 ReproBucket:
288 Type: AWS::S3::Bucket
289Outputs:
290 BucketName:
291 Value: !Ref ReproBucket
292",
293 )
294 .expect("template parses");
295 assert_eq!(
296 parsed["Outputs"]["BucketName"]["Value"],
297 json!({"Ref": "ReproBucket"})
298 );
299 assert_eq!(
300 parsed["Resources"]["ReproBucket"]["Type"],
301 "AWS::S3::Bucket"
302 );
303 }
304
305 #[test]
306 fn every_intrinsic_short_form_expands() {
307 let parsed = parse_template_body(
308 r#"
309Resources:
310 Thing:
311 Type: AWS::S3::Bucket
312 Properties:
313 Sub: !Sub "${AWS::StackName}-bucket"
314 GetAtt: !GetAtt Other.Arn
315 GetAttList: !GetAtt [Other, Arn]
316 Join: !Join ["-", [a, b]]
317 Select: !Select [0, [a, b]]
318 Split: !Split [",", "a,b"]
319 Base64: !Base64 hello
320 FindInMap: !FindInMap [Map, Key, Value]
321 GetAZs: !GetAZs us-east-1
322 ImportValue: !ImportValue OtherStackExport
323 Cidr: !Cidr ["10.0.0.0/16", 6, 5]
324 Length: !Length [a, b]
325 ToJsonString: !ToJsonString {a: b}
326 Transform: !Transform {Name: Macro}
327"#,
328 )
329 .expect("template parses");
330 let props = &parsed["Resources"]["Thing"]["Properties"];
331 assert_eq!(props["Sub"], json!({"Fn::Sub": "${AWS::StackName}-bucket"}));
332 assert_eq!(props["GetAtt"], json!({"Fn::GetAtt": "Other.Arn"}));
333 assert_eq!(props["GetAttList"], json!({"Fn::GetAtt": ["Other", "Arn"]}));
334 assert_eq!(props["Join"], json!({"Fn::Join": ["-", ["a", "b"]]}));
335 assert_eq!(props["Select"], json!({"Fn::Select": [0, ["a", "b"]]}));
336 assert_eq!(props["Split"], json!({"Fn::Split": [",", "a,b"]}));
337 assert_eq!(props["Base64"], json!({"Fn::Base64": "hello"}));
338 assert_eq!(
339 props["FindInMap"],
340 json!({"Fn::FindInMap": ["Map", "Key", "Value"]})
341 );
342 assert_eq!(props["GetAZs"], json!({"Fn::GetAZs": "us-east-1"}));
343 assert_eq!(
344 props["ImportValue"],
345 json!({"Fn::ImportValue": "OtherStackExport"})
346 );
347 assert_eq!(props["Cidr"], json!({"Fn::Cidr": ["10.0.0.0/16", 6, 5]}));
348 assert_eq!(props["Length"], json!({"Fn::Length": ["a", "b"]}));
349 assert_eq!(
350 props["ToJsonString"],
351 json!({"Fn::ToJsonString": {"a": "b"}})
352 );
353 assert_eq!(
354 props["Transform"],
355 json!({"Fn::Transform": {"Name": "Macro"}})
356 );
357 }
358
359 #[test]
360 fn condition_short_forms_expand() {
361 let parsed = parse_template_body(
362 r#"
363Conditions:
364 IsProd: !Equals [!Ref Env, prod]
365 NotProd: !Not [!Condition IsProd]
366 Either: !Or [!Condition IsProd, !Condition NotProd]
367 Both: !And [!Condition IsProd, !Condition NotProd]
368Resources:
369 Thing:
370 Type: AWS::S3::Bucket
371 Properties:
372 Name: !If [IsProd, prod-bucket, dev-bucket]
373"#,
374 )
375 .expect("template parses");
376 assert_eq!(
377 parsed["Conditions"]["IsProd"],
378 json!({"Fn::Equals": [{"Ref": "Env"}, "prod"]})
379 );
380 assert_eq!(
381 parsed["Conditions"]["NotProd"],
382 json!({"Fn::Not": [{"Condition": "IsProd"}]})
383 );
384 assert_eq!(
385 parsed["Conditions"]["Either"],
386 json!({"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "NotProd"}]})
387 );
388 assert_eq!(
389 parsed["Conditions"]["Both"],
390 json!({"Fn::And": [{"Condition": "IsProd"}, {"Condition": "NotProd"}]})
391 );
392 assert_eq!(
393 parsed["Resources"]["Thing"]["Properties"]["Name"],
394 json!({"Fn::If": ["IsProd", "prod-bucket", "dev-bucket"]})
395 );
396 }
397
398 #[test]
399 fn nested_short_forms_resolve_inside_out() {
400 let parsed = parse_template_body(
401 r#"
402Resources:
403 Thing:
404 Type: AWS::SQS::Queue
405 Properties:
406 Name: !Join ["-", [!Ref Prefix, !GetAtt Other.Arn]]
407"#,
408 )
409 .expect("template parses");
410 assert_eq!(
411 parsed["Resources"]["Thing"]["Properties"]["Name"],
412 json!({"Fn::Join": ["-", [{"Ref": "Prefix"}, {"Fn::GetAtt": "Other.Arn"}]]})
413 );
414 }
415
416 #[test]
417 fn long_form_yaml_is_unchanged() {
418 let parsed = parse_template_body(
419 r#"
420Resources:
421 Thing:
422 Type: AWS::S3::Bucket
423 Properties:
424 Name:
425 Fn::Sub: "${AWS::StackName}-bucket"
426 Owner:
427 Ref: OwnerParam
428"#,
429 )
430 .expect("template parses");
431 let props = &parsed["Resources"]["Thing"]["Properties"];
432 assert_eq!(
433 props["Name"],
434 json!({"Fn::Sub": "${AWS::StackName}-bucket"})
435 );
436 assert_eq!(props["Owner"], json!({"Ref": "OwnerParam"}));
437 }
438
439 #[test]
440 fn json_templates_still_parse() {
441 let parsed = parse_template_body(
442 r#"{"Resources": {"Thing": {"Type": "AWS::S3::Bucket", "Properties": {"N": 1.5}}}}"#,
443 )
444 .expect("template parses");
445 assert_eq!(parsed["Resources"]["Thing"]["Properties"]["N"], json!(1.5));
446 }
447
448 #[test]
449 fn unknown_tags_are_an_error() {
450 let err = parse_template_body(
455 r"
456Resources:
457 Thing:
458 Type: AWS::S3::Bucket
459 Properties:
460 Custom: !SomethingElse hello
461",
462 )
463 .expect_err("an unrecognised intrinsic must be reported");
464 assert!(
465 err.contains("unknown intrinsic tag !SomethingElse"),
466 "{err}"
467 );
468 }
469
470 #[test]
471 fn placeholder_bodies_are_not_objects() {
472 assert!(parse_template_object("test").is_none());
473 assert_eq!(parse_template_body("test").expect("scalar parses"), "test");
474 }
475
476 #[test]
477 fn malformed_yaml_reports_an_error() {
478 let err = parse_template_body("Resources:\n - [unbalanced\n").expect_err("must fail");
479 assert!(err.starts_with("Invalid YAML template:"), "{err}");
480 }
481
482 #[test]
483 fn yaml_flow_style_body_still_parses() {
484 let parsed =
487 parse_template_body("{Resources: {A: {Type: AWS::SQS::Queue}}}").expect("parses");
488 assert_eq!(parsed["Resources"]["A"]["Type"], "AWS::SQS::Queue");
489 assert!(parse_template_object("{Resources: {A: {Type: AWS::SQS::Queue}}}").is_some());
490 }
491
492 #[test]
493 fn json_body_that_is_neither_reports_the_json_error() {
494 let err = parse_template_body("{\"Resources\": [oops").expect_err("must fail");
495 assert!(err.starts_with("Invalid JSON template:"), "{err}");
496 }
497
498 #[test]
499 fn flow_style_body_reports_the_yaml_stage_error() {
500 let err = parse_template_body(
504 "{Resources: {Q: {Type: AWS::SQS::Queue, Properties: {V: !GettAtt A.B}}}}",
505 )
506 .expect_err("must fail");
507 assert!(err.contains("unknown intrinsic tag !GettAtt"), "{err}");
508 assert!(!err.contains("Invalid JSON template"), "{err}");
509 }
510
511 #[test]
512 fn syntax_errors_are_still_recognised_as_template_documents() {
513 let tab_indent = "Resources:\n\tBad: indented with a tab\n";
517 assert!(parse_template_body(tab_indent).is_err());
518 assert!(is_template_document(tab_indent));
519
520 let unbalanced = "Resources:\n Queue:\n Type: [AWS::SQS::Queue\n";
521 assert!(parse_template_body(unbalanced).is_err());
522 assert!(is_template_document(unbalanced));
523
524 let bad_json = "{\"Resources\": [oops";
527 assert!(parse_template_body(bad_json).is_err());
528 assert!(is_template_document(bad_json));
529
530 let truncated = "{\"AWSTemplateFormatVersion\": \"2010-09-09\", \"Desc";
533 assert!(parse_template_body(truncated).is_err());
534 assert!(is_template_document(truncated));
535 }
536
537 #[test]
538 fn unknown_tags_are_rejected_not_unwrapped() {
539 let err = parse_template_body(
543 "Resources:\n Q:\n Type: AWS::SQS::Queue\n Properties:\n V: !GettAtt Topic.Arn\n",
544 )
545 .expect_err("a misspelled intrinsic must not be silently unwrapped");
546 assert!(err.contains("unknown intrinsic tag !GettAtt"), "{err}");
547 }
548
549 #[test]
550 fn rules_section_short_forms_expand() {
551 let parsed = parse_template_body(
552 r#"
553Rules:
554 R:
555 Assertions:
556 - Assert: !Contains [[a, b], !Ref Thing]
557 - Assert: !EachMemberEquals [[a], a]
558 - Assert: !EachMemberIn [[a], [a, b]]
559 - Assert: !ValueOf [Param, Tags]
560 - Assert: !ValueOfAll ["AWS::EC2::Subnet::Id", VpcId]
561 - Assert: !RefAll "AWS::EC2::VPC::Id"
562Resources:
563 Q:
564 Type: AWS::SQS::Queue
565"#,
566 )
567 .expect("Rules-section short forms are CloudFormation intrinsics");
568 let asserts = &parsed["Rules"]["R"]["Assertions"];
569 assert_eq!(
570 asserts[0]["Assert"],
571 json!({"Fn::Contains": [["a", "b"], {"Ref": "Thing"}]})
572 );
573 assert_eq!(
574 asserts[1]["Assert"],
575 json!({"Fn::EachMemberEquals": [["a"], "a"]})
576 );
577 assert_eq!(
578 asserts[2]["Assert"],
579 json!({"Fn::EachMemberIn": [["a"], ["a", "b"]]})
580 );
581 assert_eq!(
582 asserts[3]["Assert"],
583 json!({"Fn::ValueOf": ["Param", "Tags"]})
584 );
585 assert_eq!(
586 asserts[5]["Assert"],
587 json!({"Fn::RefAll": "AWS::EC2::VPC::Id"})
588 );
589 }
590
591 #[test]
592 fn double_bang_tags_pass_through() {
593 let parsed = parse_template_body(
600 "Resources:\n Q:\n Type: AWS::SQS::Queue\n Properties:\n A: !!custom 5\n B: !!binary aGk=\n C: !!timestamp 2024-01-01\n",
601 )
602 .expect("`!!` tags resolve before reaching the intrinsic check");
603 let props = &parsed["Resources"]["Q"]["Properties"];
604 assert_eq!(props["A"], json!("5"));
605 assert_eq!(props["B"], json!("aGk="));
606 assert_eq!(props["C"], json!("2024-01-01"));
607 }
608
609 #[test]
610 fn yaml_standard_tags_still_pass_through() {
611 let parsed = parse_template_body(
614 "Resources:\n Q:\n Type: AWS::SQS::Queue\n Properties:\n A: !!str 5\n B: !!int \"7\"\n",
615 )
616 .expect("standard YAML tags are not CloudFormation intrinsics");
617 let props = &parsed["Resources"]["Q"]["Properties"];
618 assert_eq!(props["A"], json!("5"));
619 assert_eq!(props["B"], json!(7));
620 }
621
622 #[test]
623 fn quoted_or_spaced_resources_key_is_recognised() {
624 for body in [
627 "\"Resources\":\n\tQ:\n\t\tType: AWS::SQS::Queue\n",
628 "'Resources':\n\tQ:\n\t\tType: AWS::SQS::Queue\n",
629 "Resources :\n\tQ:\n\t\tType: AWS::SQS::Queue\n",
630 ] {
631 assert!(
632 parse_template_body(body).is_err(),
633 "{body:?} should not parse"
634 );
635 assert!(
636 is_template_document(body),
637 "{body:?} must be recognised as a template"
638 );
639 }
640 }
641
642 #[test]
643 fn wrong_shaped_resources_section_is_still_a_template_document() {
644 let seq = "Resources:\n - Type: AWS::SQS::Queue\n";
648 assert!(parse_template_body(seq).is_ok(), "parses as YAML");
649 assert!(is_template_document(seq));
650
651 let broken_flow = "{Resources: {A: {Type: AWS::SQS::Queue}";
654 assert!(parse_template_body(broken_flow).is_err());
655 assert!(is_template_document(broken_flow));
656 }
657
658 #[test]
659 fn non_finite_numbers_are_reported_not_nulled() {
660 let err = parse_template_body("Resources:\n Q:\n Timeout: .nan\n")
661 .expect_err("NaN has no JSON representation");
662 assert!(err.contains("no JSON representation"), "{err}");
663 assert!(is_template_document(
665 "Resources:\n Q:\n Timeout: .nan\n"
666 ));
667 }
668
669 #[test]
670 fn indented_section_names_are_not_top_level_keys() {
671 let indented = " Parameters:\n\tbroken\n";
675 assert!(parse_template_body(indented).is_err());
676 assert!(!is_template_document(indented));
677 }
678
679 #[test]
680 fn unparseable_body_is_recognised_by_any_top_level_section() {
681 let body =
685 "Parameters:\n\tEnv:\n\t\tType: String\nResources:\n Q:\n Type: AWS::SQS::Queue\n";
686 assert!(parse_template_body(body).is_err());
687 assert!(is_template_document(body));
688
689 let truncated = "AWSTemplateFormatVersion: '2010-09-09'\nParameters:\n\tEnv:\n";
691 assert!(parse_template_body(truncated).is_err());
692 assert!(is_template_document(truncated));
693 }
694
695 #[test]
696 fn other_top_level_sections_mark_a_template_document() {
697 for body in [
702 "Parameters:\n Env:\n Type: String\n",
703 "AWSTemplateFormatVersion: '2010-09-09'\n",
704 "Outputs:\n A:\n Value: x\n",
705 "Transform: AWS::Serverless-2016-10-31\n",
706 ] {
707 assert!(parse_template_body(body).is_ok(), "{body:?}");
708 assert!(is_template_document(body), "{body:?}");
709 }
710 }
711
712 #[test]
713 fn bom_prefixed_templates_are_recognised() {
714 let good = "\u{FEFF}Resources:\n Q:\n Type: AWS::SQS::Queue\n";
717 assert_eq!(
718 parse_template_body(good).expect("BOM'd template parses")["Resources"]["Q"]["Type"],
719 "AWS::SQS::Queue"
720 );
721 assert!(is_template_document(good));
722
723 let broken = "\u{FEFF}Resources:\n\tQ:\n\t\tType: AWS::SQS::Queue\n";
726 assert!(parse_template_body(broken).is_err());
727 assert!(is_template_document(broken));
728
729 let json = "\u{FEFF}{\"Resources\": {\"Q\": {\"Type\": \"AWS::SQS::Queue\"}}}";
731 assert!(parse_template_body(json).is_ok());
732 assert!(is_template_document(json));
733 }
734
735 #[test]
736 fn placeholders_are_not_template_documents() {
737 for placeholder in ["test", "t", "aaaaaaaa", "", "dGVzdA=="] {
740 assert!(
741 !is_template_document(placeholder),
742 "{placeholder:?} must not be treated as a template"
743 );
744 }
745 assert!(is_template_document("Description: just a description\n"));
749 assert!(is_template_document("{}"));
750 assert!(is_template_document("foo: 1\n"));
751
752 for empty in ["", " ", "\n", "null", "~"] {
755 assert!(
756 !is_template_document(empty),
757 "{empty:?} must keep the lenient path"
758 );
759 }
760
761 assert!(!is_template_document(
765 "Name\tResources\tOwner\n\tbad\ttabs\there\n"
766 ));
767 }
768
769 #[test]
770 fn non_object_bodies_are_not_placeholders() {
771 for body in ["[]", "[a, b]", "1", "true", "- a\n- b\n"] {
776 assert!(parse_template_body(body).is_ok(), "{body:?} should parse");
777 assert!(is_template_document(body), "{body:?} is not a placeholder");
778 }
779 }
780
781 #[test]
782 fn well_formed_templates_are_template_documents() {
783 assert!(is_template_document(
784 "Resources:\n Q:\n Type: AWS::SQS::Queue\n"
785 ));
786 assert!(is_template_document(
787 r#"{"Resources": {"Q": {"Type": "AWS::SQS::Queue"}}}"#
788 ));
789 }
790}