Skip to main content

fakecloud_core/
cfn_template.rs

1//! Parsing for CloudFormation-shaped template bodies.
2//!
3//! A template body arrives as either JSON or YAML, and the YAML dialect
4//! CloudFormation accepts is not plain YAML: intrinsic functions may be
5//! written as short-form node tags (`!Ref`, `!GetAtt`, `!Sub`, ...). A plain
6//! YAML-to-JSON deserialize rejects those outright — the whole document fails,
7//! not just the tagged node — so a template using the short forms parsed to
8//! nothing at all and stacks came up empty (#2480).
9//!
10//! Everything that reads a template body (CloudFormation, Serverless
11//! Application Repository, Config conformance packs) goes through here, so a
12//! short form behaves exactly like its `{"Fn::Sub": ...}` long-form spelling.
13
14use serde_json::{Map, Value as Json};
15use serde_yaml::value::TaggedValue;
16use serde_yaml::Value as Yaml;
17
18/// Short-form tags that expand to `{"Fn::<Tag>": <arg>}`. Covers the template
19/// intrinsics, the condition functions, and the `Rules`-section functions —
20/// CloudFormation defines no others, which is what lets an unrecognised tag be
21/// treated as an error rather than quietly unwrapped.
22const 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
49/// Short-form tags that expand to a bare `{"<Tag>": <arg>}` key. `Ref` and
50/// `Condition` are the two CloudFormation spells without the `Fn::` prefix.
51const BARE_TAGS: &[&str] = &["Ref", "Condition"];
52
53/// Parse a CloudFormation template body (JSON or YAML, short-form tags
54/// included) into a JSON value with every intrinsic in its long form.
55///
56/// A body starting with `{` is tried as JSON first — YAML is a JSON superset,
57/// but going through `serde_json` preserves exact numeric precision for the
58/// programmatically-generated templates that dominate that case. It still
59/// falls back to YAML on a JSON parse error, because YAML *flow* style also
60/// opens with `{` (`{Resources: {A: {Type: X}}}` is valid YAML and invalid
61/// JSON — unquoted keys), and committing to JSON on the first character would
62/// silently drop such a template.
63pub 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                // It is valid YAML after all, so any remaining problem is a
70                // YAML-stage one (an unknown intrinsic tag, a non-finite
71                // number) and that message is the actionable diagnostic.
72                // Reporting the JSON error here would point at syntax that is
73                // legal in the dialect actually being used.
74                Ok(yaml) => yaml_to_json(yaml).map_err(|e| format!("Invalid YAML template: {e}")),
75                // Neither dialect parses. For a `{`-leading body the JSON
76                // error is what the author needs.
77                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
90/// Strip a UTF-8 byte-order mark. `str::trim_start` does not remove U+FEFF,
91/// and PowerShell's `Out-File` / `Set-Content` write one by default — so a
92/// BOM'd template's first line reads `\u{FEFF}Resources:`, which fails every
93/// prefix check and sends the body down the lenient path (#2480 again).
94fn strip_bom(body: &str) -> &str {
95    body.strip_prefix('\u{FEFF}').unwrap_or(body)
96}
97
98/// Whether a body is meant to be a CloudFormation template *document*, as
99/// opposed to a placeholder scalar (the conformance probe sends `"test"`-style
100/// strings for `TemplateBody`).
101///
102/// A body that parses is judged on its shape: only a string scalar or an
103/// absent/empty body is a placeholder.
104/// One that does NOT parse still has to be classified — and it is exactly the
105/// interesting case, since a syntax error (a stray tab, a bad indent, an
106/// unbalanced bracket) is the most common way a real template breaks. Re-using
107/// the parser here would answer "not a template" for every syntax error and
108/// send it down the lenient degrade-to-empty path, which is the silent no-op
109/// #2480 is about. So fall back to a text-level scan for any top-level section
110/// name.
111pub fn is_template_document(body: &str) -> bool {
112    let body = strip_bom(body);
113    if let Ok(value) = parse_template_body(body) {
114        // Presence, not shape: `Resources:` holding a sequence instead of a
115        // mapping is a common YAML slip that the template parser rejects. It
116        // is still unmistakably someone's template, so it must fail loudly
117        // rather than degrade to an empty stack.
118        //
119        // The other top-level sections count too. A template that declares
120        // `Parameters` or `AWSTemplateFormatVersion` but omits `Resources` is
121        // rejected by the parser ("Template must contain a Resources
122        // section"), and judging it on `Resources` alone would send that error
123        // down the lenient path — accepting an empty stack, or on update
124        // removing every existing resource. `Description` is deliberately not
125        // in the list: on its own it is too generic to mark a body as a
126        // template.
127        // Only a string scalar and an absent/empty body stay lenient: the
128        // first is the placeholder shape the probe sends
129        // (`TemplateBody="test"`), the second is an omitted member. Anything
130        // else — a mapping (whatever sections it has, including none), a
131        // sequence, a number, a bool — is someone submitting a document, and
132        // real CloudFormation rejects the ones that aren't valid templates
133        // rather than building an empty stack from them.
134        return !matches!(value, Json::String(_) | Json::Null);
135    }
136    looks_like_template_text(body)
137}
138
139/// Top-level sections that mark an *unparseable* body as someone's
140/// CloudFormation template, per the template anatomy AWS documents. A body
141/// that parses is judged by its shape instead (see `is_template_document`).
142const TEMPLATE_SECTIONS: &[&str] = &[
143    "AWSTemplateFormatVersion",
144    "Conditions",
145    "Mappings",
146    "Metadata",
147    "Outputs",
148    "Parameters",
149    "Resources",
150    "Rules",
151    "Transform",
152];
153
154/// Text-level shape check for a body that would not parse: does it *look* like
155/// someone's template? Keyed on any top-level section CloudFormation defines,
156/// not just `Resources` — the syntax error may sit in (or truncate at) the
157/// `Parameters` block before `Resources:` is ever reached.
158fn looks_like_template_text(body: &str) -> bool {
159    if body.trim_start().starts_with('{') {
160        // A `{`-leading body that parses as neither JSON nor YAML is a broken
161        // document, not a placeholder — the synthetic scalars are bare strings
162        // (`test`), never braced. Substring-matching `Resources` here would be
163        // wrong in both directions: a JSON template truncated before its
164        // `"Resources"` key would be judged a placeholder and silently produce
165        // an empty stack, while `"Description": "Resources: see docs"` would be
166        // force-failed.
167        return true;
168    }
169    // A top-level YAML key sits at column zero. It may be quoted, and may
170    // carry whitespace before its colon — both legal, and both emitted by
171    // real generators.
172    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    // The same section list the parsed branch uses. Keying only on
181    // `Resources:` would miss a template whose syntax error sits in (or
182    // truncates at) the `Parameters` / `Mappings` block above it, sending it
183    // back down the silent-empty-stack path.
184    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        // Require the colon. A real top-level key always has one, and without
193        // it any unparseable body that merely contains one of these words (a
194        // pasted log, a CSV header) would be classified as a template —
195        // turning a lenient degrade into a hard error.
196        rest.trim_start().starts_with(':')
197    })
198}
199
200/// Convenience wrapper for callers that only want a template-shaped object and
201/// treat anything else (a placeholder scalar, a parse failure) as absent.
202pub 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
228/// JSON object keys are strings; a YAML mapping key that isn't one (`1: foo`)
229/// takes its JSON rendering, matching how the key would have been written in
230/// the equivalent JSON template.
231fn 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    // YAML has `.nan` / `.inf`; JSON has no representation for either, and no
246    // CloudFormation property legitimately holds one. Surfacing the bad value
247    // beats silently rewriting it to `null`, which reads downstream as a
248    // deliberately-absent property.
249    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        // An unrecognised single-`!` tag. CloudFormation defines a closed set
265        // of short forms, so this is a typo (`!GettAtt`) or an unsupported
266        // function. Unwrapping it to its argument would turn `!GettAtt
267        // Topic.Arn` into the literal string "Topic.Arn" and provision a
268        // wrong-but-plausible value under a CREATE_COMPLETE stack — the same
269        // silent-wrong-value class this module exists to close.
270        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        // This originally asserted the tag unwrapped to its value. That is
451        // precisely the silent-wrong-value behaviour the module exists to
452        // prevent: the property would provision as the string "hello" under a
453        // CREATE_COMPLETE stack, with nothing reported.
454        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        // Valid YAML, invalid JSON (unquoted keys) despite the leading `{`.
485        // Committing to JSON on the first character would drop it silently.
486        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        // Valid YAML, invalid JSON. The real problem is the misspelled
501        // intrinsic, so reporting the JSON parse error would point the author
502        // at syntax that is legal in the dialect they actually used.
503        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        // The #2480 case: a template that does not parse must still be
514        // classified as a template, so the caller can fail loudly instead of
515        // degrading to an empty stack.
516        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        // Unbalanced in both dialects, so neither the JSON nor the YAML pass
525        // can rescue it.
526        let bad_json = "{\"Resources\": [oops";
527        assert!(parse_template_body(bad_json).is_err());
528        assert!(is_template_document(bad_json));
529
530        // Truncated before the `Resources` key ever appears. A substring test
531        // would call this a placeholder and silently build an empty stack.
532        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        // A one-character typo used to unwrap to the literal string
540        // "Topic.Arn" and provision a wrong-but-plausible value under a
541        // CREATE_COMPLETE stack.
542        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        // `!!`-prefixed tags never reach `tagged_to_json` at all: libyaml
594        // expands the `!!` handle and serde_yaml resolves the result, so the
595        // node arrives already plain. Verified for `!!binary`, `!!timestamp`,
596        // `!!custom` and `!!str` — all come back as values, never
597        // `Value::Tagged`. That is why the unknown-intrinsic check below can
598        // reject every tag it sees without special-casing them.
599        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        // `!!str` and friends are YAML's own tags, not CloudFormation's, and
612        // must not trip the unknown-intrinsic check.
613        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        // Legal YAML spellings of the top-level key, each paired with a
625        // syntax error elsewhere so classification runs on the raw text.
626        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        // `Resources` as a sequence instead of a mapping is a common YAML
645        // slip. The template parser rejects it, so it has to be classified as
646        // a template or it degrades to a silent empty stack.
647        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        // Unparseable flow-style YAML: the key is unquoted, so a check for
652        // the JSON spelling alone would miss it.
653        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        // Still classified as a template, so the caller fails loudly.
664        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        // Only a column-zero key marks a template. An indented `Parameters:`
672        // (a nested property, a pasted fragment) must keep the lenient path,
673        // or a placeholder input would take the hard-failure route.
674        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        // The syntax error sits in `Parameters`, before `Resources:` is ever
682        // reached — a text scan keyed only on `Resources:` would call this a
683        // placeholder and silently build an empty stack.
684        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        // Truncated before `Resources:` appears at all.
690        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        // Parses fine, but the template parser rejects it for having no
698        // `Resources`. Judging on `Resources` alone would send that error down
699        // the lenient path — an empty stack on create, and on update the
700        // removal of every existing resource.
701        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        // PowerShell's Out-File / Set-Content write a UTF-8 BOM by default,
715        // and `trim_start` does not strip U+FEFF.
716        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        // The case that mattered: BOM + a syntax error must still be
724        // classified as a template, or it degrades to an empty stack.
725        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        // BOM in front of a JSON body too.
730        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        // The conformance probe's synthetic TemplateBody values must keep the
738        // lenient path — ValidationError is not in CreateStack's Smithy errors.
739        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        // A mapping is always a submitted document, whatever it contains —
746        // real CloudFormation rejects one with no Resources rather than
747        // building an empty stack from it.
748        assert!(is_template_document("Description: just a description\n"));
749        assert!(is_template_document("{}"));
750        assert!(is_template_document("foo: 1\n"));
751
752        // An empty / absent body parses to null and must stay lenient — the
753        // probe omits TemplateBody entirely on some variants.
754        for empty in ["", "   ", "\n", "null", "~"] {
755            assert!(
756                !is_template_document(empty),
757                "{empty:?} must keep the lenient path"
758            );
759        }
760
761        // An unparseable body that merely mentions `Resources` without making
762        // it a key (a pasted log, a CSV header) must stay lenient — the hard
763        // failure paths would otherwise reject it outright.
764        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        // These parse, but no CloudFormation template is a sequence, a number
772        // or a bool. Real CFN rejects them as an unsupported structure;
773        // treating them as placeholders would accept an empty stack (and on
774        // update remove every existing resource).
775        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}