fakecloud-core 0.45.0

Core service traits and dispatch for FakeCloud
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
//! Parsing for CloudFormation-shaped template bodies.
//!
//! A template body arrives as either JSON or YAML, and the YAML dialect
//! CloudFormation accepts is not plain YAML: intrinsic functions may be
//! written as short-form node tags (`!Ref`, `!GetAtt`, `!Sub`, ...). A plain
//! YAML-to-JSON deserialize rejects those outright — the whole document fails,
//! not just the tagged node — so a template using the short forms parsed to
//! nothing at all and stacks came up empty (#2480).
//!
//! Everything that reads a template body (CloudFormation, Serverless
//! Application Repository, Config conformance packs) goes through here, so a
//! short form behaves exactly like its `{"Fn::Sub": ...}` long-form spelling.

use serde_json::{Map, Value as Json};
use serde_yaml::value::TaggedValue;
use serde_yaml::Value as Yaml;

/// Short-form tags that expand to `{"Fn::<Tag>": <arg>}`. Covers the template
/// intrinsics, the condition functions, and the `Rules`-section functions —
/// CloudFormation defines no others, which is what lets an unrecognised tag be
/// treated as an error rather than quietly unwrapped.
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",
];

/// Short-form tags that expand to a bare `{"<Tag>": <arg>}` key. `Ref` and
/// `Condition` are the two CloudFormation spells without the `Fn::` prefix.
const BARE_TAGS: &[&str] = &["Ref", "Condition"];

/// Parse a CloudFormation template body (JSON or YAML, short-form tags
/// included) into a JSON value with every intrinsic in its long form.
///
/// A body starting with `{` is tried as JSON first — YAML is a JSON superset,
/// but going through `serde_json` preserves exact numeric precision for the
/// programmatically-generated templates that dominate that case. It still
/// falls back to YAML on a JSON parse error, because YAML *flow* style also
/// opens with `{` (`{Resources: {A: {Type: X}}}` is valid YAML and invalid
/// JSON — unquoted keys), and committing to JSON on the first character would
/// silently drop such a template.
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) {
                // It is valid YAML after all, so any remaining problem is a
                // YAML-stage one (an unknown intrinsic tag, a non-finite
                // number) and that message is the actionable diagnostic.
                // Reporting the JSON error here would point at syntax that is
                // legal in the dialect actually being used.
                Ok(yaml) => yaml_to_json(yaml).map_err(|e| format!("Invalid YAML template: {e}")),
                // Neither dialect parses. For a `{`-leading body the JSON
                // error is what the author needs.
                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}"))
}

/// Strip a UTF-8 byte-order mark. `str::trim_start` does not remove U+FEFF,
/// and PowerShell's `Out-File` / `Set-Content` write one by default — so a
/// BOM'd template's first line reads `\u{FEFF}Resources:`, which fails every
/// prefix check and sends the body down the lenient path (#2480 again).
fn strip_bom(body: &str) -> &str {
    body.strip_prefix('\u{FEFF}').unwrap_or(body)
}

/// Whether a body is meant to be a CloudFormation template *document*, as
/// opposed to a placeholder scalar (the conformance probe sends `"test"`-style
/// strings for `TemplateBody`).
///
/// A body that parses is judged on its shape: only a string scalar or an
/// absent/empty body is a placeholder.
/// One that does NOT parse still has to be classified — and it is exactly the
/// interesting case, since a syntax error (a stray tab, a bad indent, an
/// unbalanced bracket) is the most common way a real template breaks. Re-using
/// the parser here would answer "not a template" for every syntax error and
/// send it down the lenient degrade-to-empty path, which is the silent no-op
/// #2480 is about. So fall back to a text-level scan for any top-level section
/// name.
pub fn is_template_document(body: &str) -> bool {
    let body = strip_bom(body);
    if let Ok(value) = parse_template_body(body) {
        // Presence, not shape: `Resources:` holding a sequence instead of a
        // mapping is a common YAML slip that the template parser rejects. It
        // is still unmistakably someone's template, so it must fail loudly
        // rather than degrade to an empty stack.
        //
        // The other top-level sections count too. A template that declares
        // `Parameters` or `AWSTemplateFormatVersion` but omits `Resources` is
        // rejected by the parser ("Template must contain a Resources
        // section"), and judging it on `Resources` alone would send that error
        // down the lenient path — accepting an empty stack, or on update
        // removing every existing resource. `Description` is deliberately not
        // in the list: on its own it is too generic to mark a body as a
        // template.
        // Only a string scalar and an absent/empty body stay lenient: the
        // first is the placeholder shape the probe sends
        // (`TemplateBody="test"`), the second is an omitted member. Anything
        // else — a mapping (whatever sections it has, including none), a
        // sequence, a number, a bool — is someone submitting a document, and
        // real CloudFormation rejects the ones that aren't valid templates
        // rather than building an empty stack from them.
        return !matches!(value, Json::String(_) | Json::Null);
    }
    looks_like_template_text(body)
}

