use serde_json::{Map, Value as Json};
use serde_yaml::value::TaggedValue;
use serde_yaml::Value as Yaml;
const FN_TAGS: &[&str] = &[
"And",
"Base64",
"Cidr",
"Contains",
"EachMemberEquals",
"EachMemberIn",
"Equals",
"FindInMap",
"GetAZs",
"GetAtt",
"If",
"ImportValue",
"Join",
"Length",
"Not",
"Or",
"RefAll",
"Select",
"Split",
"Sub",
"ToJsonString",
"Transform",
"ValueOf",
"ValueOfAll",
];
const BARE_TAGS: &[&str] = &["Ref", "Condition"];
pub fn parse_template_body(body: &str) -> Result<Json, String> {
let body = strip_bom(body);
if body.trim_start().starts_with('{') {
return match serde_json::from_str(body) {
Ok(value) => Ok(value),
Err(json_err) => match serde_yaml::from_str::<Yaml>(body) {
Ok(yaml) => yaml_to_json(yaml).map_err(|e| format!("Invalid YAML template: {e}")),
Err(_) => Err(format!("Invalid JSON template: {json_err}")),
},
};
}
parse_yaml(body)
}
fn parse_yaml(body: &str) -> Result<Json, String> {
let yaml: Yaml =
serde_yaml::from_str(body).map_err(|e| format!("Invalid YAML template: {e}"))?;
yaml_to_json(yaml).map_err(|e| format!("Invalid YAML template: {e}"))
}
fn strip_bom(body: &str) -> &str {
body.strip_prefix('\u{FEFF}').unwrap_or(body)
}
pub fn is_template_document(body: &str) -> bool {
let body = strip_bom(body);
if let Ok(value) = parse_template_body(body) {
return !matches!(value, Json::String(_) | Json::Null);
}
looks_like_template_text(body)
}
const TEMPLATE_SECTIONS: &[&str] = &[
"AWSTemplateFormatVersion",
"Conditions",
"Mappings",
"Metadata",
"Outputs",
"Parameters",
"Resources",
"Rules",
"Transform",
];
fn looks_like_template_text(body: &str) -> bool {
if body.trim_start().starts_with('{') {
return true;
}
body.lines().any(declares_template_section)
}
fn declares_template_section(line: &str) -> bool {
let unquoted = line
.strip_prefix('"')
.or_else(|| line.strip_prefix('\''))
.unwrap_or(line);
TEMPLATE_SECTIONS.iter().any(|section| {
let Some(rest) = unquoted.strip_prefix(section) else {
return false;
};
let rest = rest
.strip_prefix('"')
.or_else(|| rest.strip_prefix('\''))
.unwrap_or(rest);
rest.trim_start().starts_with(':')
})
}
pub fn parse_template_object(body: &str) -> Option<Json> {
parse_template_body(body).ok().filter(Json::is_object)
}
fn yaml_to_json(value: Yaml) -> Result<Json, String> {
Ok(match value {
Yaml::Null => Json::Null,
Yaml::Bool(b) => Json::Bool(b),
Yaml::Number(n) => number_to_json(&n)?,
Yaml::String(s) => Json::String(s),
Yaml::Sequence(seq) => Json::Array(
seq.into_iter()
.map(yaml_to_json)
.collect::<Result<Vec<_>, _>>()?,
),
Yaml::Mapping(map) => {
let mut obj = Map::new();
for (k, v) in map {
obj.insert(mapping_key(k)?, yaml_to_json(v)?);
}
Json::Object(obj)
}
Yaml::Tagged(tagged) => tagged_to_json(*tagged)?,
})
}
fn mapping_key(key: Yaml) -> Result<String, String> {
Ok(match yaml_to_json(key)? {
Json::String(s) => s,
other => other.to_string(),
})
}
fn number_to_json(n: &serde_yaml::Number) -> Result<Json, String> {
if let Some(i) = n.as_i64() {
return Ok(Json::Number(i.into()));
}
if let Some(u) = n.as_u64() {
return Ok(Json::Number(u.into()));
}
n.as_f64()
.and_then(serde_json::Number::from_f64)
.map(Json::Number)
.ok_or_else(|| format!("number {n} has no JSON representation"))
}
fn tagged_to_json(tagged: TaggedValue) -> Result<Json, String> {
let rendered = tagged.tag.to_string();
let name = rendered.strip_prefix('!').unwrap_or(&rendered);
let arg = yaml_to_json(tagged.value)?;
let key = if BARE_TAGS.contains(&name) {
name.to_string()
} else if FN_TAGS.contains(&name) {
format!("Fn::{name}")
} else {
return Err(format!("unknown intrinsic tag !{name}"));
};
let mut obj = Map::new();
obj.insert(key, arg);
Ok(Json::Object(obj))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn short_form_ref_becomes_long_form() {
let parsed = parse_template_body(
r"
Resources:
ReproBucket:
Type: AWS::S3::Bucket
Outputs:
BucketName:
Value: !Ref ReproBucket
",
)
.expect("template parses");
assert_eq!(
parsed["Outputs"]["BucketName"]["Value"],
json!({"Ref": "ReproBucket"})
);
assert_eq!(
parsed["Resources"]["ReproBucket"]["Type"],
"AWS::S3::Bucket"
);
}
#[test]
fn every_intrinsic_short_form_expands() {
let parsed = parse_template_body(
r#"
Resources:
Thing:
Type: AWS::S3::Bucket
Properties:
Sub: !Sub "${AWS::StackName}-bucket"
GetAtt: !GetAtt Other.Arn
GetAttList: !GetAtt [Other, Arn]
Join: !Join ["-", [a, b]]
Select: !Select [0, [a, b]]
Split: !Split [",", "a,b"]
Base64: !Base64 hello
FindInMap: !FindInMap [Map, Key, Value]
GetAZs: !GetAZs us-east-1
ImportValue: !ImportValue OtherStackExport
Cidr: !Cidr ["10.0.0.0/16", 6, 5]
Length: !Length [a, b]
ToJsonString: !ToJsonString {a: b}
Transform: !Transform {Name: Macro}
"#,
)
.expect("template parses");
let props = &parsed["Resources"]["Thing"]["Properties"];
assert_eq!(props["Sub"], json!({"Fn::Sub": "${AWS::StackName}-bucket"}));
assert_eq!(props["GetAtt"], json!({"Fn::GetAtt": "Other.Arn"}));
assert_eq!(props["GetAttList"], json!({"Fn::GetAtt": ["Other", "Arn"]}));
assert_eq!(props["Join"], json!({"Fn::Join": ["-", ["a", "b"]]}));
assert_eq!(props["Select"], json!({"Fn::Select": [0, ["a", "b"]]}));
assert_eq!(props["Split"], json!({"Fn::Split": [",", "a,b"]}));
assert_eq!(props["Base64"], json!({"Fn::Base64": "hello"}));
assert_eq!(
props["FindInMap"],
json!({"Fn::FindInMap": ["Map", "Key", "Value"]})
);
assert_eq!(props["GetAZs"], json!({"Fn::GetAZs": "us-east-1"}));
assert_eq!(
props["ImportValue"],
json!({"Fn::ImportValue": "OtherStackExport"})
);
assert_eq!(props["Cidr"], json!({"Fn::Cidr": ["10.0.0.0/16", 6, 5]}));
assert_eq!(props["Length"], json!({"Fn::Length": ["a", "b"]}));
assert_eq!(
props["ToJsonString"],
json!({"Fn::ToJsonString": {"a": "b"}})
);
assert_eq!(
props["Transform"],
json!({"Fn::Transform": {"Name": "Macro"}})
);
}
#[test]
fn condition_short_forms_expand() {
let parsed = parse_template_body(
r#"
Conditions:
IsProd: !Equals [!Ref Env, prod]
NotProd: !Not [!Condition IsProd]
Either: !Or [!Condition IsProd, !Condition NotProd]
Both: !And [!Condition IsProd, !Condition NotProd]
Resources:
Thing:
Type: AWS::S3::Bucket
Properties:
Name: !If [IsProd, prod-bucket, dev-bucket]
"#,
)
.expect("template parses");
assert_eq!(
parsed["Conditions"]["IsProd"],
json!({"Fn::Equals": [{"Ref": "Env"}, "prod"]})
);
assert_eq!(
parsed["Conditions"]["NotProd"],
json!({"Fn::Not": [{"Condition": "IsProd"}]})
);
assert_eq!(
parsed["Conditions"]["Either"],
json!({"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "NotProd"}]})
);
assert_eq!(
parsed["Conditions"]["Both"],
json!({"Fn::And": [{"Condition": "IsProd"}, {"Condition": "NotProd"}]})
);
assert_eq!(
parsed["Resources"]["Thing"]["Properties"]["Name"],
json!({"Fn::If": ["IsProd", "prod-bucket", "dev-bucket"]})
);
}
#[test]
fn nested_short_forms_resolve_inside_out() {
let parsed = parse_template_body(
r#"
Resources:
Thing:
Type: AWS::SQS::Queue
Properties:
Name: !Join ["-", [!Ref Prefix, !GetAtt Other.Arn]]
"#,
)
.expect("template parses");
assert_eq!(
parsed["Resources"]["Thing"]["Properties"]["Name"],
json!({"Fn::Join": ["-", [{"Ref": "Prefix"}, {"Fn::GetAtt": "Other.Arn"}]]})
);
}
#[test]
fn long_form_yaml_is_unchanged() {
let parsed = parse_template_body(
r#"
Resources:
Thing:
Type: AWS::S3::Bucket
Properties:
Name:
Fn::Sub: "${AWS::StackName}-bucket"
Owner:
Ref: OwnerParam
"#,
)
.expect("template parses");
let props = &parsed["Resources"]["Thing"]["Properties"];
assert_eq!(
props["Name"],
json!({"Fn::Sub": "${AWS::StackName}-bucket"})
);
assert_eq!(props["Owner"], json!({"Ref": "OwnerParam"}));
}
#[test]
fn json_templates_still_parse() {
let parsed = parse_template_body(
r#"{"Resources": {"Thing": {"Type": "AWS::S3::Bucket", "Properties": {"N": 1.5}}}}"#,
)
.expect("template parses");
assert_eq!(parsed["Resources"]["Thing"]["Properties"]["N"], json!(1.5));
}
#[test]
fn unknown_tags_are_an_error() {
let err = parse_template_body(
r"
Resources:
Thing:
Type: AWS::S3::Bucket
Properties:
Custom: !SomethingElse hello
",
)
.expect_err("an unrecognised intrinsic must be reported");
assert!(
err.contains("unknown intrinsic tag !SomethingElse"),
"{err}"
);
}
#[test]
fn placeholder_bodies_are_not_objects() {
assert!(parse_template_object("test").is_none());
assert_eq!(parse_template_body("test").expect("scalar parses"), "test");
}
#[test]
fn malformed_yaml_reports_an_error() {
let err = parse_template_body("Resources:\n - [unbalanced\n").expect_err("must fail");
assert!(err.starts_with("Invalid YAML template:"), "{err}");
}
#[test]
fn yaml_flow_style_body_still_parses() {
let parsed =
parse_template_body("{Resources: {A: {Type: AWS::SQS::Queue}}}").expect("parses");
assert_eq!(parsed["Resources"]["A"]["Type"], "AWS::SQS::Queue");
assert!(parse_template_object("{Resources: {A: {Type: AWS::SQS::Queue}}}").is_some());
}
#[test]
fn json_body_that_is_neither_reports_the_json_error() {
let err = parse_template_body("{\"Resources\": [oops").expect_err("must fail");
assert!(err.starts_with("Invalid JSON template:"), "{err}");
}
#[test]
fn flow_style_body_reports_the_yaml_stage_error() {
let err = parse_template_body(
"{Resources: {Q: {Type: AWS::SQS::Queue, Properties: {V: !GettAtt A.B}}}}",
)
.expect_err("must fail");
assert!(err.contains("unknown intrinsic tag !GettAtt"), "{err}");
assert!(!err.contains("Invalid JSON template"), "{err}");
}
#[test]
fn syntax_errors_are_still_recognised_as_template_documents() {
let tab_indent = "Resources:\n\tBad: indented with a tab\n";
assert!(parse_template_body(tab_indent).is_err());
assert!(is_template_document(tab_indent));
let unbalanced = "Resources:\n Queue:\n Type: [AWS::SQS::Queue\n";
assert!(parse_template_body(unbalanced).is_err());
assert!(is_template_document(unbalanced));
let bad_json = "{\"Resources\": [oops";
assert!(parse_template_body(bad_json).is_err());
assert!(is_template_document(bad_json));
let truncated = "{\"AWSTemplateFormatVersion\": \"2010-09-09\", \"Desc";
assert!(parse_template_body(truncated).is_err());
assert!(is_template_document(truncated));
}
#[test]
fn unknown_tags_are_rejected_not_unwrapped() {
let err = parse_template_body(
"Resources:\n Q:\n Type: AWS::SQS::Queue\n Properties:\n V: !GettAtt Topic.Arn\n",
)
.expect_err("a misspelled intrinsic must not be silently unwrapped");
assert!(err.contains("unknown intrinsic tag !GettAtt"), "{err}");
}
#[test]
fn rules_section_short_forms_expand() {
let parsed = parse_template_body(
r#"
Rules:
R:
Assertions:
- Assert: !Contains [[a, b], !Ref Thing]
- Assert: !EachMemberEquals [[a], a]
- Assert: !EachMemberIn [[a], [a, b]]
- Assert: !ValueOf [Param, Tags]
- Assert: !ValueOfAll ["AWS::EC2::Subnet::Id", VpcId]
- Assert: !RefAll "AWS::EC2::VPC::Id"
Resources:
Q:
Type: AWS::SQS::Queue
"#,
)
.expect("Rules-section short forms are CloudFormation intrinsics");
let asserts = &parsed["Rules"]["R"]["Assertions"];
assert_eq!(
asserts[0]["Assert"],
json!({"Fn::Contains": [["a", "b"], {"Ref": "Thing"}]})
);
assert_eq!(
asserts[1]["Assert"],
json!({"Fn::EachMemberEquals": [["a"], "a"]})
);
assert_eq!(
asserts[2]["Assert"],
json!({"Fn::EachMemberIn": [["a"], ["a", "b"]]})
);
assert_eq!(
asserts[3]["Assert"],
json!({"Fn::ValueOf": ["Param", "Tags"]})
);
assert_eq!(
asserts[5]["Assert"],
json!({"Fn::RefAll": "AWS::EC2::VPC::Id"})
);
}
#[test]
fn double_bang_tags_pass_through() {
let parsed = parse_template_body(
"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",
)
.expect("`!!` tags resolve before reaching the intrinsic check");
let props = &parsed["Resources"]["Q"]["Properties"];
assert_eq!(props["A"], json!("5"));
assert_eq!(props["B"], json!("aGk="));
assert_eq!(props["C"], json!("2024-01-01"));
}
#[test]
fn yaml_standard_tags_still_pass_through() {
let parsed = parse_template_body(
"Resources:\n Q:\n Type: AWS::SQS::Queue\n Properties:\n A: !!str 5\n B: !!int \"7\"\n",
)
.expect("standard YAML tags are not CloudFormation intrinsics");
let props = &parsed["Resources"]["Q"]["Properties"];
assert_eq!(props["A"], json!("5"));
assert_eq!(props["B"], json!(7));
}
#[test]
fn quoted_or_spaced_resources_key_is_recognised() {
for body in [
"\"Resources\":\n\tQ:\n\t\tType: AWS::SQS::Queue\n",
"'Resources':\n\tQ:\n\t\tType: AWS::SQS::Queue\n",
"Resources :\n\tQ:\n\t\tType: AWS::SQS::Queue\n",
] {
assert!(
parse_template_body(body).is_err(),
"{body:?} should not parse"
);
assert!(
is_template_document(body),
"{body:?} must be recognised as a template"
);
}
}
#[test]
fn wrong_shaped_resources_section_is_still_a_template_document() {
let seq = "Resources:\n - Type: AWS::SQS::Queue\n";
assert!(parse_template_body(seq).is_ok(), "parses as YAML");
assert!(is_template_document(seq));
let broken_flow = "{Resources: {A: {Type: AWS::SQS::Queue}";
assert!(parse_template_body(broken_flow).is_err());
assert!(is_template_document(broken_flow));
}
#[test]
fn non_finite_numbers_are_reported_not_nulled() {
let err = parse_template_body("Resources:\n Q:\n Timeout: .nan\n")
.expect_err("NaN has no JSON representation");
assert!(err.contains("no JSON representation"), "{err}");
assert!(is_template_document(
"Resources:\n Q:\n Timeout: .nan\n"
));
}
#[test]
fn indented_section_names_are_not_top_level_keys() {
let indented = " Parameters:\n\tbroken\n";
assert!(parse_template_body(indented).is_err());
assert!(!is_template_document(indented));
}
#[test]
fn unparseable_body_is_recognised_by_any_top_level_section() {
let body =
"Parameters:\n\tEnv:\n\t\tType: String\nResources:\n Q:\n Type: AWS::SQS::Queue\n";
assert!(parse_template_body(body).is_err());
assert!(is_template_document(body));
let truncated = "AWSTemplateFormatVersion: '2010-09-09'\nParameters:\n\tEnv:\n";
assert!(parse_template_body(truncated).is_err());
assert!(is_template_document(truncated));
}
#[test]
fn other_top_level_sections_mark_a_template_document() {
for body in [
"Parameters:\n Env:\n Type: String\n",
"AWSTemplateFormatVersion: '2010-09-09'\n",
"Outputs:\n A:\n Value: x\n",
"Transform: AWS::Serverless-2016-10-31\n",
] {
assert!(parse_template_body(body).is_ok(), "{body:?}");
assert!(is_template_document(body), "{body:?}");
}
}
#[test]
fn bom_prefixed_templates_are_recognised() {
let good = "\u{FEFF}Resources:\n Q:\n Type: AWS::SQS::Queue\n";
assert_eq!(
parse_template_body(good).expect("BOM'd template parses")["Resources"]["Q"]["Type"],
"AWS::SQS::Queue"
);
assert!(is_template_document(good));
let broken = "\u{FEFF}Resources:\n\tQ:\n\t\tType: AWS::SQS::Queue\n";
assert!(parse_template_body(broken).is_err());
assert!(is_template_document(broken));
let json = "\u{FEFF}{\"Resources\": {\"Q\": {\"Type\": \"AWS::SQS::Queue\"}}}";
assert!(parse_template_body(json).is_ok());
assert!(is_template_document(json));
}
#[test]
fn placeholders_are_not_template_documents() {
for placeholder in ["test", "t", "aaaaaaaa", "", "dGVzdA=="] {
assert!(
!is_template_document(placeholder),
"{placeholder:?} must not be treated as a template"
);
}
assert!(is_template_document("Description: just a description\n"));
assert!(is_template_document("{}"));
assert!(is_template_document("foo: 1\n"));
for empty in ["", " ", "\n", "null", "~"] {
assert!(
!is_template_document(empty),
"{empty:?} must keep the lenient path"
);
}
assert!(!is_template_document(
"Name\tResources\tOwner\n\tbad\ttabs\there\n"
));
}
#[test]
fn non_object_bodies_are_not_placeholders() {
for body in ["[]", "[a, b]", "1", "true", "- a\n- b\n"] {
assert!(parse_template_body(body).is_ok(), "{body:?} should parse");
assert!(is_template_document(body), "{body:?} is not a placeholder");
}
}
#[test]
fn well_formed_templates_are_template_documents() {
assert!(is_template_document(
"Resources:\n Q:\n Type: AWS::SQS::Queue\n"
));
assert!(is_template_document(
r#"{"Resources": {"Q": {"Type": "AWS::SQS::Queue"}}}"#
));
}
}