/// Top-level sections that mark an *unparseable* body as someone's
/// CloudFormation template, per the template anatomy AWS documents. A body
/// that parses is judged by its shape instead (see `is_template_document`).
const TEMPLATE_SECTIONS: &[&str] = &[
    "AWSTemplateFormatVersion",
    "Conditions",
    "Mappings",
    "Metadata",
    "Outputs",
    "Parameters",
    "Resources",
    "Rules",
    "Transform",
];

/// Text-level shape check for a body that would not parse: does it *look* like
/// someone's template? Keyed on any top-level section CloudFormation defines,
/// not just `Resources` — the syntax error may sit in (or truncate at) the
/// `Parameters` block before `Resources:` is ever reached.
fn looks_like_template_text(body: &str) -> bool {
    if body.trim_start().starts_with('{') {
        // A `{`-leading body that parses as neither JSON nor YAML is a broken
        // document, not a placeholder — the synthetic scalars are bare strings
        // (`test`), never braced. Substring-matching `Resources` here would be
        // wrong in both directions: a JSON template truncated before its
        // `"Resources"` key would be judged a placeholder and silently produce
        // an empty stack, while `"Description": "Resources: see docs"` would be
        // force-failed.
        return true;
    }
    // A top-level YAML key sits at column zero. It may be quoted, and may
    // carry whitespace before its colon — both legal, and both emitted by
    // real generators.
    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);
    // The same section list the parsed branch uses. Keying only on
    // `Resources:` would miss a template whose syntax error sits in (or
    // truncates at) the `Parameters` / `Mappings` block above it, sending it
    // back down the silent-empty-stack path.
    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);
        // Require the colon. A real top-level key always has one, and without
        // it any unparseable body that merely contains one of these words (a
        // pasted log, a CSV header) would be classified as a template —
        // turning a lenient degrade into a hard error.
        rest.trim_start().starts_with(':')
    })
}

/// Convenience wrapper for callers that only want a template-shaped object and
/// treat anything else (a placeholder scalar, a parse failure) as absent.
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)?,
    })
}

/// JSON object keys are strings; a YAML mapping key that isn't one (`1: foo`)
/// takes its JSON rendering, matching how the key would have been written in
/// the equivalent JSON template.
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()));
    }
    // YAML has `.nan` / `.inf`; JSON has no representation for either, and no
    // CloudFormation property legitimately holds one. Surfacing the bad value
    // beats silently rewriting it to `null`, which reads downstream as a
    // deliberately-absent property.
    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 {
        // An unrecognised single-`!` tag. CloudFormation defines a closed set
        // of short forms, so this is a typo (`!GettAtt`) or an unsupported
        // function. Unwrapping it to its argument would turn `!GettAtt
        // Topic.Arn` into the literal string "Topic.Arn" and provision a
        // wrong-but-plausible value under a CREATE_COMPLETE stack — the same
        // silent-wrong-value class this module exists to close.
        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() {
        // This originally asserted the tag unwrapped to its value. That is
        // precisely the silent-wrong-value behaviour the module exists to
        // prevent: the property would provision as the string "hello" under a
        // CREATE_COMPLETE stack, with nothing reported.
        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() {
        // Valid YAML, invalid JSON (unquoted keys) despite the leading `{`.
        // Committing to JSON on the first character would drop it silently.
        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() {
        // Valid YAML, invalid JSON. The real problem is the misspelled
        // intrinsic, so reporting the JSON parse error would point the author
        // at syntax that is legal in the dialect they actually used.
        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() {
        // The #2480 case: a template that does not parse must still be
        // classified as a template, so the caller can fail loudly instead of
        // degrading to an empty stack.
        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));

        // Unbalanced in both dialects, so neither the JSON nor the YAML pass
        // can rescue it.
        let bad_json = "{\"Resources\": [oops";
        assert!(parse_template_body(bad_json).is_err());
        assert!(is_template_document(bad_json));

        // Truncated before the `Resources` key ever appears. A substring test
        // would call this a placeholder and silently build an empty stack.
        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() {
        // A one-character typo used to unwrap to the literal string
        // "Topic.Arn" and provision a wrong-but-plausible value under a
        // CREATE_COMPLETE stack.
        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() {
        // `!!`-prefixed tags never reach `tagged_to_json` at all: libyaml
        // expands the `!!` handle and serde_yaml resolves the result, so the
        // node arrives already plain. Verified for `!!binary`, `!!timestamp`,
        // `!!custom` and `!!str` — all come back as values, never
        // `Value::Tagged`. That is why the unknown-intrinsic check below can
        // reject every tag it sees without special-casing them.
        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() {
        // `!!str` and friends are YAML's own tags, not CloudFormation's, and
        // must not trip the unknown-intrinsic check.
        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() {
        // Legal YAML spellings of the top-level key, each paired with a
        // syntax error elsewhere so classification runs on the raw text.
        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() {
        // `Resources` as a sequence instead of a mapping is a common YAML
        // slip. The template parser rejects it, so it has to be classified as
        // a template or it degrades to a silent empty stack.
        let seq = "Resources:\n  - Type: AWS::SQS::Queue\n";
        assert!(parse_template_body(seq).is_ok(), "parses as YAML");
        assert!(is_template_document(seq));

        // Unparseable flow-style YAML: the key is unquoted, so a check for
        // the JSON spelling alone would miss it.
        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}");
        // Still classified as a template, so the caller fails loudly.
        assert!(is_template_document(
            "Resources:\n  Q:\n    Timeout: .nan\n"
        ));
    }

    #[test]
    fn indented_section_names_are_not_top_level_keys() {
        // Only a column-zero key marks a template. An indented `Parameters:`
        // (a nested property, a pasted fragment) must keep the lenient path,
        // or a placeholder input would take the hard-failure route.
        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() {
        // The syntax error sits in `Parameters`, before `Resources:` is ever
        // reached — a text scan keyed only on `Resources:` would call this a
        // placeholder and silently build an empty stack.
        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));

        // Truncated before `Resources:` appears at all.
        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() {
        // Parses fine, but the template parser rejects it for having no
        // `Resources`. Judging on `Resources` alone would send that error down
        // the lenient path — an empty stack on create, and on update the
        // removal of every existing resource.
        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() {
        // PowerShell's Out-File / Set-Content write a UTF-8 BOM by default,
        // and `trim_start` does not strip U+FEFF.
        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));

        // The case that mattered: BOM + a syntax error must still be
        // classified as a template, or it degrades to an empty stack.
        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));

        // BOM in front of a JSON body too.
        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() {
        // The conformance probe's synthetic TemplateBody values must keep the
        // lenient path — ValidationError is not in CreateStack's Smithy errors.
        for placeholder in ["test", "t", "aaaaaaaa", "", "dGVzdA=="] {
            assert!(
                !is_template_document(placeholder),
                "{placeholder:?} must not be treated as a template"
            );
        }
        // A mapping is always a submitted document, whatever it contains —
        // real CloudFormation rejects one with no Resources rather than
        // building an empty stack from it.
        assert!(is_template_document("Description: just a description\n"));
        assert!(is_template_document("{}"));
        assert!(is_template_document("foo: 1\n"));

        // An empty / absent body parses to null and must stay lenient — the
        // probe omits TemplateBody entirely on some variants.
        for empty in ["", "   ", "\n", "null", "~"] {
            assert!(
                !is_template_document(empty),
                "{empty:?} must keep the lenient path"
            );
        }

        // An unparseable body that merely mentions `Resources` without making
        // it a key (a pasted log, a CSV header) must stay lenient — the hard
        // failure paths would otherwise reject it outright.
        assert!(!is_template_document(
            "Name\tResources\tOwner\n\tbad\ttabs\there\n"
        ));
    }

    #[test]
    fn non_object_bodies_are_not_placeholders() {
        // These parse, but no CloudFormation template is a sequence, a number
        // or a bool. Real CFN rejects them as an unsupported structure;
        // treating them as placeholders would accept an empty stack (and on
        // update remove every existing resource).
        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"}}}"#
        ));
    }
}