Skip to main content

fakecloud_cloudformation/
template.rs

1use base64::Engine;
2use serde_json::{json, Value};
3use std::collections::{BTreeMap, BTreeSet};
4
5/// Internal sentinel emitted whenever a CFN value resolves to
6/// `AWS::NoValue`. After resolution finishes, [`strip_no_value`] walks
7/// the result and removes any object entry / array slot whose value is
8/// this marker, matching CloudFormation's "drop the property" semantics
9/// (e.g. inside an `Fn::If` branch). Picked so it cannot collide with
10/// any real CFN property.
11const NO_VALUE_SENTINEL_KEY: &str = "__fakecloud_aws_no_value__";
12
13/// A parsed CloudFormation template.
14#[derive(Debug, Clone)]
15pub struct ParsedTemplate {
16    pub description: Option<String>,
17    pub resources: Vec<ResourceDefinition>,
18    pub outputs: Vec<TemplateOutput>,
19}
20
21/// Resolved Outputs entry from the template's top-level `Outputs` block.
22/// `value` is the post-resolution string; `export_name` is set when the
23/// output declares `Export.Name`.
24#[derive(Debug, Clone)]
25pub struct TemplateOutput {
26    pub logical_id: String,
27    pub value: String,
28    pub description: Option<String>,
29    pub export_name: Option<String>,
30}
31
32/// A single resource from the template.
33#[derive(Debug, Clone)]
34pub struct ResourceDefinition {
35    pub logical_id: String,
36    pub resource_type: String,
37    pub properties: Value,
38}
39
40/// Known pseudo-references that should be passed through as-is.
41const PSEUDO_REFS: &[&str] = &[
42    "AWS::AccountId",
43    "AWS::NotificationARNs",
44    "AWS::NoValue",
45    "AWS::Partition",
46    "AWS::Region",
47    "AWS::StackId",
48    "AWS::StackName",
49    "AWS::URLSuffix",
50];
51
52/// Parse a CloudFormation template from a string (JSON or YAML).
53pub fn parse_template(
54    template_body: &str,
55    parameters: &BTreeMap<String, String>,
56) -> Result<ParsedTemplate, String> {
57    parse_template_with_physical_ids(template_body, parameters, &BTreeMap::new())
58}
59
60/// Parse a CloudFormation template, resolving Refs using known physical resource IDs.
61pub fn parse_template_with_physical_ids(
62    template_body: &str,
63    parameters: &BTreeMap<String, String>,
64    resource_physical_ids: &BTreeMap<String, String>,
65) -> Result<ParsedTemplate, String> {
66    parse_template_with_resolution(
67        template_body,
68        parameters,
69        resource_physical_ids,
70        &BTreeMap::new(),
71    )
72}
73
74/// Parse a CloudFormation template, resolving `Ref` via `resource_physical_ids`
75/// and `Fn::GetAtt` via `resource_attributes` (keyed by logical id, then
76/// attribute name).
77pub fn parse_template_with_resolution(
78    template_body: &str,
79    parameters: &BTreeMap<String, String>,
80    resource_physical_ids: &BTreeMap<String, String>,
81    resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
82) -> Result<ParsedTemplate, String> {
83    let value: Value = if template_body.trim_start().starts_with('{') {
84        serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
85    } else {
86        serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
87    };
88
89    // Expand `Fn::ForEach::*` macros (template transform). New resources
90    // and properties land in place before the rest of resolution sees the
91    // template, so a ForEach-emitted resource works exactly like a
92    // hand-authored one. Parameters flow in so the items list can be a
93    // `Ref` to a CommaDelimitedList parameter.
94    let value = expand_for_each(&value, &BTreeMap::new(), parameters)?;
95    let value = expand_sam(&value);
96
97    let description = value
98        .get("Description")
99        .and_then(|v| v.as_str())
100        .map(|s| s.to_string());
101
102    let conditions = evaluate_conditions(&value, parameters)?;
103    let mappings = parse_mappings(&value);
104
105    let resources_obj = value
106        .get("Resources")
107        .and_then(|v| v.as_object())
108        .ok_or("Template must contain a Resources section")?;
109
110    let mut resources = Vec::new();
111    for (logical_id, resource) in resources_obj {
112        // Skip resources whose Condition evaluates to false. Real CFN
113        // simply omits these resources from the stack.
114        if let Some(cond_name) = resource.get("Condition").and_then(|v| v.as_str()) {
115            if !conditions.get(cond_name).copied().unwrap_or(false) {
116                continue;
117            }
118        }
119        let resource_type = resource
120            .get("Type")
121            .and_then(|v| v.as_str())
122            .ok_or(format!("Resource {logical_id} must have a Type property"))?
123            .to_string();
124
125        let properties = resource
126            .get("Properties")
127            .cloned()
128            .unwrap_or(Value::Object(serde_json::Map::new()));
129
130        // Pre-resolve Fn::FindInMap before the main intrinsics pass so the
131        // existing resolver doesn't need to thread mappings through. We
132        // pass `conditions` so a FindInMap sitting in an unused Fn::If
133        // branch is skipped (CFN never executes the dropped branch).
134        let properties = apply_mappings(&properties, parameters, &mappings, &conditions)?;
135
136        // Resolve Ref and parameter substitutions in properties
137        let resolved = resolve_refs_full(
138            &properties,
139            parameters,
140            resources_obj,
141            resource_physical_ids,
142            resource_attributes,
143            &BTreeMap::new(),
144            &conditions,
145        );
146        let resolved = strip_no_value(resolved);
147
148        resources.push(ResourceDefinition {
149            logical_id: logical_id.clone(),
150            resource_type,
151            properties: resolved,
152        });
153    }
154
155    let outputs = parse_outputs(
156        &value,
157        parameters,
158        resources_obj,
159        resource_physical_ids,
160        resource_attributes,
161        &BTreeMap::new(),
162    )?;
163
164    Ok(ParsedTemplate {
165        description,
166        resources,
167        outputs,
168    })
169}
170
171/// Walk every `Fn::ImportValue` site in the parsed template (Resources +
172/// Outputs) and collect the static export names it references. Names that
173/// can only be resolved at runtime (e.g. `{ "Fn::Sub": "${Env}-arn" }`)
174/// resolve against `parameters` first; if they still aren't strings,
175/// they're skipped — the runtime resolver will surface the gap then.
176pub fn collect_import_value_names(
177    template: &Value,
178    parameters: &BTreeMap<String, String>,
179) -> Vec<String> {
180    let mut out: Vec<String> = Vec::new();
181    collect_imports_walk(template, parameters, &mut out);
182    out.sort();
183    out.dedup();
184    out
185}
186
187fn collect_imports_walk(
188    value: &Value,
189    parameters: &BTreeMap<String, String>,
190    out: &mut Vec<String>,
191) {
192    match value {
193        Value::Object(map) => {
194            if let Some(arg) = map.get("Fn::ImportValue") {
195                if let Some(name) = static_import_name(arg, parameters) {
196                    out.push(name);
197                } else {
198                    // Recurse into the arg in case it contains nested ImportValues.
199                    collect_imports_walk(arg, parameters, out);
200                }
201            }
202            for (k, v) in map {
203                if k == "Fn::ImportValue" {
204                    continue;
205                }
206                collect_imports_walk(v, parameters, out);
207            }
208        }
209        Value::Array(arr) => {
210            for v in arr {
211                collect_imports_walk(v, parameters, out);
212            }
213        }
214        _ => {}
215    }
216}
217
218fn static_import_name(value: &Value, parameters: &BTreeMap<String, String>) -> Option<String> {
219    match value {
220        Value::String(s) => Some(s.clone()),
221        Value::Object(m) => {
222            if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
223                return parameters.get(name).cloned();
224            }
225            if let Some(s) = m.get("Fn::Sub").and_then(|v| v.as_str()) {
226                let mut result = s.to_string();
227                for (k, v) in parameters {
228                    result = result.replace(&format!("${{{k}}}"), v);
229                }
230                if !result.contains("${") {
231                    return Some(result);
232                }
233            }
234            None
235        }
236        _ => None,
237    }
238}
239
240/// Parse the template's `Outputs` block into resolved entries. Each
241/// `Value` is fully resolved (Ref / GetAtt / Sub / Join / Fn::ImportValue)
242/// to a string. Imports use `imports` for cross-stack lookups.
243pub fn parse_outputs(
244    template: &Value,
245    parameters: &BTreeMap<String, String>,
246    resources: &serde_json::Map<String, Value>,
247    resource_physical_ids: &BTreeMap<String, String>,
248    resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
249    imports: &BTreeMap<String, String>,
250) -> Result<Vec<TemplateOutput>, String> {
251    // Expand Fn::ForEach in Outputs so resolve picks up macro-emitted
252    // entries. Callers pass the raw template value, which may still
253    // contain unexpanded ForEach macros.
254    let template_owned = expand_for_each(template, &BTreeMap::new(), parameters)?;
255    let template = &template_owned;
256    let outputs_obj = match template.get("Outputs").and_then(|v| v.as_object()) {
257        Some(o) => o,
258        None => return Ok(Vec::new()),
259    };
260
261    let conditions = evaluate_conditions(template, parameters)?;
262    let mut out = Vec::new();
263    for (logical_id, body) in outputs_obj {
264        // Skip outputs gated on a Condition that resolves false. CFN
265        // simply omits these from the resolved Outputs set.
266        if let Some(cond_name) = body.get("Condition").and_then(|v| v.as_str()) {
267            if !conditions.get(cond_name).copied().unwrap_or(false) {
268                continue;
269            }
270        }
271        let raw_value = match body.get("Value") {
272            Some(v) => v,
273            None => continue,
274        };
275        let resolved = resolve_refs_full(
276            raw_value,
277            parameters,
278            resources,
279            resource_physical_ids,
280            resource_attributes,
281            imports,
282            &conditions,
283        );
284        let resolved = strip_no_value(resolved);
285        let value = match resolved {
286            Value::String(s) => s,
287            other => other.to_string(),
288        };
289        let description = body
290            .get("Description")
291            .and_then(|v| v.as_str())
292            .map(|s| s.to_string());
293        let export_name = body.get("Export").and_then(|e| e.get("Name")).map(|n| {
294            let resolved = resolve_refs_full(
295                n,
296                parameters,
297                resources,
298                resource_physical_ids,
299                resource_attributes,
300                imports,
301                &conditions,
302            );
303            match resolved {
304                Value::String(s) => s,
305                other => other.to_string(),
306            }
307        });
308        out.push(TemplateOutput {
309            logical_id: logical_id.clone(),
310            value,
311            description,
312            export_name,
313        });
314    }
315    Ok(out)
316}
317
318/// Walk the top-level `Conditions` block and evaluate each entry to a
319/// boolean. Conditions can reference each other; we evaluate
320/// recursively with memoization plus an `in_progress` set to surface a
321/// clear error on cycles (`A` -> `B` -> `A`).
322fn evaluate_conditions(
323    template: &Value,
324    parameters: &BTreeMap<String, String>,
325) -> Result<BTreeMap<String, bool>, String> {
326    let mut memo: BTreeMap<String, bool> = BTreeMap::new();
327    let Some(conds) = template.get("Conditions").and_then(|v| v.as_object()) else {
328        return Ok(memo);
329    };
330    let mut in_progress: BTreeSet<String> = BTreeSet::new();
331    let names: Vec<String> = conds.keys().cloned().collect();
332    for name in names {
333        evaluate_condition_named(&name, conds, parameters, &mut memo, &mut in_progress)?;
334    }
335    Ok(memo)
336}
337
338/// Resolve a single named condition, recursively walking its expression
339/// tree. Memoizes into `memo`, tracks in-flight names in `in_progress`
340/// to detect cycles. `Condition: <name>` references trigger recursion.
341fn evaluate_condition_named(
342    name: &str,
343    conds: &serde_json::Map<String, Value>,
344    parameters: &BTreeMap<String, String>,
345    memo: &mut BTreeMap<String, bool>,
346    in_progress: &mut BTreeSet<String>,
347) -> Result<bool, String> {
348    if let Some(b) = memo.get(name) {
349        return Ok(*b);
350    }
351    if !in_progress.insert(name.to_string()) {
352        return Err(format!(
353            "Circular reference in Conditions: '{name}' transitively references itself"
354        ));
355    }
356    let expr = conds.get(name).ok_or_else(|| {
357        format!("Condition '{name}' is referenced but not defined in Conditions block")
358    })?;
359    let result = eval_condition_expr(expr, conds, parameters, memo, in_progress)?;
360    in_progress.remove(name);
361    memo.insert(name.to_string(), result);
362    Ok(result)
363}
364
365type Mappings = BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>;
366
367/// Parse the top-level `Mappings` block into a 2-level lookup table.
368/// `Fn::FindInMap: [MapName, TopKey, SecondKey]` returns the leaf
369/// value at that path.
370fn parse_mappings(template: &Value) -> Mappings {
371    let mut out: Mappings = BTreeMap::new();
372    let Some(maps) = template.get("Mappings").and_then(|v| v.as_object()) else {
373        return out;
374    };
375    for (map_name, top) in maps {
376        let Some(top_obj) = top.as_object() else {
377            continue;
378        };
379        let mut top_out = BTreeMap::new();
380        for (top_key, second) in top_obj {
381            let Some(second_obj) = second.as_object() else {
382                continue;
383            };
384            let mut second_out: BTreeMap<String, Value> = BTreeMap::new();
385            for (k, v) in second_obj {
386                second_out.insert(k.clone(), v.clone());
387            }
388            top_out.insert(top_key.clone(), second_out);
389        }
390        out.insert(map_name.clone(), top_out);
391    }
392    out
393}
394
395/// Evaluate a single condition expression node. Operators short-circuit
396/// where it matters (`Fn::And` stops on first false, `Fn::Or` stops on
397/// first true). Named-condition references recurse via
398/// `evaluate_condition_named` so cycles are caught at the named layer.
399fn eval_condition_expr(
400    expr: &Value,
401    conds: &serde_json::Map<String, Value>,
402    parameters: &BTreeMap<String, String>,
403    memo: &mut BTreeMap<String, bool>,
404    in_progress: &mut BTreeSet<String>,
405) -> Result<bool, String> {
406    if let Some(b) = expr.as_bool() {
407        return Ok(b);
408    }
409    let map = expr
410        .as_object()
411        .ok_or_else(|| format!("Invalid condition expression: {expr}"))?;
412    if let Some(args) = map.get("Fn::Equals").and_then(|v| v.as_array()) {
413        if args.len() != 2 {
414            return Err("Fn::Equals requires exactly 2 arguments".to_string());
415        }
416        let a = stringify_value(&args[0], parameters);
417        let b = stringify_value(&args[1], parameters);
418        return Ok(a == b);
419    }
420    if let Some(args) = map.get("Fn::And").and_then(|v| v.as_array()) {
421        if !(1..=10).contains(&args.len()) {
422            return Err("Fn::And requires between 1 and 10 conditions".to_string());
423        }
424        for a in args {
425            if !eval_condition_expr(a, conds, parameters, memo, in_progress)? {
426                return Ok(false);
427            }
428        }
429        return Ok(true);
430    }
431    if let Some(args) = map.get("Fn::Or").and_then(|v| v.as_array()) {
432        if !(1..=10).contains(&args.len()) {
433            return Err("Fn::Or requires between 1 and 10 conditions".to_string());
434        }
435        for a in args {
436            if eval_condition_expr(a, conds, parameters, memo, in_progress)? {
437                return Ok(true);
438            }
439        }
440        return Ok(false);
441    }
442    if let Some(arr) = map.get("Fn::Not").and_then(|v| v.as_array()) {
443        if arr.len() != 1 {
444            return Err("Fn::Not requires exactly 1 argument".to_string());
445        }
446        return Ok(!eval_condition_expr(
447            &arr[0],
448            conds,
449            parameters,
450            memo,
451            in_progress,
452        )?);
453    }
454    if let Some(name) = map.get("Condition").and_then(|v| v.as_str()) {
455        return evaluate_condition_named(name, conds, parameters, memo, in_progress);
456    }
457    Err(format!("Unknown condition operator in expression: {expr}"))
458}
459
460/// Render a CFN intrinsic value (Ref to a parameter, plain string, etc.)
461/// as a string for Fn::Equals comparison.
462fn stringify_value(value: &Value, parameters: &BTreeMap<String, String>) -> String {
463    match value {
464        Value::String(s) => s.clone(),
465        Value::Bool(b) => b.to_string(),
466        Value::Number(n) => n.to_string(),
467        Value::Object(m) => {
468            if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
469                if let Some(p) = parameters.get(name) {
470                    return p.clone();
471                }
472                return name.to_string();
473            }
474            value.to_string()
475        }
476        _ => value.to_string(),
477    }
478}
479
480/// Expand `Fn::ForEach::<UniqueLoopName>` macros in `value` recursively.
481///
482/// Syntax (from the AWS docs / sample):
483/// ```text
484/// "Fn::ForEach::TopicLoop": [
485///   "LoopVar",
486///   ["a", "b", "c"],
487///   { "${LoopVar}Topic": { "Type": "AWS::SNS::Topic", ... } }
488/// ]
489/// ```
490/// becomes three siblings (`aTopic`, `bTopic`, `cTopic`) in the parent
491/// object. `${LoopVar}` substitutes inside both keys and values, so the
492/// emitted body can reference the iteration value the same way `Fn::Sub`
493/// does.
494///
495/// Macros nest: an outer ForEach's bindings flow into inner ForEach
496/// bodies via `bindings`, so `${OuterVar}` resolves inside an inner
497/// loop's body. Each call resolves its own loop variable's iterations
498/// before recursing into the emitted entries.
499fn expand_for_each(
500    value: &Value,
501    bindings: &BTreeMap<String, String>,
502    parameters: &BTreeMap<String, String>,
503) -> Result<Value, String> {
504    match value {
505        Value::Object(map) => {
506            let mut out = serde_json::Map::with_capacity(map.len());
507            for (k, v) in map {
508                if let Some(loop_name) = k.strip_prefix("Fn::ForEach::") {
509                    let arr = v.as_array().ok_or_else(|| {
510                        format!("Fn::ForEach::{loop_name} requires an array argument")
511                    })?;
512                    if arr.len() != 3 {
513                        return Err(format!(
514                            "Fn::ForEach::{loop_name} requires 3 arguments (loopVar, list, template), got {}",
515                            arr.len()
516                        ));
517                    }
518                    let loop_var = arr[0].as_str().ok_or_else(|| {
519                        format!("Fn::ForEach::{loop_name} loop variable must be a string")
520                    })?;
521                    // The items list may be a literal array OR a `Ref`
522                    // to a CommaDelimitedList parameter (AWS-supported).
523                    // Resolve the latter against `parameters` by
524                    // splitting on `,` so the loop iterates the same
525                    // values the template author wrote.
526                    let items_owned: Vec<Value> =
527                        resolve_for_each_items(&arr[1], parameters).ok_or_else(|| {
528                            format!(
529                                "Fn::ForEach::{loop_name} second argument must be an array or a Ref to a CommaDelimitedList parameter"
530                            )
531                        })?;
532                    let body = arr[2].as_object().ok_or_else(|| {
533                        format!("Fn::ForEach::{loop_name} third argument must be an object")
534                    })?;
535                    for item in &items_owned {
536                        let item_str = match item {
537                            Value::String(s) => s.clone(),
538                            other => other.to_string(),
539                        };
540                        let mut next = bindings.clone();
541                        next.insert(loop_var.to_string(), item_str.clone());
542                        // Substitute loop vars across the whole body
543                        // first, then recurse via `expand_for_each` so
544                        // any nested `Fn::ForEach::*` keys land inline
545                        // as sibling entries of `out` (instead of
546                        // wrapping them under the unresolved macro key).
547                        let body_value = Value::Object(body.clone());
548                        let substituted = substitute_loop_vars_in_value(&body_value, &next);
549                        let expanded = expand_for_each(&substituted, &next, parameters)?;
550                        if let Value::Object(emitted) = expanded {
551                            for (ek, ev) in emitted {
552                                out.insert(ek, ev);
553                            }
554                        }
555                    }
556                    continue;
557                }
558                out.insert(k.clone(), expand_for_each(v, bindings, parameters)?);
559            }
560            Ok(Value::Object(out))
561        }
562        Value::Array(arr) => {
563            let mut out = Vec::with_capacity(arr.len());
564            for v in arr {
565                out.push(expand_for_each(v, bindings, parameters)?);
566            }
567            Ok(Value::Array(out))
568        }
569        other => Ok(other.clone()),
570    }
571}
572
573/// Expand AWS::Serverless-2016-10-31 SAM resources into native
574/// CloudFormation resources so the provisioner can handle them.
575fn expand_sam(value: &Value) -> Value {
576    let transform = value.get("Transform");
577    let has_sam = match transform {
578        Some(Value::String(s)) => s == "AWS::Serverless-2016-10-31",
579        Some(Value::Array(arr)) => arr
580            .iter()
581            .any(|v| v.as_str() == Some("AWS::Serverless-2016-10-31")),
582        _ => false,
583    };
584    if !has_sam {
585        return value.clone();
586    }
587
588    let mut value = value.clone();
589    let Some(resources) = value.get_mut("Resources") else {
590        return value;
591    };
592    let Some(resources_map) = resources.as_object_mut() else {
593        return value;
594    };
595
596    let mut new_resources = serde_json::Map::new();
597    for (logical_id, resource) in resources_map.iter() {
598        let Some(resource_obj) = resource.as_object() else {
599            new_resources.insert(logical_id.clone(), resource.clone());
600            continue;
601        };
602        let Some(ty) = resource_obj.get("Type").and_then(|v| v.as_str()) else {
603            new_resources.insert(logical_id.clone(), resource.clone());
604            continue;
605        };
606        let properties = resource_obj
607            .get("Properties")
608            .cloned()
609            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
610
611        match ty {
612            "AWS::Serverless::Function" => {
613                let mut lambda_props = if let Some(p) = properties.as_object() {
614                    p.clone()
615                } else {
616                    serde_json::Map::new()
617                };
618                // Map CodeUri / InlineCode to Code
619                if let Some(code_uri) = lambda_props.get("CodeUri").cloned() {
620                    lambda_props.remove("CodeUri");
621                    let code = if let Some(s) = code_uri.as_str() {
622                        if let Some(stripped) = s.strip_prefix("s3://") {
623                            let parts: Vec<&str> = stripped.splitn(2, '/').collect();
624                            if parts.len() == 2 {
625                                json!({"S3Bucket": parts[0], "S3Key": parts[1]})
626                            } else {
627                                json!({"S3Bucket": "sam", "S3Key": s})
628                            }
629                        } else {
630                            json!({"S3Bucket": "sam", "S3Key": s})
631                        }
632                    } else {
633                        code_uri
634                    };
635                    lambda_props.insert("Code".to_string(), code);
636                } else if let Some(inline) = lambda_props.get("InlineCode").cloned() {
637                    lambda_props.remove("InlineCode");
638                    lambda_props.insert("Code".to_string(), json!({"ZipFile": inline}));
639                }
640                let mut lambda_resource = serde_json::Map::new();
641                lambda_resource.insert("Type".to_string(), json!("AWS::Lambda::Function"));
642                lambda_resource.insert("Properties".to_string(), Value::Object(lambda_props));
643                for (k, v) in resource_obj {
644                    if k != "Type" && k != "Properties" {
645                        lambda_resource.insert(k.clone(), v.clone());
646                    }
647                }
648                new_resources.insert(logical_id.clone(), Value::Object(lambda_resource));
649            }
650            "AWS::Serverless::Api" => {
651                let mut api_props = if let Some(p) = properties.as_object() {
652                    p.clone()
653                } else {
654                    serde_json::Map::new()
655                };
656                if let Some(def) = api_props.get("DefinitionBody").cloned() {
657                    api_props.remove("DefinitionBody");
658                    api_props.insert("Body".to_string(), def);
659                }
660                let mut api_resource = serde_json::Map::new();
661                api_resource.insert("Type".to_string(), json!("AWS::ApiGateway::RestApi"));
662                api_resource.insert("Properties".to_string(), Value::Object(api_props));
663                for (k, v) in resource_obj {
664                    if k != "Type" && k != "Properties" {
665                        api_resource.insert(k.clone(), v.clone());
666                    }
667                }
668                new_resources.insert(logical_id.clone(), Value::Object(api_resource));
669            }
670            "AWS::Serverless::HttpApi" => {
671                let mut httpapi_resource = serde_json::Map::new();
672                httpapi_resource.insert("Type".to_string(), json!("AWS::ApiGatewayV2::Api"));
673                httpapi_resource.insert("Properties".to_string(), properties);
674                for (k, v) in resource_obj {
675                    if k != "Type" && k != "Properties" {
676                        httpapi_resource.insert(k.clone(), v.clone());
677                    }
678                }
679                new_resources.insert(logical_id.clone(), Value::Object(httpapi_resource));
680            }
681            "AWS::Serverless::SimpleTable" => {
682                let mut table_props = if let Some(p) = properties.as_object() {
683                    p.clone()
684                } else {
685                    serde_json::Map::new()
686                };
687                if let Some(pk) = table_props.get("PrimaryKey") {
688                    if let Some(pk_obj) = pk.as_object() {
689                        let name = pk_obj.get("Name").cloned().unwrap_or_else(|| json!("id"));
690                        let ty = match pk_obj.get("Type").and_then(|v| v.as_str()) {
691                            Some("String") => json!("S"),
692                            Some("Number") => json!("N"),
693                            Some("Binary") => json!("B"),
694                            Some(other) => json!(other),
695                            None => json!("S"),
696                        };
697                        table_props.remove("PrimaryKey");
698                        table_props.insert(
699                            "KeySchema".to_string(),
700                            json!([{"AttributeName": name.clone(), "KeyType": "HASH"}]),
701                        );
702                        table_props.insert(
703                            "AttributeDefinitions".to_string(),
704                            json!([{"AttributeName": name, "AttributeType": ty}]),
705                        );
706                    }
707                }
708                if !table_props.contains_key("BillingMode") {
709                    table_props.insert("BillingMode".to_string(), json!("PAY_PER_REQUEST"));
710                }
711                let mut table_resource = serde_json::Map::new();
712                table_resource.insert("Type".to_string(), json!("AWS::DynamoDB::Table"));
713                table_resource.insert("Properties".to_string(), Value::Object(table_props));
714                for (k, v) in resource_obj {
715                    if k != "Type" && k != "Properties" {
716                        table_resource.insert(k.clone(), v.clone());
717                    }
718                }
719                new_resources.insert(logical_id.clone(), Value::Object(table_resource));
720            }
721            "AWS::Serverless::LayerVersion" => {
722                let mut layer_props = if let Some(p) = properties.as_object() {
723                    p.clone()
724                } else {
725                    serde_json::Map::new()
726                };
727                if let Some(uri) = layer_props.get("ContentUri").cloned() {
728                    layer_props.remove("ContentUri");
729                    let content = if let Some(s) = uri.as_str() {
730                        if let Some(stripped) = s.strip_prefix("s3://") {
731                            let parts: Vec<&str> = stripped.splitn(2, '/').collect();
732                            if parts.len() == 2 {
733                                json!({"S3Bucket": parts[0], "S3Key": parts[1]})
734                            } else {
735                                json!({"S3Bucket": "sam", "S3Key": s})
736                            }
737                        } else {
738                            json!({"S3Bucket": "sam", "S3Key": s})
739                        }
740                    } else {
741                        uri
742                    };
743                    layer_props.insert("Content".to_string(), content);
744                }
745                let mut layer_resource = serde_json::Map::new();
746                layer_resource.insert("Type".to_string(), json!("AWS::Lambda::LayerVersion"));
747                layer_resource.insert("Properties".to_string(), Value::Object(layer_props));
748                for (k, v) in resource_obj {
749                    if k != "Type" && k != "Properties" {
750                        layer_resource.insert(k.clone(), v.clone());
751                    }
752                }
753                new_resources.insert(logical_id.clone(), Value::Object(layer_resource));
754            }
755            _ => {
756                new_resources.insert(logical_id.clone(), resource.clone());
757            }
758        }
759    }
760
761    resources_map.clear();
762    for (k, v) in new_resources {
763        resources_map.insert(k, v);
764    }
765    value
766}
767
768/// Resolve the `items` argument of an `Fn::ForEach` macro. Accepts:
769/// - A literal JSON array — returned as-is.
770/// - `{ "Ref": "<name>" }` against a parameter holding either a comma
771///   delimited list (`CommaDelimitedList` / `List<*>`) or a single
772///   value. Splits on `,` and trims whitespace so parameters set as
773///   `"a, b, c"` iterate cleanly.
774///
775/// Returns `None` for any other shape (e.g. an object that isn't a
776/// `Ref`, or a `Ref` to an undefined parameter), letting the caller
777/// surface a precise error.
778fn resolve_for_each_items(
779    value: &Value,
780    parameters: &BTreeMap<String, String>,
781) -> Option<Vec<Value>> {
782    if let Some(arr) = value.as_array() {
783        return Some(arr.clone());
784    }
785    if let Some(map) = value.as_object() {
786        if let Some(name) = map.get("Ref").and_then(|v| v.as_str()) {
787            let raw = parameters.get(name)?;
788            return Some(
789                raw.split(',')
790                    .map(|p| Value::String(p.trim().to_string()))
791                    .collect(),
792            );
793        }
794    }
795    None
796}
797
798/// Substitute every `${var}` and `&{var}` token in a string against
799/// `bindings`. Both forms are AWS-supported for `Fn::ForEach` loop
800/// variables — `&{}` exists so identifiers with non-alphanumeric
801/// characters can interpolate into resource logical IDs without
802/// colliding with Fn::Sub's `${}` syntax. Unknown vars stay verbatim
803/// so non-loop substitutions (Fn::Sub, resource physical IDs) handle
804/// them later.
805fn substitute_loop_vars(s: &str, bindings: &BTreeMap<String, String>) -> String {
806    let mut result = s.to_string();
807    for (k, v) in bindings {
808        result = result.replace(&format!("${{{k}}}"), v);
809        result = result.replace(&format!("&{{{k}}}"), v);
810    }
811    result
812}
813
814/// Walk `value` and apply `substitute_loop_vars` to every string leaf.
815/// Object keys are also rewritten so resource logical IDs and property
816/// names parameterized by the loop variable land correctly.
817fn substitute_loop_vars_in_value(value: &Value, bindings: &BTreeMap<String, String>) -> Value {
818    match value {
819        Value::String(s) => Value::String(substitute_loop_vars(s, bindings)),
820        Value::Object(map) => {
821            let mut out = serde_json::Map::with_capacity(map.len());
822            for (k, v) in map {
823                let new_key = substitute_loop_vars(k, bindings);
824                out.insert(new_key, substitute_loop_vars_in_value(v, bindings));
825            }
826            Value::Object(out)
827        }
828        Value::Array(arr) => Value::Array(
829            arr.iter()
830                .map(|v| substitute_loop_vars_in_value(v, bindings))
831                .collect(),
832        ),
833        other => other.clone(),
834    }
835}
836
837/// Walk `value`, replacing every `Fn::FindInMap` map ref with its
838/// resolved leaf value. Args resolve `Ref` / nested `Fn::FindInMap`
839/// against `parameters` + `mappings` first. Unresolvable lookups return
840/// the optional `DefaultValue` from the 4-arg form, otherwise surface a
841/// `ValidationError`-shaped string matching CloudFormation's error.
842///
843/// `Fn::If` short-circuits: only the branch picked by `conditions`
844/// recurses, so a `Fn::FindInMap` sitting in an unused branch never
845/// trips the strict miss-handling. Conditions that aren't yet known
846/// (caller passed an empty map) recurse into both branches as before
847/// to preserve behaviour.
848fn apply_mappings(
849    value: &Value,
850    parameters: &BTreeMap<String, String>,
851    mappings: &Mappings,
852    conditions: &BTreeMap<String, bool>,
853) -> Result<Value, String> {
854    match value {
855        Value::Object(map) => {
856            if let Some(arr) = map.get("Fn::If").and_then(|v| v.as_array()) {
857                if arr.len() == 3 {
858                    let cond_name = arr[0].as_str().unwrap_or("");
859                    if let Some(picked_idx) =
860                        conditions
861                            .get(cond_name)
862                            .copied()
863                            .map(|b| if b { 1 } else { 2 })
864                    {
865                        // Resolve the picked branch eagerly; leave the
866                        // unused branch verbatim so the downstream
867                        // resolver (`resolve_refs_full`) still sees the
868                        // same Fn::If shape and re-applies its own
869                        // branch picking. Crucially, we never recurse
870                        // into the unused branch, so a FindInMap that
871                        // would fail there never executes.
872                        let mut new_arr = arr.clone();
873                        new_arr[picked_idx] =
874                            apply_mappings(&arr[picked_idx], parameters, mappings, conditions)?;
875                        let mut rewritten = serde_json::Map::new();
876                        rewritten.insert("Fn::If".to_string(), Value::Array(new_arr));
877                        return Ok(Value::Object(rewritten));
878                    }
879                }
880            }
881            if let Some(arr) = map.get("Fn::FindInMap").and_then(|v| v.as_array()) {
882                return resolve_find_in_map(arr, parameters, mappings, conditions);
883            }
884            let mut new_map = serde_json::Map::new();
885            for (k, v) in map {
886                new_map.insert(
887                    k.clone(),
888                    apply_mappings(v, parameters, mappings, conditions)?,
889                );
890            }
891            Ok(Value::Object(new_map))
892        }
893        Value::Array(arr) => {
894            let mut out = Vec::with_capacity(arr.len());
895            for v in arr {
896                out.push(apply_mappings(v, parameters, mappings, conditions)?);
897            }
898            Ok(Value::Array(out))
899        }
900        other => Ok(other.clone()),
901    }
902}
903
904/// Resolve a single `Fn::FindInMap` array. Supports the 3-arg form
905/// `[MapName, TopKey, SecondKey]` and the 4-arg form
906/// `[MapName, TopKey, SecondKey, { DefaultValue: <value> }]`. Args may
907/// themselves be intrinsics (e.g. `{ "Ref": "AWS::Region" }` or a
908/// nested `Fn::FindInMap`); those resolve before lookup.
909fn resolve_find_in_map(
910    arr: &[Value],
911    parameters: &BTreeMap<String, String>,
912    mappings: &Mappings,
913    conditions: &BTreeMap<String, bool>,
914) -> Result<Value, String> {
915    if arr.len() != 3 && arr.len() != 4 {
916        return Err(format!(
917            "Fn::FindInMap requires 3 or 4 arguments, got {}",
918            arr.len()
919        ));
920    }
921    let default_value: Option<Value> = if arr.len() == 4 {
922        let opts = arr[3].as_object().ok_or_else(|| {
923            "Fn::FindInMap fourth argument must be an object with a DefaultValue key".to_string()
924        })?;
925        let dv = opts.get("DefaultValue").ok_or_else(|| {
926            "Fn::FindInMap fourth argument must contain a DefaultValue key".to_string()
927        })?;
928        Some(apply_mappings(dv, parameters, mappings, conditions)?)
929    } else {
930        None
931    };
932
933    let map_name = stringify_findinmap_arg(&arr[0], parameters, mappings, conditions)?;
934    let top_key = stringify_findinmap_arg(&arr[1], parameters, mappings, conditions)?;
935    let second_key = stringify_findinmap_arg(&arr[2], parameters, mappings, conditions)?;
936
937    if let Some(top) = mappings.get(&map_name) {
938        if let Some(second) = top.get(&top_key) {
939            if let Some(leaf) = second.get(&second_key) {
940                return Ok(leaf.clone());
941            }
942        }
943    }
944
945    if let Some(dv) = default_value {
946        return Ok(dv);
947    }
948
949    Err(format!(
950        "Template error: Unable to get mapping for {map_name}::{top_key}::{second_key}"
951    ))
952}
953
954fn stringify_findinmap_arg(
955    value: &Value,
956    parameters: &BTreeMap<String, String>,
957    mappings: &Mappings,
958    conditions: &BTreeMap<String, bool>,
959) -> Result<String, String> {
960    match value {
961        Value::String(s) => Ok(s.clone()),
962        Value::Object(m) => {
963            if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
964                if let Some(p) = parameters.get(name) {
965                    return Ok(p.clone());
966                }
967                // Pseudo refs that have a canonical default value
968                // resolve so FindInMap keyed off `AWS::Region` etc.
969                // works without the caller priming `parameters`.
970                if let Some(Value::String(s)) = pseudo_value(name, parameters) {
971                    return Ok(s);
972                }
973                return Ok(name.to_string());
974            }
975            // Nested Fn::FindInMap as a key — resolve it and stringify
976            // the leaf, so e.g. `Fn::FindInMap: [Outer, !FindInMap [...], K]`
977            // works.
978            if let Some(arr) = m.get("Fn::FindInMap").and_then(|v| v.as_array()) {
979                let resolved = resolve_find_in_map(arr, parameters, mappings, conditions)?;
980                return Ok(match resolved {
981                    Value::String(s) => s,
982                    other => other.to_string(),
983                });
984            }
985            Ok(value.to_string())
986        }
987        _ => Ok(value.to_string()),
988    }
989}
990
991/// Re-resolve a single resource definition's properties with updated physical IDs.
992pub fn resolve_resource_properties(
993    resource: &ResourceDefinition,
994    template_body: &str,
995    parameters: &BTreeMap<String, String>,
996    resource_physical_ids: &BTreeMap<String, String>,
997) -> Result<ResourceDefinition, String> {
998    resolve_resource_properties_with_attrs(
999        resource,
1000        template_body,
1001        parameters,
1002        resource_physical_ids,
1003        &BTreeMap::new(),
1004    )
1005}
1006
1007/// Re-resolve a single resource definition's properties with updated physical
1008/// IDs and attribute values for `Fn::GetAtt`.
1009pub fn resolve_resource_properties_with_attrs(
1010    resource: &ResourceDefinition,
1011    template_body: &str,
1012    parameters: &BTreeMap<String, String>,
1013    resource_physical_ids: &BTreeMap<String, String>,
1014    resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
1015) -> Result<ResourceDefinition, String> {
1016    let value: Value = if template_body.trim_start().starts_with('{') {
1017        serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
1018    } else {
1019        serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
1020    };
1021    // Re-expand ForEach so the resource we look up matches the post-
1022    // expansion logical IDs from the original parse.
1023    let value = expand_for_each(&value, &BTreeMap::new(), parameters)?;
1024
1025    let resources_obj = value
1026        .get("Resources")
1027        .and_then(|v| v.as_object())
1028        .ok_or("Template must contain a Resources section")?;
1029
1030    let raw_props = resources_obj
1031        .get(&resource.logical_id)
1032        .and_then(|r| r.get("Properties"))
1033        .cloned()
1034        .unwrap_or(Value::Object(serde_json::Map::new()));
1035
1036    // Re-evaluate Conditions / Mappings on every resolve so Fn::If picks
1037    // the right branch and AWS::NoValue still strips at incremental
1038    // provisioning time. Without this, the sentinel would leak into the
1039    // provisioned property map.
1040    let conditions = evaluate_conditions(&value, parameters)?;
1041    let mappings = parse_mappings(&value);
1042    let raw_props = apply_mappings(&raw_props, parameters, &mappings, &conditions)?;
1043
1044    let resolved = resolve_refs_full(
1045        &raw_props,
1046        parameters,
1047        resources_obj,
1048        resource_physical_ids,
1049        resource_attributes,
1050        &BTreeMap::new(),
1051        &conditions,
1052    );
1053    let resolved = strip_no_value(resolved);
1054
1055    Ok(ResourceDefinition {
1056        logical_id: resource.logical_id.clone(),
1057        resource_type: resource.resource_type.clone(),
1058        properties: resolved,
1059    })
1060}
1061
1062/// Substitute a pseudo-parameter with the value provided through the
1063/// stack `parameters` map (keyed by the same `AWS::*` name). When the
1064/// caller hasn't supplied a value, fall back to the canonical default
1065/// for that parameter (commercial partition / us-east-1 / empty list).
1066fn pseudo_value(name: &str, parameters: &BTreeMap<String, String>) -> Option<Value> {
1067    // AWS::NotificationARNs is array-typed; the seed encodes it as a
1068    // JSON array string so it round-trips through the string-keyed
1069    // parameters map cleanly. Falls back to the default empty list when
1070    // the seed is missing or malformed.
1071    if name == "AWS::NotificationARNs" {
1072        if let Some(raw) = parameters.get(name) {
1073            if let Ok(parsed) = serde_json::from_str::<Vec<String>>(raw) {
1074                return Some(Value::Array(
1075                    parsed.into_iter().map(Value::String).collect(),
1076                ));
1077            }
1078        }
1079        return Some(Value::Array(Vec::new()));
1080    }
1081    if let Some(v) = parameters.get(name) {
1082        return Some(Value::String(v.clone()));
1083    }
1084    let region = parameters
1085        .get("AWS::Region")
1086        .map(String::as_str)
1087        .unwrap_or("us-east-1");
1088    match name {
1089        // Partition + URLSuffix mirror real CFN: derive from the request
1090        // region so a stack in `cn-north-1` lands `aws-cn` /
1091        // `amazonaws.com.cn`, and `us-gov-west-1` lands `aws-us-gov`.
1092        "AWS::Partition" => Some(Value::String(partition_for_region(region).to_string())),
1093        "AWS::URLSuffix" => Some(Value::String(url_suffix_for_region(region).to_string())),
1094        "AWS::Region" => Some(Value::String(region.to_string())),
1095        // NoValue is a sentinel: emit a private marker object so the
1096        // post-resolution `strip_no_value` walk can drop the parent
1097        // property entirely. CloudFormation removes the key from the
1098        // resolved object rather than leaving a JSON null behind.
1099        "AWS::NoValue" => Some(no_value_sentinel()),
1100        _ => None,
1101    }
1102}
1103
1104/// Map an AWS region to its IAM/ARN partition. China regions land on
1105/// `aws-cn`, GovCloud on `aws-us-gov`, everything else on `aws`. Used
1106/// by both `AWS::Partition` resolution and the URL-suffix derivation
1107/// below so partition decisions stay consistent.
1108pub(crate) fn partition_for_region(region: &str) -> &'static str {
1109    if region.starts_with("cn-") {
1110        "aws-cn"
1111    } else if region.starts_with("us-gov-") {
1112        "aws-us-gov"
1113    } else {
1114        "aws"
1115    }
1116}
1117
1118/// Map an AWS region to its DNS URL suffix. China regions use
1119/// `amazonaws.com.cn`; every other partition (commercial + GovCloud)
1120/// uses `amazonaws.com`, matching the real CFN `AWS::URLSuffix`.
1121pub(crate) fn url_suffix_for_region(region: &str) -> &'static str {
1122    if region.starts_with("cn-") {
1123        "amazonaws.com.cn"
1124    } else {
1125        "amazonaws.com"
1126    }
1127}
1128
1129/// Build a fresh `AWS::NoValue` sentinel object. See
1130/// [`NO_VALUE_SENTINEL_KEY`].
1131fn no_value_sentinel() -> Value {
1132    let mut m = serde_json::Map::new();
1133    m.insert(NO_VALUE_SENTINEL_KEY.to_string(), Value::Bool(true));
1134    Value::Object(m)
1135}
1136
1137/// Return true when `value` is the `AWS::NoValue` sentinel emitted by
1138/// `pseudo_value` (or by an `Fn::If` branch that resolved to it).
1139fn is_no_value(value: &Value) -> bool {
1140    value
1141        .as_object()
1142        .map(|m| m.len() == 1 && m.contains_key(NO_VALUE_SENTINEL_KEY))
1143        .unwrap_or(false)
1144}
1145
1146/// Recursively walk `value` and drop any object entry / array slot
1147/// whose resolved content is the `AWS::NoValue` sentinel. A top-level
1148/// `AWS::NoValue` collapses to `Value::Null` so the caller can detect
1149/// the empty case (CFN's behavior is to omit the property entirely).
1150fn strip_no_value(value: Value) -> Value {
1151    match value {
1152        Value::Object(map) => {
1153            if is_no_value(&Value::Object(map.clone())) {
1154                return Value::Null;
1155            }
1156            let mut out = serde_json::Map::with_capacity(map.len());
1157            for (k, v) in map {
1158                if is_no_value(&v) {
1159                    continue;
1160                }
1161                out.insert(k, strip_no_value(v));
1162            }
1163            Value::Object(out)
1164        }
1165        Value::Array(arr) => Value::Array(
1166            arr.into_iter()
1167                .filter(|v| !is_no_value(v))
1168                .map(strip_no_value)
1169                .collect(),
1170        ),
1171        other => other,
1172    }
1173}
1174
1175/// Resolve `Ref`, `Fn::GetAtt`, `Fn::Join`, and `Fn::Sub` in property
1176/// values. Cross-stack `Fn::ImportValue` is not consulted; use
1177/// `resolve_refs_with_imports` for that. Test-only after the
1178/// resource-properties path moved to `resolve_refs_full`.
1179#[cfg(test)]
1180fn resolve_refs(
1181    value: &Value,
1182    parameters: &BTreeMap<String, String>,
1183    _resources: &serde_json::Map<String, Value>,
1184    resource_physical_ids: &BTreeMap<String, String>,
1185    resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
1186) -> Value {
1187    resolve_refs_full(
1188        value,
1189        parameters,
1190        _resources,
1191        resource_physical_ids,
1192        resource_attributes,
1193        &BTreeMap::new(),
1194        &BTreeMap::new(),
1195    )
1196}
1197
1198/// Resolve `Ref`, `Fn::GetAtt`, `Fn::Join`, `Fn::Sub`, and
1199/// `Fn::ImportValue` in property values.
1200fn resolve_refs_full(
1201    value: &Value,
1202    parameters: &BTreeMap<String, String>,
1203    _resources: &serde_json::Map<String, Value>,
1204    resource_physical_ids: &BTreeMap<String, String>,
1205    resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
1206    imports: &BTreeMap<String, String>,
1207    conditions: &BTreeMap<String, bool>,
1208) -> Value {
1209    // Fn::If always rewrites to either branch BEFORE descent so we don't
1210    // try to resolve the unused branch (it may legitimately reference an
1211    // unconditional resource).
1212    if let Some(map) = value.as_object() {
1213        if let Some(arr) = map.get("Fn::If").and_then(|v| v.as_array()) {
1214            if arr.len() == 3 {
1215                let cond_name = arr[0].as_str().unwrap_or("");
1216                let picked = if conditions.get(cond_name).copied().unwrap_or(false) {
1217                    &arr[1]
1218                } else {
1219                    &arr[2]
1220                };
1221                return resolve_refs_full(
1222                    picked,
1223                    parameters,
1224                    _resources,
1225                    resource_physical_ids,
1226                    resource_attributes,
1227                    imports,
1228                    conditions,
1229                );
1230            }
1231        }
1232    }
1233    match value {
1234        Value::Object(map) => {
1235            if let Some(ref_val) = map.get("Ref") {
1236                if let Some(ref_name) = ref_val.as_str() {
1237                    // 1. Pseudo-references go through `pseudo_value`
1238                    //    first — `AWS::NotificationARNs` is array-typed
1239                    //    and would otherwise fall through to the
1240                    //    string-only parameter path and leak its JSON
1241                    //    encoding into the resolved value.
1242                    if PSEUDO_REFS.contains(&ref_name) {
1243                        if let Some(v) = pseudo_value(ref_name, parameters) {
1244                            return v;
1245                        }
1246                        return Value::String(ref_name.to_string());
1247                    }
1248                    // 2. Explicit template parameters.
1249                    if let Some(param_val) = parameters.get(ref_name) {
1250                        return Value::String(param_val.clone());
1251                    }
1252                    // 3. Already-provisioned resource physical IDs.
1253                    if let Some(physical_id) = resource_physical_ids.get(ref_name) {
1254                        return Value::String(physical_id.clone());
1255                    }
1256                    // 4. Known logical resource in the template but
1257                    //    not yet provisioned: return the logical ID and
1258                    //    let incremental provisioning rewrite it.
1259                    if _resources.contains_key(ref_name) {
1260                        return Value::String(ref_name.to_string());
1261                    }
1262                    // 5. Unknown ref — return as-is (could be a default parameter)
1263                    return Value::String(ref_name.to_string());
1264                }
1265            }
1266            // Fn::ImportValue: look up an exported value from another stack.
1267            // Resolves to the empty string when the export name isn't known
1268            // (callers that need strict failure can pre-validate).
1269            if let Some(import_val) = map.get("Fn::ImportValue") {
1270                let resolved = resolve_refs_full(
1271                    import_val,
1272                    parameters,
1273                    _resources,
1274                    resource_physical_ids,
1275                    resource_attributes,
1276                    imports,
1277                    conditions,
1278                );
1279                let key = match &resolved {
1280                    Value::String(s) => s.clone(),
1281                    other => other.to_string(),
1282                };
1283                if let Some(v) = imports.get(&key) {
1284                    return Value::String(v.clone());
1285                }
1286                return Value::String(String::new());
1287            }
1288            if let Some(getatt_val) = map.get("Fn::GetAtt") {
1289                if let Some((logical_id, attr_name)) = parse_getatt(getatt_val) {
1290                    if let Some(attrs) = resource_attributes.get(&logical_id) {
1291                        if let Some(attr_value) = attrs.get(&attr_name) {
1292                            return Value::String(attr_value.clone());
1293                        }
1294                    }
1295                    // Resource not yet provisioned, or attribute unknown.
1296                    // Surface a placeholder so the consumer can still string-format
1297                    // it; multi-pass provisioning will retry once attributes land.
1298                    return Value::String(format!("{logical_id}.{attr_name}"));
1299                }
1300            }
1301            if let Some(join_val) = map.get("Fn::Join") {
1302                if let Some(arr) = join_val.as_array() {
1303                    if arr.len() == 2 {
1304                        let delimiter = arr[0].as_str().unwrap_or("");
1305                        if let Some(parts) = arr[1].as_array() {
1306                            let resolved_parts: Vec<String> = parts
1307                                .iter()
1308                                .map(|p| {
1309                                    let resolved = resolve_refs_full(
1310                                        p,
1311                                        parameters,
1312                                        _resources,
1313                                        resource_physical_ids,
1314                                        resource_attributes,
1315                                        imports,
1316                                        conditions,
1317                                    );
1318                                    match resolved {
1319                                        Value::String(s) => s,
1320                                        other => other.to_string(),
1321                                    }
1322                                })
1323                                .collect();
1324                            return Value::String(resolved_parts.join(delimiter));
1325                        }
1326                    }
1327                }
1328            }
1329            // Fn::Base64: base64-encode a string (or recursively-resolved
1330            // value).
1331            if let Some(b64_val) = map.get("Fn::Base64") {
1332                let resolved = resolve_refs_full(
1333                    b64_val,
1334                    parameters,
1335                    _resources,
1336                    resource_physical_ids,
1337                    resource_attributes,
1338                    imports,
1339                    conditions,
1340                );
1341                let s = match &resolved {
1342                    Value::String(s) => s.clone(),
1343                    other => other.to_string(),
1344                };
1345                return Value::String(
1346                    base64::engine::general_purpose::STANDARD.encode(s.as_bytes()),
1347                );
1348            }
1349            // Fn::Length: number of elements in an array, or characters
1350            // in a string. Real CFN only documents list inputs but
1351            // accepts strings; we count UTF-8 chars (not bytes) so
1352            // multi-byte characters count once.
1353            if let Some(len_val) = map.get("Fn::Length") {
1354                let resolved = resolve_refs_full(
1355                    len_val,
1356                    parameters,
1357                    _resources,
1358                    resource_physical_ids,
1359                    resource_attributes,
1360                    imports,
1361                    conditions,
1362                );
1363                let n: usize = match &resolved {
1364                    Value::Array(arr) => arr.len(),
1365                    Value::String(s) => s.chars().count(),
1366                    _ => 0,
1367                };
1368                return Value::Number(serde_json::Number::from(n));
1369            }
1370            // Fn::ToJsonString: serialize a value as a JSON string.
1371            if let Some(to_json) = map.get("Fn::ToJsonString") {
1372                let resolved = resolve_refs_full(
1373                    to_json,
1374                    parameters,
1375                    _resources,
1376                    resource_physical_ids,
1377                    resource_attributes,
1378                    imports,
1379                    conditions,
1380                );
1381                let s = serde_json::to_string(&resolved).unwrap_or_default();
1382                return Value::String(s);
1383            }
1384            // Fn::Split: split a string by a delimiter into an array of
1385            // strings. Args: ["delim", "source"] (source can be a Ref/etc).
1386            if let Some(split_val) = map.get("Fn::Split") {
1387                if let Some(arr) = split_val.as_array() {
1388                    if arr.len() == 2 {
1389                        let delim = arr[0].as_str().unwrap_or("");
1390                        let src_resolved = resolve_refs_full(
1391                            &arr[1],
1392                            parameters,
1393                            _resources,
1394                            resource_physical_ids,
1395                            resource_attributes,
1396                            imports,
1397                            conditions,
1398                        );
1399                        let src = match src_resolved {
1400                            Value::String(s) => s,
1401                            other => other.to_string(),
1402                        };
1403                        let parts: Vec<Value> = src
1404                            .split(delim)
1405                            .map(|p| Value::String(p.to_string()))
1406                            .collect();
1407                        return Value::Array(parts);
1408                    }
1409                }
1410            }
1411            // Fn::Select: pick element at index from an array. Args:
1412            // [index, list]. The list may itself be an Fn::Split / Ref.
1413            if let Some(sel_val) = map.get("Fn::Select") {
1414                if let Some(arr) = sel_val.as_array() {
1415                    if arr.len() == 2 {
1416                        let idx_val = resolve_refs_full(
1417                            &arr[0],
1418                            parameters,
1419                            _resources,
1420                            resource_physical_ids,
1421                            resource_attributes,
1422                            imports,
1423                            conditions,
1424                        );
1425                        let list_val = resolve_refs_full(
1426                            &arr[1],
1427                            parameters,
1428                            _resources,
1429                            resource_physical_ids,
1430                            resource_attributes,
1431                            imports,
1432                            conditions,
1433                        );
1434                        let idx: usize = match &idx_val {
1435                            Value::Number(n) => n.as_u64().unwrap_or(0) as usize,
1436                            Value::String(s) => s.parse().unwrap_or(0),
1437                            _ => 0,
1438                        };
1439                        if let Some(list) = list_val.as_array() {
1440                            if let Some(elt) = list.get(idx) {
1441                                return elt.clone();
1442                            }
1443                        }
1444                        return Value::Null;
1445                    }
1446                }
1447            }
1448            // Fn::Cidr: split a CIDR block into N subnets each of a given
1449            // bit count. Args: [ip_block, count, cidr_bits]. We compute
1450            // contiguous sub-blocks within an IPv4 range; IPv6 falls
1451            // through as a string for simplicity.
1452            if let Some(cidr_val) = map.get("Fn::Cidr") {
1453                if let Some(arr) = cidr_val.as_array() {
1454                    if arr.len() == 3 {
1455                        let block_val = resolve_refs_full(
1456                            &arr[0],
1457                            parameters,
1458                            _resources,
1459                            resource_physical_ids,
1460                            resource_attributes,
1461                            imports,
1462                            conditions,
1463                        );
1464                        let count_val = resolve_refs_full(
1465                            &arr[1],
1466                            parameters,
1467                            _resources,
1468                            resource_physical_ids,
1469                            resource_attributes,
1470                            imports,
1471                            conditions,
1472                        );
1473                        let bits_val = resolve_refs_full(
1474                            &arr[2],
1475                            parameters,
1476                            _resources,
1477                            resource_physical_ids,
1478                            resource_attributes,
1479                            imports,
1480                            conditions,
1481                        );
1482                        let block_str = match &block_val {
1483                            Value::String(s) => s.clone(),
1484                            other => other.to_string(),
1485                        };
1486                        let count: u32 = match &count_val {
1487                            Value::Number(n) => n.as_u64().unwrap_or(0) as u32,
1488                            Value::String(s) => s.parse().unwrap_or(0),
1489                            _ => 0,
1490                        };
1491                        let cidr_bits: u32 = match &bits_val {
1492                            Value::Number(n) => n.as_u64().unwrap_or(0) as u32,
1493                            Value::String(s) => s.parse().unwrap_or(0),
1494                            _ => 0,
1495                        };
1496                        if let Some(sub_cidrs) = compute_cidr_subnets(&block_str, count, cidr_bits)
1497                        {
1498                            return Value::Array(
1499                                sub_cidrs.into_iter().map(Value::String).collect(),
1500                            );
1501                        }
1502                    }
1503                }
1504            }
1505            if let Some(sub_val) = map.get("Fn::Sub") {
1506                // Two CFN-supported shapes:
1507                //   "Fn::Sub": "literal-${Var}"
1508                //   "Fn::Sub": ["literal-${Var}", { "Var": <intrinsic> }]
1509                // The array form lets the template author bind extra
1510                // variables that aren't template parameters or resource
1511                // logical IDs. We resolve each binding through
1512                // `resolve_refs_full` so nested `Ref` / `Fn::GetAtt`
1513                // works inside the map.
1514                let (template_str, extra_vars): (Option<&str>, BTreeMap<String, String>) =
1515                    if let Some(s) = sub_val.as_str() {
1516                        (Some(s), BTreeMap::new())
1517                    } else if let Some(arr) = sub_val.as_array() {
1518                        let str_part = arr.first().and_then(|v| v.as_str());
1519                        let mut bindings: BTreeMap<String, String> = BTreeMap::new();
1520                        if let Some(obj) = arr.get(1).and_then(|v| v.as_object()) {
1521                            for (k, v) in obj {
1522                                let resolved = resolve_refs_full(
1523                                    v,
1524                                    parameters,
1525                                    _resources,
1526                                    resource_physical_ids,
1527                                    resource_attributes,
1528                                    imports,
1529                                    conditions,
1530                                );
1531                                let s = match resolved {
1532                                    Value::String(s) => s,
1533                                    other => other.to_string(),
1534                                };
1535                                bindings.insert(k.clone(), s);
1536                            }
1537                        }
1538                        (str_part, bindings)
1539                    } else {
1540                        (None, BTreeMap::new())
1541                    };
1542                if let Some(s) = template_str {
1543                    let mut result = s.to_string();
1544                    // 1. Bindings from the array form take precedence —
1545                    //    AWS docs spell this out: explicit map wins over
1546                    //    template parameters with the same name.
1547                    for (k, v) in &extra_vars {
1548                        result = result.replace(&format!("${{{k}}}"), v);
1549                    }
1550                    // 2. Pseudo-parameters: handle AWS::NoValue by
1551                    //    swapping in the sentinel string so the surrounding
1552                    //    string literal still resolves cleanly. The walker
1553                    //    `strip_no_value` only acts on object/array
1554                    //    children, so a Fn::Sub that hard-references
1555                    //    `${AWS::NoValue}` is best-effort: we drop the
1556                    //    token from the rendered string. Other AWS::*
1557                    //    pseudo-params resolve via `pseudo_value` with
1558                    //    region-aware partition/URLSuffix derivation.
1559                    for pseudo in PSEUDO_REFS {
1560                        let token = format!("${{{pseudo}}}");
1561                        if !result.contains(&token) {
1562                            continue;
1563                        }
1564                        if *pseudo == "AWS::NoValue" {
1565                            // Inside a string, NoValue collapses to empty
1566                            // — there's no JSON-level key to drop.
1567                            result = result.replace(&token, "");
1568                            continue;
1569                        }
1570                        if let Some(v) = pseudo_value(pseudo, parameters) {
1571                            let s = match v {
1572                                Value::String(s) => s,
1573                                other => other.to_string(),
1574                            };
1575                            result = result.replace(&token, &s);
1576                        }
1577                    }
1578                    // 3. Template parameters (including AWS::Region etc.
1579                    //    if the caller seeded them).
1580                    for (k, v) in parameters {
1581                        result = result.replace(&format!("${{{k}}}"), v);
1582                    }
1583                    // 4. Resource physical IDs from already-provisioned
1584                    //    siblings.
1585                    for (k, v) in resource_physical_ids {
1586                        result = result.replace(&format!("${{{k}}}"), v);
1587                    }
1588                    // 5. GetAtt-style substitutions: ${LogicalId.AttrName}
1589                    for (logical, attrs) in resource_attributes {
1590                        for (attr, value) in attrs {
1591                            result = result.replace(&format!("${{{logical}.{attr}}}"), value);
1592                        }
1593                    }
1594                    return Value::String(result);
1595                }
1596            }
1597            // Recurse into object
1598            let mut new_map = serde_json::Map::new();
1599            for (k, v) in map {
1600                new_map.insert(
1601                    k.clone(),
1602                    resolve_refs_full(
1603                        v,
1604                        parameters,
1605                        _resources,
1606                        resource_physical_ids,
1607                        resource_attributes,
1608                        imports,
1609                        conditions,
1610                    ),
1611                );
1612            }
1613            Value::Object(new_map)
1614        }
1615        Value::Array(arr) => Value::Array(
1616            arr.iter()
1617                .map(|v| {
1618                    resolve_refs_full(
1619                        v,
1620                        parameters,
1621                        _resources,
1622                        resource_physical_ids,
1623                        resource_attributes,
1624                        imports,
1625                        conditions,
1626                    )
1627                })
1628                .collect(),
1629        ),
1630        other => other.clone(),
1631    }
1632}
1633
1634/// Carve `ip_block` (eg. `10.0.0.0/16`) into `count` subnet CIDR
1635/// strings each with a host count of `2^cidr_bits - 2` (matching real
1636/// `Fn::Cidr`). IPv4 only — returns `None` for IPv6 or malformed
1637/// inputs, which leaves the value unresolved at the caller.
1638fn compute_cidr_subnets(ip_block: &str, count: u32, cidr_bits: u32) -> Option<Vec<String>> {
1639    let (ip_str, prefix_str) = ip_block.split_once('/')?;
1640    let prefix: u32 = prefix_str.parse().ok()?;
1641    let ip: std::net::Ipv4Addr = ip_str.parse().ok()?;
1642    let base: u32 = ip.into();
1643    // Subnet size in bits = 32 - new_prefix. Real Fn::Cidr cidr_bits
1644    // is the host portion length, so new_prefix = 32 - cidr_bits.
1645    let new_prefix = 32u32.checked_sub(cidr_bits)?;
1646    if new_prefix <= prefix {
1647        return None;
1648    }
1649    let step: u32 = 1u32 << cidr_bits;
1650    let mut out = Vec::with_capacity(count as usize);
1651    for i in 0..count {
1652        let subnet_base = base.checked_add(step.checked_mul(i)?)?;
1653        let addr = std::net::Ipv4Addr::from(subnet_base);
1654        out.push(format!("{addr}/{new_prefix}"));
1655    }
1656    Some(out)
1657}
1658
1659/// Parse a `Fn::GetAtt` argument. Accepts either the array form
1660/// `["LogicalId", "Attr"]` (also nested attribute paths joined with `.`)
1661/// or the short string form `"LogicalId.Attr"`.
1662fn parse_getatt(value: &Value) -> Option<(String, String)> {
1663    match value {
1664        Value::Array(arr) if arr.len() >= 2 => {
1665            let logical_id = arr[0].as_str()?.to_string();
1666            let parts: Vec<String> = arr[1..]
1667                .iter()
1668                .map(|v| match v {
1669                    Value::String(s) => s.clone(),
1670                    other => other.to_string(),
1671                })
1672                .collect();
1673            Some((logical_id, parts.join(".")))
1674        }
1675        Value::String(s) => {
1676            let (logical_id, attr) = s.split_once('.')?;
1677            Some((logical_id.to_string(), attr.to_string()))
1678        }
1679        _ => None,
1680    }
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685    use super::*;
1686
1687    #[test]
1688    fn parse_json_template() {
1689        let template = r#"{
1690            "Resources": {
1691                "MyQueue": {
1692                    "Type": "AWS::SQS::Queue",
1693                    "Properties": {
1694                        "QueueName": "test-queue"
1695                    }
1696                }
1697            }
1698        }"#;
1699
1700        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1701        assert_eq!(parsed.resources.len(), 1);
1702        assert_eq!(parsed.resources[0].logical_id, "MyQueue");
1703        assert_eq!(parsed.resources[0].resource_type, "AWS::SQS::Queue");
1704    }
1705
1706    #[test]
1707    fn parse_yaml_template() {
1708        let template = r#"
1709Resources:
1710  MyTopic:
1711    Type: AWS::SNS::Topic
1712    Properties:
1713      TopicName: test-topic
1714"#;
1715
1716        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1717        assert_eq!(parsed.resources.len(), 1);
1718        assert_eq!(parsed.resources[0].logical_id, "MyTopic");
1719        assert_eq!(parsed.resources[0].resource_type, "AWS::SNS::Topic");
1720    }
1721
1722    #[test]
1723    fn resolve_ref_parameters() {
1724        let template = r#"{
1725            "Resources": {
1726                "MyQueue": {
1727                    "Type": "AWS::SQS::Queue",
1728                    "Properties": {
1729                        "QueueName": { "Ref": "QueueNameParam" }
1730                    }
1731                }
1732            }
1733        }"#;
1734
1735        let mut params = BTreeMap::new();
1736        params.insert("QueueNameParam".to_string(), "resolved-queue".to_string());
1737        let parsed = parse_template(template, &params).unwrap();
1738        assert_eq!(
1739            parsed.resources[0].properties["QueueName"],
1740            Value::String("resolved-queue".to_string())
1741        );
1742    }
1743
1744    #[test]
1745    fn ref_resolves_physical_id_over_logical_id() {
1746        let template = r#"{
1747            "Resources": {
1748                "MyTopic": {
1749                    "Type": "AWS::SNS::Topic",
1750                    "Properties": {
1751                        "TopicName": "my-topic"
1752                    }
1753                },
1754                "MySub": {
1755                    "Type": "AWS::SNS::Subscription",
1756                    "Properties": {
1757                        "TopicArn": { "Ref": "MyTopic" },
1758                        "Protocol": "sqs",
1759                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
1760                    }
1761                }
1762            }
1763        }"#;
1764
1765        let mut physical_ids = BTreeMap::new();
1766        physical_ids.insert(
1767            "MyTopic".to_string(),
1768            "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
1769        );
1770
1771        let parsed =
1772            parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
1773        let sub = parsed
1774            .resources
1775            .iter()
1776            .find(|r| r.logical_id == "MySub")
1777            .unwrap();
1778        assert_eq!(
1779            sub.properties["TopicArn"],
1780            Value::String("arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
1781        );
1782    }
1783
1784    #[test]
1785    fn ref_without_physical_id_returns_logical_id_for_known_resource() {
1786        let template = r#"{
1787            "Resources": {
1788                "MyTopic": {
1789                    "Type": "AWS::SNS::Topic",
1790                    "Properties": {
1791                        "TopicName": "my-topic"
1792                    }
1793                },
1794                "MySub": {
1795                    "Type": "AWS::SNS::Subscription",
1796                    "Properties": {
1797                        "TopicArn": { "Ref": "MyTopic" },
1798                        "Protocol": "sqs",
1799                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
1800                    }
1801                }
1802            }
1803        }"#;
1804
1805        // No physical IDs yet — logical ID returned for known resources
1806        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1807        let sub = parsed
1808            .resources
1809            .iter()
1810            .find(|r| r.logical_id == "MySub")
1811            .unwrap();
1812        assert_eq!(
1813            sub.properties["TopicArn"],
1814            Value::String("MyTopic".to_string())
1815        );
1816    }
1817
1818    #[test]
1819    fn pseudo_ref_substitutes_when_param_provided() {
1820        let template = r#"{
1821            "Resources": {
1822                "MyQueue": {
1823                    "Type": "AWS::SQS::Queue",
1824                    "Properties": {
1825                        "QueueArn": {
1826                            "Fn::Join": ["", [
1827                                "arn:", {"Ref": "AWS::Partition"}, ":sqs:",
1828                                {"Ref": "AWS::Region"}, ":", {"Ref": "AWS::AccountId"},
1829                                ":", {"Ref": "AWS::StackName"}, "-q"
1830                            ]]
1831                        }
1832                    }
1833                }
1834            }
1835        }"#;
1836        let mut params = BTreeMap::new();
1837        params.insert("AWS::Region".to_string(), "us-west-2".to_string());
1838        params.insert("AWS::AccountId".to_string(), "111122223333".to_string());
1839        params.insert("AWS::Partition".to_string(), "aws".to_string());
1840        params.insert("AWS::StackName".to_string(), "demo".to_string());
1841
1842        let parsed = parse_template(template, &params).unwrap();
1843        assert_eq!(
1844            parsed.resources[0].properties["QueueArn"],
1845            Value::String("arn:aws:sqs:us-west-2:111122223333:demo-q".to_string())
1846        );
1847    }
1848
1849    #[test]
1850    fn pseudo_ref_partition_default_when_unset() {
1851        let template = r#"{
1852            "Resources": {
1853                "MyQueue": {
1854                    "Type": "AWS::SQS::Queue",
1855                    "Properties": {
1856                        "Partition": {"Ref": "AWS::Partition"},
1857                        "Suffix": {"Ref": "AWS::URLSuffix"}
1858                    }
1859                }
1860            }
1861        }"#;
1862        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1863        assert_eq!(
1864            parsed.resources[0].properties["Partition"],
1865            Value::String("aws".to_string())
1866        );
1867        assert_eq!(
1868            parsed.resources[0].properties["Suffix"],
1869            Value::String("amazonaws.com".to_string())
1870        );
1871    }
1872
1873    #[test]
1874    fn pseudo_ref_passes_through() {
1875        let template = r#"{
1876            "Resources": {
1877                "MyQueue": {
1878                    "Type": "AWS::SQS::Queue",
1879                    "Properties": {
1880                        "QueueName": { "Ref": "AWS::StackName" }
1881                    }
1882                }
1883            }
1884        }"#;
1885
1886        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1887        assert_eq!(
1888            parsed.resources[0].properties["QueueName"],
1889            Value::String("AWS::StackName".to_string())
1890        );
1891    }
1892
1893    // ── BB6: pseudo-parameter coverage ────────────────────────────
1894
1895    #[test]
1896    fn bb6_ref_aws_region_returns_seeded_region() {
1897        let template = r#"{
1898            "Resources": {
1899                "Q": {
1900                    "Type": "AWS::SQS::Queue",
1901                    "Properties": {"Region": {"Ref": "AWS::Region"}}
1902                }
1903            }
1904        }"#;
1905        let mut params = BTreeMap::new();
1906        params.insert("AWS::Region".to_string(), "us-east-1".to_string());
1907        let parsed = parse_template(template, &params).unwrap();
1908        assert_eq!(
1909            parsed.resources[0].properties["Region"],
1910            Value::String("us-east-1".to_string())
1911        );
1912    }
1913
1914    #[test]
1915    fn bb6_fn_sub_substitutes_aws_account_id() {
1916        let template = r#"{
1917            "Resources": {
1918                "Q": {
1919                    "Type": "AWS::SQS::Queue",
1920                    "Properties": {
1921                        "Owner": {"Fn::Sub": "owner-${AWS::AccountId}"}
1922                    }
1923                }
1924            }
1925        }"#;
1926        let mut params = BTreeMap::new();
1927        params.insert("AWS::AccountId".to_string(), "123456789012".to_string());
1928        let parsed = parse_template(template, &params).unwrap();
1929        assert_eq!(
1930            parsed.resources[0].properties["Owner"],
1931            Value::String("owner-123456789012".to_string())
1932        );
1933    }
1934
1935    #[test]
1936    fn bb6_partition_for_china_region_is_aws_cn() {
1937        // Caller seeds region but no explicit partition; pseudo_value
1938        // should derive `aws-cn` for cn-* regions.
1939        let template = r#"{
1940            "Resources": {
1941                "Q": {
1942                    "Type": "AWS::SQS::Queue",
1943                    "Properties": {"P": {"Ref": "AWS::Partition"}}
1944                }
1945            }
1946        }"#;
1947        let mut params = BTreeMap::new();
1948        params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
1949        let parsed = parse_template(template, &params).unwrap();
1950        assert_eq!(
1951            parsed.resources[0].properties["P"],
1952            Value::String("aws-cn".to_string())
1953        );
1954    }
1955
1956    #[test]
1957    fn bb6_partition_for_govcloud_region_is_aws_us_gov() {
1958        let template = r#"{
1959            "Resources": {
1960                "Q": {
1961                    "Type": "AWS::SQS::Queue",
1962                    "Properties": {"P": {"Ref": "AWS::Partition"}}
1963                }
1964            }
1965        }"#;
1966        let mut params = BTreeMap::new();
1967        params.insert("AWS::Region".to_string(), "us-gov-west-1".to_string());
1968        let parsed = parse_template(template, &params).unwrap();
1969        assert_eq!(
1970            parsed.resources[0].properties["P"],
1971            Value::String("aws-us-gov".to_string())
1972        );
1973    }
1974
1975    #[test]
1976    fn bb6_url_suffix_for_china_is_amazonaws_com_cn() {
1977        let template = r#"{
1978            "Resources": {
1979                "Q": {
1980                    "Type": "AWS::SQS::Queue",
1981                    "Properties": {"S": {"Ref": "AWS::URLSuffix"}}
1982                }
1983            }
1984        }"#;
1985        let mut params = BTreeMap::new();
1986        params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
1987        let parsed = parse_template(template, &params).unwrap();
1988        assert_eq!(
1989            parsed.resources[0].properties["S"],
1990            Value::String("amazonaws.com.cn".to_string())
1991        );
1992    }
1993
1994    #[test]
1995    fn bb6_url_suffix_for_govcloud_stays_amazonaws_com() {
1996        // GovCloud keeps the standard suffix — only China switches.
1997        let template = r#"{
1998            "Resources": {
1999                "Q": {
2000                    "Type": "AWS::SQS::Queue",
2001                    "Properties": {"S": {"Ref": "AWS::URLSuffix"}}
2002                }
2003            }
2004        }"#;
2005        let mut params = BTreeMap::new();
2006        params.insert("AWS::Region".to_string(), "us-gov-east-1".to_string());
2007        let parsed = parse_template(template, &params).unwrap();
2008        assert_eq!(
2009            parsed.resources[0].properties["S"],
2010            Value::String("amazonaws.com".to_string())
2011        );
2012    }
2013
2014    #[test]
2015    fn bb6_no_value_omits_property_from_resource_input() {
2016        // Direct Ref to AWS::NoValue (no Fn::If wrapper) must still
2017        // drop the property from the resolved resource map.
2018        let template = r#"{
2019            "Resources": {
2020                "Q": {
2021                    "Type": "AWS::SQS::Queue",
2022                    "Properties": {
2023                        "QueueName": "q",
2024                        "OptionalProp": {"Ref": "AWS::NoValue"}
2025                    }
2026                }
2027            }
2028        }"#;
2029        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2030        let props = parsed.resources[0].properties.as_object().unwrap();
2031        assert!(
2032            !props.contains_key("OptionalProp"),
2033            "OptionalProp should be omitted, got: {props:?}"
2034        );
2035        assert_eq!(
2036            props.get("QueueName"),
2037            Some(&Value::String("q".to_string()))
2038        );
2039    }
2040
2041    #[test]
2042    fn bb6_notification_arns_returns_seeded_array() {
2043        // Pseudo-parameter `AWS::NotificationARNs` resolves to an array
2044        // sourced from the JSON-encoded seed (matching the wiring in
2045        // service::create_stack).
2046        let template = r#"{
2047            "Resources": {
2048                "Q": {
2049                    "Type": "AWS::SQS::Queue",
2050                    "Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
2051                }
2052            }
2053        }"#;
2054        let mut params = BTreeMap::new();
2055        params.insert(
2056            "AWS::NotificationARNs".to_string(),
2057            r#"["arn:aws:sns:us-east-1:111122223333:topic"]"#.to_string(),
2058        );
2059        let parsed = parse_template(template, &params).unwrap();
2060        assert_eq!(
2061            parsed.resources[0].properties["Targets"],
2062            serde_json::json!(["arn:aws:sns:us-east-1:111122223333:topic"])
2063        );
2064    }
2065
2066    #[test]
2067    fn bb6_notification_arns_defaults_to_empty_array() {
2068        let template = r#"{
2069            "Resources": {
2070                "Q": {
2071                    "Type": "AWS::SQS::Queue",
2072                    "Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
2073                }
2074            }
2075        }"#;
2076        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2077        assert_eq!(
2078            parsed.resources[0].properties["Targets"],
2079            serde_json::json!([])
2080        );
2081    }
2082
2083    #[test]
2084    fn bb6_fn_sub_array_form_substitutes_extra_vars() {
2085        // The array form `Fn::Sub: ["literal", {Var: ...}]` lets the
2086        // template pass extra bindings; pseudo-params still resolve.
2087        let template = r#"{
2088            "Resources": {
2089                "Q": {
2090                    "Type": "AWS::SQS::Queue",
2091                    "Properties": {
2092                        "Path": {"Fn::Sub": ["${AWS::Region}/${Suffix}", {"Suffix": "tail"}]}
2093                    }
2094                }
2095            }
2096        }"#;
2097        let mut params = BTreeMap::new();
2098        params.insert("AWS::Region".to_string(), "eu-west-1".to_string());
2099        let parsed = parse_template(template, &params).unwrap();
2100        assert_eq!(
2101            parsed.resources[0].properties["Path"],
2102            Value::String("eu-west-1/tail".to_string())
2103        );
2104    }
2105
2106    #[test]
2107    fn bb6_partition_helper_classifies_regions() {
2108        assert_eq!(partition_for_region("us-east-1"), "aws");
2109        assert_eq!(partition_for_region("eu-central-1"), "aws");
2110        assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
2111        assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
2112        assert_eq!(partition_for_region("us-gov-west-1"), "aws-us-gov");
2113        assert_eq!(partition_for_region("us-gov-east-1"), "aws-us-gov");
2114    }
2115
2116    #[test]
2117    fn bb6_url_suffix_helper_classifies_regions() {
2118        assert_eq!(url_suffix_for_region("us-east-1"), "amazonaws.com");
2119        assert_eq!(url_suffix_for_region("us-gov-west-1"), "amazonaws.com");
2120        assert_eq!(url_suffix_for_region("cn-north-1"), "amazonaws.com.cn");
2121    }
2122
2123    #[test]
2124    fn fn_sub_resolves_physical_ids() {
2125        let template = r#"{
2126            "Resources": {
2127                "MyTopic": {
2128                    "Type": "AWS::SNS::Topic",
2129                    "Properties": {
2130                        "TopicName": "my-topic"
2131                    }
2132                },
2133                "MyParam": {
2134                    "Type": "AWS::SSM::Parameter",
2135                    "Properties": {
2136                        "Name": "/app/topic",
2137                        "Type": "String",
2138                        "Value": { "Fn::Sub": "Topic is ${MyTopic}" }
2139                    }
2140                }
2141            }
2142        }"#;
2143
2144        let mut physical_ids = BTreeMap::new();
2145        physical_ids.insert(
2146            "MyTopic".to_string(),
2147            "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
2148        );
2149
2150        let parsed =
2151            parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
2152        let param = parsed
2153            .resources
2154            .iter()
2155            .find(|r| r.logical_id == "MyParam")
2156            .unwrap();
2157        assert_eq!(
2158            param.properties["Value"],
2159            Value::String("Topic is arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
2160        );
2161    }
2162
2163    // ── error paths ──
2164
2165    #[test]
2166    fn parse_template_invalid_json_errors() {
2167        let params = BTreeMap::new();
2168        let result = parse_template("{not-json}", &params);
2169        assert!(result.is_err());
2170    }
2171
2172    #[test]
2173    fn parse_template_missing_resources_errors() {
2174        let params = BTreeMap::new();
2175        let result = parse_template(r#"{"Description":"no resources"}"#, &params);
2176        assert!(result.is_err());
2177    }
2178
2179    #[test]
2180    fn parse_template_resources_not_object_errors() {
2181        let params = BTreeMap::new();
2182        let result = parse_template(r#"{"Resources": []}"#, &params);
2183        assert!(result.is_err());
2184    }
2185
2186    #[test]
2187    fn parse_template_missing_type_errors() {
2188        let params = BTreeMap::new();
2189        let result = parse_template(r#"{"Resources":{"R":{"Properties":{}}}}"#, &params);
2190        assert!(result.is_err());
2191    }
2192
2193    // ── Fn::GetAtt ──
2194
2195    #[test]
2196    fn fn_getatt_resolves_attribute_in_array_form() {
2197        let template = r#"{
2198            "Resources": {
2199                "MyQueue": {
2200                    "Type": "AWS::SQS::Queue",
2201                    "Properties": { "QueueName": "q1" }
2202                },
2203                "MyTopic": {
2204                    "Type": "AWS::SNS::Topic",
2205                    "Properties": {
2206                        "TopicName": "t1",
2207                        "DataProtectionPolicy": {
2208                            "Fn::GetAtt": ["MyQueue", "Arn"]
2209                        }
2210                    }
2211                }
2212            }
2213        }"#;
2214
2215        let mut attrs = BTreeMap::new();
2216        let mut q_attrs = BTreeMap::new();
2217        q_attrs.insert(
2218            "Arn".to_string(),
2219            "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
2220        );
2221        attrs.insert("MyQueue".to_string(), q_attrs);
2222
2223        let parsed =
2224            parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
2225                .unwrap();
2226        let topic = parsed
2227            .resources
2228            .iter()
2229            .find(|r| r.logical_id == "MyTopic")
2230            .unwrap();
2231        assert_eq!(
2232            topic.properties["DataProtectionPolicy"],
2233            Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
2234        );
2235    }
2236
2237    #[test]
2238    fn fn_getatt_resolves_attribute_in_short_string_form() {
2239        let template = r#"{
2240            "Resources": {
2241                "MyTopic": {
2242                    "Type": "AWS::SNS::Topic",
2243                    "Properties": {
2244                        "TopicName": "t1",
2245                        "PolicyArn": { "Fn::GetAtt": "MyQueue.Arn" }
2246                    }
2247                }
2248            }
2249        }"#;
2250
2251        let mut attrs = BTreeMap::new();
2252        let mut q_attrs = BTreeMap::new();
2253        q_attrs.insert(
2254            "Arn".to_string(),
2255            "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
2256        );
2257        attrs.insert("MyQueue".to_string(), q_attrs);
2258
2259        let parsed =
2260            parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
2261                .unwrap();
2262        assert_eq!(
2263            parsed.resources[0].properties["PolicyArn"],
2264            Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
2265        );
2266    }
2267
2268    #[test]
2269    fn fn_getatt_unknown_resource_returns_placeholder() {
2270        let template = r#"{
2271            "Resources": {
2272                "MyTopic": {
2273                    "Type": "AWS::SNS::Topic",
2274                    "Properties": {
2275                        "TopicName": { "Fn::GetAtt": ["MyQueue", "Arn"] }
2276                    }
2277                }
2278            }
2279        }"#;
2280
2281        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2282        // Unresolved GetAtt becomes a placeholder; multi-pass provisioning
2283        // re-resolves once the target is known.
2284        assert_eq!(
2285            parsed.resources[0].properties["TopicName"],
2286            Value::String("MyQueue.Arn".to_string())
2287        );
2288    }
2289
2290    #[test]
2291    fn fn_getatt_inside_fn_join_resolves() {
2292        let template = r#"{
2293            "Resources": {
2294                "MyParam": {
2295                    "Type": "AWS::SSM::Parameter",
2296                    "Properties": {
2297                        "Name": "/app/q",
2298                        "Type": "String",
2299                        "Value": {
2300                            "Fn::Join": [":", ["queue", { "Fn::GetAtt": ["MyQueue", "Arn"] }]]
2301                        }
2302                    }
2303                }
2304            }
2305        }"#;
2306
2307        let mut attrs = BTreeMap::new();
2308        let mut q_attrs = BTreeMap::new();
2309        q_attrs.insert(
2310            "Arn".to_string(),
2311            "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
2312        );
2313        attrs.insert("MyQueue".to_string(), q_attrs);
2314
2315        let parsed =
2316            parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
2317                .unwrap();
2318        assert_eq!(
2319            parsed.resources[0].properties["Value"],
2320            Value::String("queue:arn:aws:sqs:us-east-1:123456789012:q1".to_string())
2321        );
2322    }
2323
2324    #[test]
2325    fn fn_sub_resolves_getatt_style_substitution() {
2326        let template = r#"{
2327            "Resources": {
2328                "MyParam": {
2329                    "Type": "AWS::SSM::Parameter",
2330                    "Properties": {
2331                        "Name": "/app/q",
2332                        "Type": "String",
2333                        "Value": { "Fn::Sub": "Queue arn is ${MyQueue.Arn}" }
2334                    }
2335                }
2336            }
2337        }"#;
2338
2339        let mut attrs = BTreeMap::new();
2340        let mut q_attrs = BTreeMap::new();
2341        q_attrs.insert(
2342            "Arn".to_string(),
2343            "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
2344        );
2345        attrs.insert("MyQueue".to_string(), q_attrs);
2346
2347        let parsed =
2348            parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
2349                .unwrap();
2350        assert_eq!(
2351            parsed.resources[0].properties["Value"],
2352            Value::String("Queue arn is arn:aws:sqs:us-east-1:123456789012:q1".to_string())
2353        );
2354    }
2355
2356    #[test]
2357    fn parse_template_with_description() {
2358        let params = BTreeMap::new();
2359        let parsed = parse_template(
2360            r#"{"Description":"My template","Resources":{"R":{"Type":"AWS::SQS::Queue"}}}"#,
2361            &params,
2362        )
2363        .unwrap();
2364        assert_eq!(parsed.description.as_deref(), Some("My template"));
2365        assert_eq!(parsed.resources.len(), 1);
2366    }
2367
2368    type EmptyCtx = (
2369        BTreeMap<String, String>,
2370        serde_json::Map<String, Value>,
2371        BTreeMap<String, String>,
2372        BTreeMap<String, BTreeMap<String, String>>,
2373    );
2374
2375    fn empty() -> EmptyCtx {
2376        (
2377            BTreeMap::new(),
2378            serde_json::Map::new(),
2379            BTreeMap::new(),
2380            BTreeMap::new(),
2381        )
2382    }
2383
2384    #[test]
2385    fn fn_base64_encodes_string() {
2386        let (p, r, ids, attrs) = empty();
2387        let v: Value = serde_json::from_str(r#"{"Fn::Base64": "hello"}"#).unwrap();
2388        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2389        assert_eq!(resolved, Value::String("aGVsbG8=".to_string()));
2390    }
2391
2392    #[test]
2393    fn fn_split_emits_array() {
2394        let (p, r, ids, attrs) = empty();
2395        let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "a,b,c"]}"#).unwrap();
2396        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2397        assert_eq!(resolved, serde_json::json!(["a", "b", "c"]));
2398    }
2399
2400    #[test]
2401    fn fn_select_picks_index() {
2402        let (p, r, ids, attrs) = empty();
2403        let v: Value =
2404            serde_json::from_str(r#"{"Fn::Select": [1, {"Fn::Split": [",", "a,b,c"]}]}"#).unwrap();
2405        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2406        assert_eq!(resolved, Value::String("b".to_string()));
2407    }
2408
2409    #[test]
2410    fn fn_length_counts_array() {
2411        let (p, r, ids, attrs) = empty();
2412        let v: Value = serde_json::from_str(r#"{"Fn::Length": [1,2,3,4]}"#).unwrap();
2413        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2414        assert_eq!(resolved, Value::Number(4.into()));
2415    }
2416
2417    #[test]
2418    fn fn_to_json_string_serializes() {
2419        let (p, r, ids, attrs) = empty();
2420        let v: Value =
2421            serde_json::from_str(r#"{"Fn::ToJsonString": {"a": 1, "b": [2, 3]}}"#).unwrap();
2422        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2423        let s = resolved.as_str().unwrap();
2424        // Order-insensitive: just verify it parses back.
2425        let parsed: Value = serde_json::from_str(s).unwrap();
2426        assert_eq!(parsed["a"], serde_json::json!(1));
2427        assert_eq!(parsed["b"], serde_json::json!([2, 3]));
2428    }
2429
2430    #[test]
2431    fn fn_cidr_carves_subnets() {
2432        let (p, r, ids, attrs) = empty();
2433        // Carve 10.0.0.0/16 into 4 /24 subnets (cidr_bits = 8 host bits).
2434        let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 4, 8]}"#).unwrap();
2435        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
2436        assert_eq!(
2437            resolved,
2438            serde_json::json!(["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",])
2439        );
2440    }
2441
2442    #[test]
2443    fn condition_skips_resource_when_false() {
2444        let template = r#"{
2445            "Parameters": {"Env": {"Type": "String"}},
2446            "Conditions": {
2447                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
2448            },
2449            "Resources": {
2450                "ProdQueue": {
2451                    "Type": "AWS::SQS::Queue",
2452                    "Condition": "IsProd",
2453                    "Properties": {"QueueName": "prod-q"}
2454                },
2455                "AlwaysQueue": {
2456                    "Type": "AWS::SQS::Queue",
2457                    "Properties": {"QueueName": "always-q"}
2458                }
2459            }
2460        }"#;
2461        let mut params = BTreeMap::new();
2462        params.insert("Env".to_string(), "dev".to_string());
2463        let parsed = parse_template(template, &params).unwrap();
2464        let names: Vec<&str> = parsed
2465            .resources
2466            .iter()
2467            .map(|r| r.logical_id.as_str())
2468            .collect();
2469        assert!(names.contains(&"AlwaysQueue"));
2470        assert!(!names.contains(&"ProdQueue"));
2471    }
2472
2473    #[test]
2474    fn condition_includes_resource_when_true() {
2475        let template = r#"{
2476            "Parameters": {"Env": {"Type": "String"}},
2477            "Conditions": {
2478                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
2479            },
2480            "Resources": {
2481                "ProdQueue": {
2482                    "Type": "AWS::SQS::Queue",
2483                    "Condition": "IsProd",
2484                    "Properties": {"QueueName": "prod-q"}
2485                }
2486            }
2487        }"#;
2488        let mut params = BTreeMap::new();
2489        params.insert("Env".to_string(), "prod".to_string());
2490        let parsed = parse_template(template, &params).unwrap();
2491        assert_eq!(parsed.resources.len(), 1);
2492    }
2493
2494    #[test]
2495    fn fn_if_picks_branch_based_on_condition() {
2496        let template = r#"{
2497            "Parameters": {"Env": {"Type": "String"}},
2498            "Conditions": {
2499                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
2500            },
2501            "Resources": {
2502                "Q": {
2503                    "Type": "AWS::SQS::Queue",
2504                    "Properties": {
2505                        "QueueName": {"Fn::If": ["IsProd", "prod-q", "dev-q"]}
2506                    }
2507                }
2508            }
2509        }"#;
2510        let mut params = BTreeMap::new();
2511        params.insert("Env".to_string(), "dev".to_string());
2512        let parsed = parse_template(template, &params).unwrap();
2513        assert_eq!(
2514            parsed.resources[0].properties["QueueName"],
2515            Value::String("dev-q".to_string())
2516        );
2517    }
2518
2519    #[test]
2520    fn fn_and_or_not_combine_conditions() {
2521        let template = r#"{
2522            "Parameters": {"Env": {"Type": "String"}, "Region": {"Type": "String"}},
2523            "Conditions": {
2524                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
2525                "IsUsEast": {"Fn::Equals": [{"Ref": "Region"}, "us-east-1"]},
2526                "IsProdInUsEast": {"Fn::And": [{"Condition": "IsProd"}, {"Condition": "IsUsEast"}]},
2527                "IsNotProd": {"Fn::Not": [{"Condition": "IsProd"}]},
2528                "IsAny": {"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "IsNotProd"}]}
2529            },
2530            "Resources": {
2531                "Q": {
2532                    "Type": "AWS::SQS::Queue",
2533                    "Properties": {
2534                        "P1": {"Fn::If": ["IsProdInUsEast", "yes", "no"]},
2535                        "P2": {"Fn::If": ["IsNotProd", "yes", "no"]},
2536                        "P3": {"Fn::If": ["IsAny", "yes", "no"]}
2537                    }
2538                }
2539            }
2540        }"#;
2541        let mut params = BTreeMap::new();
2542        params.insert("Env".to_string(), "prod".to_string());
2543        params.insert("Region".to_string(), "us-east-1".to_string());
2544        let parsed = parse_template(template, &params).unwrap();
2545        let p = &parsed.resources[0].properties;
2546        assert_eq!(p["P1"], Value::String("yes".to_string()));
2547        assert_eq!(p["P2"], Value::String("no".to_string()));
2548        assert_eq!(p["P3"], Value::String("yes".to_string()));
2549    }
2550
2551    #[test]
2552    fn fn_find_in_map_resolves_leaf_value() {
2553        let template = r#"{
2554            "Mappings": {
2555                "RegionMap": {
2556                    "us-east-1": {"AMI": "ami-east"},
2557                    "us-west-2": {"AMI": "ami-west"}
2558                }
2559            },
2560            "Resources": {
2561                "Inst": {
2562                    "Type": "AWS::EC2::Instance",
2563                    "Properties": {
2564                        "ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
2565                    }
2566                }
2567            }
2568        }"#;
2569        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2570        assert_eq!(
2571            parsed.resources[0].properties["ImageId"],
2572            Value::String("ami-east".to_string())
2573        );
2574    }
2575
2576    #[test]
2577    fn fn_find_in_map_resolves_keys_via_ref() {
2578        let template = r#"{
2579            "Parameters": {"Region": {"Type": "String"}},
2580            "Mappings": {
2581                "RegionMap": {
2582                    "us-east-1": {"AMI": "ami-east"},
2583                    "us-west-2": {"AMI": "ami-west"}
2584                }
2585            },
2586            "Resources": {
2587                "Inst": {
2588                    "Type": "AWS::EC2::Instance",
2589                    "Properties": {
2590                        "ImageId": {"Fn::FindInMap": ["RegionMap", {"Ref": "Region"}, "AMI"]}
2591                    }
2592                }
2593            }
2594        }"#;
2595        let mut params = BTreeMap::new();
2596        params.insert("Region".to_string(), "us-west-2".to_string());
2597        let parsed = parse_template(template, &params).unwrap();
2598        assert_eq!(
2599            parsed.resources[0].properties["ImageId"],
2600            Value::String("ami-west".to_string())
2601        );
2602    }
2603
2604    #[test]
2605    fn fn_find_in_map_unknown_keys_returns_error() {
2606        let template = r#"{
2607            "Mappings": {
2608                "RegionMap": {
2609                    "us-east-1": {"AMI": "ami-east"}
2610                }
2611            },
2612            "Resources": {
2613                "Inst": {
2614                    "Type": "AWS::EC2::Instance",
2615                    "Properties": {
2616                        "ImageId": {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]}
2617                    }
2618                }
2619            }
2620        }"#;
2621        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2622        assert!(
2623            err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
2624            "got: {err}"
2625        );
2626    }
2627
2628    #[test]
2629    fn fn_find_in_map_four_arg_returns_default_when_missing() {
2630        let template = r#"{
2631            "Mappings": {
2632                "RegionMap": {
2633                    "us-east-1": {"AMI": "ami-east"}
2634                }
2635            },
2636            "Resources": {
2637                "Inst": {
2638                    "Type": "AWS::EC2::Instance",
2639                    "Properties": {
2640                        "ImageId": {"Fn::FindInMap": [
2641                            "RegionMap",
2642                            "ap-south-1",
2643                            "AMI",
2644                            {"DefaultValue": "ami-fallback"}
2645                        ]}
2646                    }
2647                }
2648            }
2649        }"#;
2650        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2651        assert_eq!(
2652            parsed.resources[0].properties["ImageId"],
2653            Value::String("ami-fallback".to_string())
2654        );
2655    }
2656
2657    #[test]
2658    fn fn_find_in_map_four_arg_prefers_match_over_default() {
2659        let template = r#"{
2660            "Mappings": {
2661                "RegionMap": {
2662                    "us-east-1": {"AMI": "ami-east"}
2663                }
2664            },
2665            "Resources": {
2666                "Inst": {
2667                    "Type": "AWS::EC2::Instance",
2668                    "Properties": {
2669                        "ImageId": {"Fn::FindInMap": [
2670                            "RegionMap",
2671                            "us-east-1",
2672                            "AMI",
2673                            {"DefaultValue": "ami-fallback"}
2674                        ]}
2675                    }
2676                }
2677            }
2678        }"#;
2679        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2680        assert_eq!(
2681            parsed.resources[0].properties["ImageId"],
2682            Value::String("ami-east".to_string())
2683        );
2684    }
2685
2686    #[test]
2687    fn fn_find_in_map_default_value_is_resolved_intrinsic() {
2688        let template = r#"{
2689            "Parameters": {"Fallback": {"Type": "String"}},
2690            "Mappings": {
2691                "RegionMap": {
2692                    "us-east-1": {"AMI": "ami-east"}
2693                }
2694            },
2695            "Resources": {
2696                "Inst": {
2697                    "Type": "AWS::EC2::Instance",
2698                    "Properties": {
2699                        "ImageId": {"Fn::FindInMap": [
2700                            "RegionMap",
2701                            "ap-south-1",
2702                            "AMI",
2703                            {"DefaultValue": {"Ref": "Fallback"}}
2704                        ]}
2705                    }
2706                }
2707            }
2708        }"#;
2709        let mut params = BTreeMap::new();
2710        params.insert("Fallback".to_string(), "ami-default".to_string());
2711        let parsed = parse_template(template, &params).unwrap();
2712        assert_eq!(
2713            parsed.resources[0].properties["ImageId"],
2714            Value::String("ami-default".to_string())
2715        );
2716    }
2717
2718    #[test]
2719    fn fn_find_in_map_unknown_map_name_errors() {
2720        let template = r#"{
2721            "Mappings": {
2722                "RegionMap": {
2723                    "us-east-1": {"AMI": "ami-east"}
2724                }
2725            },
2726            "Resources": {
2727                "Inst": {
2728                    "Type": "AWS::EC2::Instance",
2729                    "Properties": {
2730                        "ImageId": {"Fn::FindInMap": ["DoesNotExist", "us-east-1", "AMI"]}
2731                    }
2732                }
2733            }
2734        }"#;
2735        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2736        assert!(
2737            err.contains("Unable to get mapping for DoesNotExist::us-east-1::AMI"),
2738            "got: {err}"
2739        );
2740    }
2741
2742    #[test]
2743    fn fn_find_in_map_wrong_arg_count_errors() {
2744        let template = r#"{
2745            "Mappings": {"M": {"a": {"b": "c"}}},
2746            "Resources": {
2747                "Q": {
2748                    "Type": "AWS::SQS::Queue",
2749                    "Properties": {
2750                        "QueueName": {"Fn::FindInMap": ["M", "a"]}
2751                    }
2752                }
2753            }
2754        }"#;
2755        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2756        assert!(
2757            err.contains("Fn::FindInMap requires 3 or 4 arguments"),
2758            "got: {err}"
2759        );
2760    }
2761
2762    #[test]
2763    fn fn_find_in_map_resolves_via_pseudo_region() {
2764        let template = r#"{
2765            "Mappings": {
2766                "RegionMap": {
2767                    "us-east-1": {"AMI": "ami-east"},
2768                    "us-west-2": {"AMI": "ami-west"}
2769                }
2770            },
2771            "Resources": {
2772                "Inst": {
2773                    "Type": "AWS::EC2::Instance",
2774                    "Properties": {
2775                        "ImageId": {"Fn::FindInMap": [
2776                            "RegionMap",
2777                            {"Ref": "AWS::Region"},
2778                            "AMI"
2779                        ]}
2780                    }
2781                }
2782            }
2783        }"#;
2784        // No AWS::Region in parameters — the pseudo-default ("us-east-1")
2785        // should kick in so FindInMap still resolves.
2786        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2787        assert_eq!(
2788            parsed.resources[0].properties["ImageId"],
2789            Value::String("ami-east".to_string())
2790        );
2791    }
2792
2793    #[test]
2794    fn fn_find_in_map_in_unused_if_branch_does_not_error() {
2795        // FindInMap sits in the FALSE branch of Fn::If; the path
2796        // `RegionMap::ap-south-1::AMI` doesn't exist. Because
2797        // `WantAlt` resolves to "no" the alt branch is unused and
2798        // CFN never executes that FindInMap — parse_template must
2799        // succeed instead of erroring.
2800        let template = r#"{
2801            "Parameters": {"WantAlt": {"Type": "String"}},
2802            "Conditions": {
2803                "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
2804            },
2805            "Mappings": {
2806                "RegionMap": {
2807                    "us-east-1": {"AMI": "ami-east"}
2808                }
2809            },
2810            "Resources": {
2811                "Inst": {
2812                    "Type": "AWS::EC2::Instance",
2813                    "Properties": {
2814                        "ImageId": {"Fn::If": [
2815                            "UseAlt",
2816                            {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
2817                            {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
2818                        ]}
2819                    }
2820                }
2821            }
2822        }"#;
2823        let mut params = BTreeMap::new();
2824        params.insert("WantAlt".to_string(), "no".to_string());
2825        let parsed = parse_template(template, &params).unwrap();
2826        assert_eq!(
2827            parsed.resources[0].properties["ImageId"],
2828            Value::String("ami-east".to_string())
2829        );
2830    }
2831
2832    #[test]
2833    fn fn_find_in_map_in_active_if_branch_still_errors_on_miss() {
2834        // Same shape as above but the active branch is the broken
2835        // one; the strict miss handling must still surface.
2836        let template = r#"{
2837            "Parameters": {"WantAlt": {"Type": "String"}},
2838            "Conditions": {
2839                "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
2840            },
2841            "Mappings": {
2842                "RegionMap": {
2843                    "us-east-1": {"AMI": "ami-east"}
2844                }
2845            },
2846            "Resources": {
2847                "Inst": {
2848                    "Type": "AWS::EC2::Instance",
2849                    "Properties": {
2850                        "ImageId": {"Fn::If": [
2851                            "UseAlt",
2852                            {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
2853                            {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
2854                        ]}
2855                    }
2856                }
2857            }
2858        }"#;
2859        let mut params = BTreeMap::new();
2860        params.insert("WantAlt".to_string(), "yes".to_string());
2861        let err = parse_template(template, &params).unwrap_err();
2862        assert!(
2863            err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
2864            "got: {err}"
2865        );
2866    }
2867
2868    #[test]
2869    fn fn_find_in_map_alongside_ref_and_sub_still_resolve() {
2870        let template = r#"{
2871            "Parameters": {"Env": {"Type": "String"}},
2872            "Mappings": {
2873                "EnvMap": {
2874                    "prod": {"Suffix": "live"},
2875                    "dev": {"Suffix": "test"}
2876                }
2877            },
2878            "Resources": {
2879                "Q": {
2880                    "Type": "AWS::SQS::Queue",
2881                    "Properties": {
2882                        "QueueName": {"Fn::FindInMap": ["EnvMap", {"Ref": "Env"}, "Suffix"]},
2883                        "Tags": [
2884                            {"Key": "EnvRef", "Value": {"Ref": "Env"}},
2885                            {"Key": "Subbed", "Value": {"Fn::Sub": "env-${Env}"}}
2886                        ]
2887                    }
2888                }
2889            }
2890        }"#;
2891        let mut params = BTreeMap::new();
2892        params.insert("Env".to_string(), "prod".to_string());
2893        let parsed = parse_template(template, &params).unwrap();
2894        let p = &parsed.resources[0].properties;
2895        assert_eq!(p["QueueName"], Value::String("live".to_string()));
2896        assert_eq!(p["Tags"][0]["Value"], Value::String("prod".to_string()));
2897        assert_eq!(p["Tags"][1]["Value"], Value::String("env-prod".to_string()));
2898    }
2899
2900    // ── Conditions: cycle detection + AWS::NoValue removal ──
2901
2902    #[test]
2903    fn cyclic_conditions_self_reference_errors() {
2904        let template = r#"{
2905            "Conditions": {
2906                "A": {"Condition": "A"}
2907            },
2908            "Resources": {
2909                "Q": {
2910                    "Type": "AWS::SQS::Queue",
2911                    "Condition": "A",
2912                    "Properties": {"QueueName": "q"}
2913                }
2914            }
2915        }"#;
2916        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2917        assert!(err.contains("Circular reference"), "got: {err}");
2918        assert!(err.contains("'A'"), "got: {err}");
2919    }
2920
2921    #[test]
2922    fn cyclic_conditions_two_step_errors() {
2923        let template = r#"{
2924            "Conditions": {
2925                "A": {"Condition": "B"},
2926                "B": {"Condition": "A"}
2927            },
2928            "Resources": {
2929                "Q": {
2930                    "Type": "AWS::SQS::Queue",
2931                    "Condition": "A",
2932                    "Properties": {"QueueName": "q"}
2933                }
2934            }
2935        }"#;
2936        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2937        assert!(err.contains("Circular reference"), "got: {err}");
2938    }
2939
2940    #[test]
2941    fn condition_referencing_undefined_name_errors() {
2942        let template = r#"{
2943            "Conditions": {
2944                "A": {"Condition": "DoesNotExist"}
2945            },
2946            "Resources": {
2947                "Q": {
2948                    "Type": "AWS::SQS::Queue",
2949                    "Condition": "A",
2950                    "Properties": {"QueueName": "q"}
2951                }
2952            }
2953        }"#;
2954        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2955        assert!(err.contains("DoesNotExist"), "got: {err}");
2956    }
2957
2958    #[test]
2959    fn fn_if_no_value_removes_property_from_parent_map() {
2960        let template = r#"{
2961            "Parameters": {"WantTags": {"Type": "String"}},
2962            "Conditions": {
2963                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
2964            },
2965            "Resources": {
2966                "Q": {
2967                    "Type": "AWS::SQS::Queue",
2968                    "Properties": {
2969                        "QueueName": "q",
2970                        "Tags": {"Fn::If": [
2971                            "HasTags",
2972                            [{"Key": "a", "Value": "b"}],
2973                            {"Ref": "AWS::NoValue"}
2974                        ]}
2975                    }
2976                }
2977            }
2978        }"#;
2979        let mut params = BTreeMap::new();
2980        params.insert("WantTags".to_string(), "no".to_string());
2981        let parsed = parse_template(template, &params).unwrap();
2982        let props = parsed.resources[0].properties.as_object().unwrap();
2983        assert!(
2984            !props.contains_key("Tags"),
2985            "Tags should be omitted when AWS::NoValue picked, got: {props:?}"
2986        );
2987        assert_eq!(
2988            props.get("QueueName"),
2989            Some(&Value::String("q".to_string()))
2990        );
2991    }
2992
2993    #[test]
2994    fn fn_if_no_value_keeps_property_when_branch_concrete() {
2995        let template = r#"{
2996            "Parameters": {"WantTags": {"Type": "String"}},
2997            "Conditions": {
2998                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
2999            },
3000            "Resources": {
3001                "Q": {
3002                    "Type": "AWS::SQS::Queue",
3003                    "Properties": {
3004                        "QueueName": "q",
3005                        "Tags": {"Fn::If": [
3006                            "HasTags",
3007                            [{"Key": "a", "Value": "b"}],
3008                            {"Ref": "AWS::NoValue"}
3009                        ]}
3010                    }
3011                }
3012            }
3013        }"#;
3014        let mut params = BTreeMap::new();
3015        params.insert("WantTags".to_string(), "yes".to_string());
3016        let parsed = parse_template(template, &params).unwrap();
3017        let tags = &parsed.resources[0].properties["Tags"];
3018        assert_eq!(
3019            tags,
3020            &serde_json::json!([{"Key": "a", "Value": "b"}]),
3021            "tags should be the true branch's array"
3022        );
3023    }
3024
3025    #[test]
3026    fn fn_if_no_value_in_array_drops_element() {
3027        let template = r#"{
3028            "Parameters": {"Extra": {"Type": "String"}},
3029            "Conditions": {
3030                "HasExtra": {"Fn::Equals": [{"Ref": "Extra"}, "yes"]}
3031            },
3032            "Resources": {
3033                "Q": {
3034                    "Type": "AWS::SQS::Queue",
3035                    "Properties": {
3036                        "Items": [
3037                            "first",
3038                            {"Fn::If": ["HasExtra", "second", {"Ref": "AWS::NoValue"}]},
3039                            "third"
3040                        ]
3041                    }
3042                }
3043            }
3044        }"#;
3045        let mut params = BTreeMap::new();
3046        params.insert("Extra".to_string(), "no".to_string());
3047        let parsed = parse_template(template, &params).unwrap();
3048        assert_eq!(
3049            parsed.resources[0].properties["Items"],
3050            serde_json::json!(["first", "third"])
3051        );
3052    }
3053
3054    #[test]
3055    fn condition_skips_output_when_false() {
3056        let template = r#"{
3057            "Parameters": {"Env": {"Type": "String"}},
3058            "Conditions": {
3059                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
3060            },
3061            "Resources": {
3062                "Q": {
3063                    "Type": "AWS::SQS::Queue",
3064                    "Properties": {"QueueName": "q"}
3065                }
3066            },
3067            "Outputs": {
3068                "ProdName": {
3069                    "Condition": "IsProd",
3070                    "Value": "prod-only"
3071                },
3072                "Always": {
3073                    "Value": "shown"
3074                }
3075            }
3076        }"#;
3077        let mut params = BTreeMap::new();
3078        params.insert("Env".to_string(), "dev".to_string());
3079        let parsed = parse_template(template, &params).unwrap();
3080        let names: Vec<&str> = parsed
3081            .outputs
3082            .iter()
3083            .map(|o| o.logical_id.as_str())
3084            .collect();
3085        assert!(names.contains(&"Always"));
3086        assert!(!names.contains(&"ProdName"));
3087    }
3088
3089    #[test]
3090    fn fn_and_short_circuits_on_false() {
3091        let template = r#"{
3092            "Parameters": {"Env": {"Type": "String"}},
3093            "Conditions": {
3094                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
3095                "Combined": {"Fn::And": [
3096                    {"Condition": "IsProd"},
3097                    {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
3098                ]}
3099            },
3100            "Resources": {
3101                "Q": {
3102                    "Type": "AWS::SQS::Queue",
3103                    "Condition": "Combined",
3104                    "Properties": {"QueueName": "q"}
3105                }
3106            }
3107        }"#;
3108        let mut params = BTreeMap::new();
3109        params.insert("Env".to_string(), "dev".to_string());
3110        let parsed = parse_template(template, &params).unwrap();
3111        assert_eq!(parsed.resources.len(), 0);
3112    }
3113
3114    #[test]
3115    fn fn_or_short_circuits_on_true() {
3116        let template = r#"{
3117            "Parameters": {"Env": {"Type": "String"}},
3118            "Conditions": {
3119                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
3120                "AnyEnv": {"Fn::Or": [
3121                    {"Condition": "IsProd"},
3122                    {"Fn::Equals": [{"Ref": "Env"}, "dev"]},
3123                    {"Fn::Equals": [{"Ref": "Env"}, "stage"]}
3124                ]}
3125            },
3126            "Resources": {
3127                "Q": {
3128                    "Type": "AWS::SQS::Queue",
3129                    "Condition": "AnyEnv",
3130                    "Properties": {"QueueName": "q"}
3131                }
3132            }
3133        }"#;
3134        let mut params = BTreeMap::new();
3135        params.insert("Env".to_string(), "stage".to_string());
3136        let parsed = parse_template(template, &params).unwrap();
3137        assert_eq!(parsed.resources.len(), 1);
3138    }
3139
3140    #[test]
3141    fn fn_and_rejects_arity_outside_1_to_10() {
3142        let template = r#"{
3143            "Conditions": {
3144                "Empty": {"Fn::And": []}
3145            },
3146            "Resources": {
3147                "Q": {
3148                    "Type": "AWS::SQS::Queue",
3149                    "Condition": "Empty",
3150                    "Properties": {"QueueName": "q"}
3151                }
3152            }
3153        }"#;
3154        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
3155        assert!(err.contains("Fn::And"), "got: {err}");
3156    }
3157
3158    #[test]
3159    fn condition_evaluation_memoizes_complex_expression() {
3160        // Both `Outer` branches reuse `Inner`. With memoization the
3161        // inner condition only resolves once; without it, this would
3162        // still pass — but the test guards against regressions where
3163        // re-evaluation triggers double Fn::Equals work.
3164        let template = r#"{
3165            "Parameters": {"Env": {"Type": "String"}},
3166            "Conditions": {
3167                "Inner": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
3168                "OuterA": {"Fn::And": [{"Condition": "Inner"}, {"Condition": "Inner"}]},
3169                "OuterB": {"Fn::Or": [{"Condition": "Inner"}, {"Condition": "OuterA"}]}
3170            },
3171            "Resources": {
3172                "Q": {
3173                    "Type": "AWS::SQS::Queue",
3174                    "Condition": "OuterB",
3175                    "Properties": {"QueueName": "q"}
3176                }
3177            }
3178        }"#;
3179        let mut params = BTreeMap::new();
3180        params.insert("Env".to_string(), "prod".to_string());
3181        let parsed = parse_template(template, &params).unwrap();
3182        assert_eq!(parsed.resources.len(), 1);
3183    }
3184
3185    #[test]
3186    fn fn_not_rejects_multiple_arguments() {
3187        let template = r#"{
3188            "Parameters": {"Env": {"Type": "String"}},
3189            "Conditions": {
3190                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
3191                "Bad": {"Fn::Not": [
3192                    {"Condition": "IsProd"},
3193                    {"Condition": "IsProd"}
3194                ]}
3195            },
3196            "Resources": {
3197                "Q": {
3198                    "Type": "AWS::SQS::Queue",
3199                    "Condition": "Bad",
3200                    "Properties": {"QueueName": "q"}
3201                }
3202            }
3203        }"#;
3204        let mut params = BTreeMap::new();
3205        params.insert("Env".to_string(), "prod".to_string());
3206        let err = parse_template(template, &params).unwrap_err();
3207        assert!(err.contains("Fn::Not"), "got: {err}");
3208    }
3209
3210    #[test]
3211    fn fn_not_rejects_zero_arguments() {
3212        let template = r#"{
3213            "Conditions": {
3214                "Bad": {"Fn::Not": []}
3215            },
3216            "Resources": {
3217                "Q": {
3218                    "Type": "AWS::SQS::Queue",
3219                    "Condition": "Bad",
3220                    "Properties": {"QueueName": "q"}
3221                }
3222            }
3223        }"#;
3224        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
3225        assert!(err.contains("Fn::Not"), "got: {err}");
3226    }
3227
3228    #[test]
3229    fn resolve_resource_properties_strips_no_value_at_provision_time() {
3230        // Mirrors the incremental-provisioning code path which calls
3231        // resolve_resource_properties_with_attrs after the initial parse.
3232        // The sentinel must not leak into the resolved properties even
3233        // when re-resolved with updated physical IDs.
3234        let template = r#"{
3235            "Parameters": {"WantTags": {"Type": "String"}},
3236            "Conditions": {
3237                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
3238            },
3239            "Resources": {
3240                "Q": {
3241                    "Type": "AWS::SQS::Queue",
3242                    "Properties": {
3243                        "QueueName": "q",
3244                        "Tags": {"Fn::If": [
3245                            "HasTags",
3246                            [{"Key": "a", "Value": "b"}],
3247                            {"Ref": "AWS::NoValue"}
3248                        ]}
3249                    }
3250                }
3251            }
3252        }"#;
3253        let mut params = BTreeMap::new();
3254        params.insert("WantTags".to_string(), "no".to_string());
3255        let parsed = parse_template(template, &params).unwrap();
3256        let resource = parsed
3257            .resources
3258            .iter()
3259            .find(|r| r.logical_id == "Q")
3260            .unwrap();
3261        // First parse already strips Tags.
3262        assert!(!resource
3263            .properties
3264            .as_object()
3265            .unwrap()
3266            .contains_key("Tags"));
3267
3268        // Re-resolve with empty physical IDs (mid-provisioning). The
3269        // sentinel must still be stripped — no `__fakecloud_aws_no_value__`
3270        // marker should reach the caller.
3271        let reresolved = resolve_resource_properties_with_attrs(
3272            resource,
3273            template,
3274            &params,
3275            &BTreeMap::new(),
3276            &BTreeMap::new(),
3277        )
3278        .unwrap();
3279        let props = reresolved.properties.as_object().unwrap();
3280        assert!(
3281            !props.contains_key("Tags"),
3282            "Tags should be stripped on re-resolve, got: {props:?}"
3283        );
3284        // Sanity: serialized form must not contain the sentinel key.
3285        let serialized = serde_json::to_string(&reresolved.properties).unwrap();
3286        assert!(
3287            !serialized.contains(NO_VALUE_SENTINEL_KEY),
3288            "sentinel leaked: {serialized}"
3289        );
3290    }
3291
3292    // ── BB5: Fn::Select / Split / Base64 / Cidr / Length / ToJsonString / ForEach ──
3293
3294    #[test]
3295    fn fn_select_string_index_resolves() {
3296        // CFN accepts the index as a string literal (`"0"`) — CFN's
3297        // own examples do this, so the engine must coerce.
3298        let (p, r, ids, attrs) = empty();
3299        let v: Value = serde_json::from_str(r#"{"Fn::Select": ["2", ["a", "b", "c", "d"]]}"#)
3300            .expect("static fixture parses");
3301        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3302        assert_eq!(resolved, Value::String("c".to_string()));
3303    }
3304
3305    #[test]
3306    fn fn_select_out_of_range_returns_null() {
3307        let (p, r, ids, attrs) = empty();
3308        let v: Value = serde_json::from_str(r#"{"Fn::Select": [10, ["a", "b"]]}"#)
3309            .expect("static fixture parses");
3310        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3311        assert_eq!(resolved, Value::Null);
3312    }
3313
3314    #[test]
3315    fn fn_select_resolves_ref_inside_list() {
3316        let template = r#"{
3317            "Parameters": {"AZs": {"Type": "CommaDelimitedList"}},
3318            "Resources": {
3319                "Q": {
3320                    "Type": "AWS::SQS::Queue",
3321                    "Properties": {
3322                        "QueueName": {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "AZs"}]}]}
3323                    }
3324                }
3325            }
3326        }"#;
3327        let mut params = BTreeMap::new();
3328        params.insert(
3329            "AZs".to_string(),
3330            "us-east-1a,us-east-1b,us-east-1c".to_string(),
3331        );
3332        let parsed = parse_template(template, &params).unwrap();
3333        assert_eq!(
3334            parsed.resources[0].properties["QueueName"],
3335            Value::String("us-east-1a".to_string())
3336        );
3337    }
3338
3339    #[test]
3340    fn fn_split_empty_delimiter_returns_full_string_split_per_char() {
3341        let (p, r, ids, attrs) = empty();
3342        let v: Value =
3343            serde_json::from_str(r#"{"Fn::Split": ["", "abc"]}"#).expect("static fixture parses");
3344        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3345        // str::split("") yields `["", "a", "b", "c", ""]` in Rust.
3346        // CFN's behavior with empty delimiter is undefined, but match
3347        // the underlying primitive so callers can reason about it.
3348        assert!(resolved.is_array());
3349    }
3350
3351    #[test]
3352    fn fn_split_no_match_returns_single_element_array() {
3353        let (p, r, ids, attrs) = empty();
3354        let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "no-commas-here"]}"#)
3355            .expect("static fixture parses");
3356        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3357        assert_eq!(resolved, serde_json::json!(["no-commas-here"]));
3358    }
3359
3360    #[test]
3361    fn fn_base64_encodes_unicode() {
3362        let (p, r, ids, attrs) = empty();
3363        let v: Value =
3364            serde_json::from_str(r#"{"Fn::Base64": "héllo"}"#).expect("static fixture parses");
3365        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3366        // "héllo" is 6 bytes UTF-8 (h=1, é=2, l=1, l=1, o=1).
3367        assert_eq!(resolved, Value::String("aMOpbGxv".to_string()));
3368    }
3369
3370    #[test]
3371    fn fn_base64_resolves_nested_intrinsic() {
3372        let template = r#"{
3373            "Parameters": {"Greeting": {"Type": "String"}},
3374            "Resources": {
3375                "Q": {
3376                    "Type": "AWS::SQS::Queue",
3377                    "Properties": {
3378                        "QueueName": {"Fn::Base64": {"Ref": "Greeting"}}
3379                    }
3380                }
3381            }
3382        }"#;
3383        let mut params = BTreeMap::new();
3384        params.insert("Greeting".to_string(), "hello".to_string());
3385        let parsed = parse_template(template, &params).unwrap();
3386        assert_eq!(
3387            parsed.resources[0].properties["QueueName"],
3388            Value::String("aGVsbG8=".to_string())
3389        );
3390    }
3391
3392    #[test]
3393    fn fn_length_counts_string_chars() {
3394        let (p, r, ids, attrs) = empty();
3395        let v: Value =
3396            serde_json::from_str(r#"{"Fn::Length": "héllo"}"#).expect("static fixture parses");
3397        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3398        // 5 chars (not 6 bytes) — multibyte counted once.
3399        assert_eq!(resolved, Value::Number(5.into()));
3400    }
3401
3402    #[test]
3403    fn fn_length_resolves_nested_split() {
3404        let (p, r, ids, attrs) = empty();
3405        let v: Value = serde_json::from_str(r#"{"Fn::Length": {"Fn::Split": [",", "a,b,c,d,e"]}}"#)
3406            .expect("static fixture parses");
3407        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3408        assert_eq!(resolved, Value::Number(5.into()));
3409    }
3410
3411    #[test]
3412    fn fn_to_json_string_serializes_array() {
3413        let (p, r, ids, attrs) = empty();
3414        let v: Value = serde_json::from_str(r#"{"Fn::ToJsonString": ["a", "b", "c"]}"#)
3415            .expect("static fixture parses");
3416        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3417        assert_eq!(resolved, Value::String(r#"["a","b","c"]"#.to_string()));
3418    }
3419
3420    #[test]
3421    fn fn_to_json_string_resolves_inner_ref() {
3422        let template = r#"{
3423            "Parameters": {"Name": {"Type": "String"}},
3424            "Resources": {
3425                "Q": {
3426                    "Type": "AWS::SQS::Queue",
3427                    "Properties": {
3428                        "QueueName": {
3429                            "Fn::ToJsonString": {"k": {"Ref": "Name"}}
3430                        }
3431                    }
3432                }
3433            }
3434        }"#;
3435        let mut params = BTreeMap::new();
3436        params.insert("Name".to_string(), "abc".to_string());
3437        let parsed = parse_template(template, &params).unwrap();
3438        assert_eq!(
3439            parsed.resources[0].properties["QueueName"],
3440            Value::String(r#"{"k":"abc"}"#.to_string())
3441        );
3442    }
3443
3444    #[test]
3445    fn fn_cidr_count_matches_request() {
3446        // Real Fn::Cidr returns up to 2^cidr_bits subnets; we ask for 2
3447        // out of a possible 256, so only 2 land in the output.
3448        let (p, r, ids, attrs) = empty();
3449        let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 2, 8]}"#)
3450            .expect("static fixture parses");
3451        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
3452        assert_eq!(resolved, serde_json::json!(["10.0.0.0/24", "10.0.1.0/24"]));
3453    }
3454
3455    #[test]
3456    fn fn_cidr_resolves_via_ref() {
3457        let template = r#"{
3458            "Parameters": {"Vpc": {"Type": "String"}},
3459            "Resources": {
3460                "Q": {
3461                    "Type": "AWS::SQS::Queue",
3462                    "Properties": {
3463                        "QueueName": {"Fn::Select": [
3464                            0,
3465                            {"Fn::Cidr": [{"Ref": "Vpc"}, 4, 8]}
3466                        ]}
3467                    }
3468                }
3469            }
3470        }"#;
3471        let mut params = BTreeMap::new();
3472        params.insert("Vpc".to_string(), "172.16.0.0/16".to_string());
3473        let parsed = parse_template(template, &params).unwrap();
3474        assert_eq!(
3475            parsed.resources[0].properties["QueueName"],
3476            Value::String("172.16.0.0/24".to_string())
3477        );
3478    }
3479
3480    #[test]
3481    fn fn_for_each_expands_resources() {
3482        let template = r#"{
3483            "Resources": {
3484                "Fn::ForEach::TopicLoop": [
3485                    "TopicName",
3486                    ["alpha", "beta", "gamma"],
3487                    {
3488                        "${TopicName}Topic": {
3489                            "Type": "AWS::SNS::Topic",
3490                            "Properties": {"TopicName": "${TopicName}-topic"}
3491                        }
3492                    }
3493                ]
3494            }
3495        }"#;
3496        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3497        let names: Vec<&str> = parsed
3498            .resources
3499            .iter()
3500            .map(|r| r.logical_id.as_str())
3501            .collect();
3502        assert!(names.contains(&"alphaTopic"), "got: {names:?}");
3503        assert!(names.contains(&"betaTopic"), "got: {names:?}");
3504        assert!(names.contains(&"gammaTopic"), "got: {names:?}");
3505        let alpha = parsed
3506            .resources
3507            .iter()
3508            .find(|r| r.logical_id == "alphaTopic")
3509            .unwrap();
3510        assert_eq!(
3511            alpha.properties["TopicName"],
3512            Value::String("alpha-topic".to_string())
3513        );
3514    }
3515
3516    #[test]
3517    fn fn_for_each_substitutes_in_nested_values() {
3518        let template = r#"{
3519            "Resources": {
3520                "Fn::ForEach::Q": [
3521                    "QName",
3522                    ["one", "two"],
3523                    {
3524                        "${QName}Queue": {
3525                            "Type": "AWS::SQS::Queue",
3526                            "Properties": {
3527                                "QueueName": "${QName}",
3528                                "Tags": [
3529                                    {"Key": "name", "Value": "${QName}"}
3530                                ]
3531                            }
3532                        }
3533                    }
3534                ]
3535            }
3536        }"#;
3537        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3538        let one = parsed
3539            .resources
3540            .iter()
3541            .find(|r| r.logical_id == "oneQueue")
3542            .unwrap();
3543        assert_eq!(
3544            one.properties["QueueName"],
3545            Value::String("one".to_string())
3546        );
3547        assert_eq!(
3548            one.properties["Tags"][0]["Value"],
3549            Value::String("one".to_string())
3550        );
3551    }
3552
3553    #[test]
3554    fn fn_for_each_nested_loops_expand_cartesian() {
3555        let template = r#"{
3556            "Resources": {
3557                "Fn::ForEach::Outer": [
3558                    "Env",
3559                    ["dev", "prod"],
3560                    {
3561                        "Fn::ForEach::Inner": [
3562                            "Region",
3563                            ["us-east-1", "eu-west-1"],
3564                            {
3565                                "${Env}${Region}Q": {
3566                                    "Type": "AWS::SQS::Queue",
3567                                    "Properties": {"QueueName": "${Env}-${Region}"}
3568                                }
3569                            }
3570                        ]
3571                    }
3572                ]
3573            }
3574        }"#;
3575        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3576        let names: Vec<&str> = parsed
3577            .resources
3578            .iter()
3579            .map(|r| r.logical_id.as_str())
3580            .collect();
3581        for env in ["dev", "prod"] {
3582            for region in ["us-east-1", "eu-west-1"] {
3583                let expected = format!("{env}{region}Q");
3584                assert!(
3585                    names.contains(&expected.as_str()),
3586                    "missing {expected} in {names:?}"
3587                );
3588            }
3589        }
3590        let dev_us = parsed
3591            .resources
3592            .iter()
3593            .find(|r| r.logical_id == "devus-east-1Q")
3594            .unwrap();
3595        assert_eq!(
3596            dev_us.properties["QueueName"],
3597            Value::String("dev-us-east-1".to_string())
3598        );
3599    }
3600
3601    #[test]
3602    fn fn_for_each_keeps_other_resources_untouched() {
3603        let template = r#"{
3604            "Resources": {
3605                "Static": {
3606                    "Type": "AWS::SQS::Queue",
3607                    "Properties": {"QueueName": "static-q"}
3608                },
3609                "Fn::ForEach::Loop": [
3610                    "I",
3611                    ["a", "b"],
3612                    {
3613                        "${I}Topic": {
3614                            "Type": "AWS::SNS::Topic",
3615                            "Properties": {"TopicName": "${I}"}
3616                        }
3617                    }
3618                ]
3619            }
3620        }"#;
3621        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3622        let names: Vec<&str> = parsed
3623            .resources
3624            .iter()
3625            .map(|r| r.logical_id.as_str())
3626            .collect();
3627        assert!(names.contains(&"Static"));
3628        assert!(names.contains(&"aTopic"));
3629        assert!(names.contains(&"bTopic"));
3630        assert_eq!(parsed.resources.len(), 3);
3631    }
3632
3633    #[test]
3634    fn fn_for_each_invalid_arity_errors() {
3635        let template = r#"{
3636            "Resources": {
3637                "Fn::ForEach::Bad": [
3638                    "Var",
3639                    ["a"]
3640                ]
3641            }
3642        }"#;
3643        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
3644        assert!(err.contains("Fn::ForEach"), "got: {err}");
3645    }
3646
3647    #[test]
3648    fn fn_for_each_resolves_intrinsics_in_emitted_resources() {
3649        // Body of the loop references both the loop variable and a
3650        // stack parameter, exercising that downstream intrinsic
3651        // resolution still runs over emitted resources.
3652        let template = r#"{
3653            "Parameters": {"Env": {"Type": "String"}},
3654            "Resources": {
3655                "Fn::ForEach::Q": [
3656                    "Name",
3657                    ["alpha", "beta"],
3658                    {
3659                        "${Name}Queue": {
3660                            "Type": "AWS::SQS::Queue",
3661                            "Properties": {
3662                                "QueueName": {"Fn::Sub": "${Env}-${Name}"}
3663                            }
3664                        }
3665                    }
3666                ]
3667            }
3668        }"#;
3669        let mut params = BTreeMap::new();
3670        params.insert("Env".to_string(), "prod".to_string());
3671        let parsed = parse_template(template, &params).unwrap();
3672        // ${Name} substitutes at ForEach expansion time; ${Env} comes
3673        // from the parameter at Fn::Sub time. Both must land.
3674        let alpha = parsed
3675            .resources
3676            .iter()
3677            .find(|r| r.logical_id == "alphaQueue")
3678            .unwrap();
3679        assert_eq!(
3680            alpha.properties["QueueName"],
3681            Value::String("prod-alpha".to_string())
3682        );
3683    }
3684
3685    #[test]
3686    fn fn_for_each_re_resolves_at_provision_time() {
3687        // resolve_resource_properties_with_attrs must also expand
3688        // ForEach so the looked-up resource by logical ID matches the
3689        // post-expansion template.
3690        let template = r#"{
3691            "Resources": {
3692                "Fn::ForEach::Q": [
3693                    "Name",
3694                    ["alpha"],
3695                    {
3696                        "${Name}Queue": {
3697                            "Type": "AWS::SQS::Queue",
3698                            "Properties": {"QueueName": "${Name}-q"}
3699                        }
3700                    }
3701                ]
3702            }
3703        }"#;
3704        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3705        let resource = parsed
3706            .resources
3707            .iter()
3708            .find(|r| r.logical_id == "alphaQueue")
3709            .unwrap();
3710        let reresolved = resolve_resource_properties_with_attrs(
3711            resource,
3712            template,
3713            &BTreeMap::new(),
3714            &BTreeMap::new(),
3715            &BTreeMap::new(),
3716        )
3717        .unwrap();
3718        assert_eq!(
3719            reresolved.properties["QueueName"],
3720            Value::String("alpha-q".to_string())
3721        );
3722    }
3723
3724    #[test]
3725    fn fn_for_each_resolves_ref_to_comma_delimited_list_param() {
3726        // CommaDelimitedList parameters are a documented ForEach input
3727        // shape. Stack passes the value as a single string; ForEach
3728        // must split it before iterating.
3729        let template = r#"{
3730            "Parameters": {"Names": {"Type": "CommaDelimitedList"}},
3731            "Resources": {
3732                "Fn::ForEach::Q": [
3733                    "N",
3734                    {"Ref": "Names"},
3735                    {
3736                        "${N}Queue": {
3737                            "Type": "AWS::SQS::Queue",
3738                            "Properties": {"QueueName": "${N}-q"}
3739                        }
3740                    }
3741                ]
3742            }
3743        }"#;
3744        let mut params = BTreeMap::new();
3745        params.insert("Names".to_string(), "alpha,beta,gamma".to_string());
3746        let parsed = parse_template(template, &params).unwrap();
3747        let names: Vec<&str> = parsed
3748            .resources
3749            .iter()
3750            .map(|r| r.logical_id.as_str())
3751            .collect();
3752        for v in ["alphaQueue", "betaQueue", "gammaQueue"] {
3753            assert!(names.contains(&v), "missing {v} in {names:?}");
3754        }
3755    }
3756
3757    #[test]
3758    fn fn_for_each_ampersand_substitution_form() {
3759        // AWS supports `&{Var}` in addition to `${Var}` for ForEach
3760        // loop variable substitution; needed when the surrounding
3761        // template separately uses ${}-style for Fn::Sub.
3762        let template = r#"{
3763            "Resources": {
3764                "Fn::ForEach::Q": [
3765                    "Name",
3766                    ["alpha", "beta"],
3767                    {
3768                        "&{Name}Queue": {
3769                            "Type": "AWS::SQS::Queue",
3770                            "Properties": {"QueueName": "&{Name}"}
3771                        }
3772                    }
3773                ]
3774            }
3775        }"#;
3776        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3777        let names: Vec<&str> = parsed
3778            .resources
3779            .iter()
3780            .map(|r| r.logical_id.as_str())
3781            .collect();
3782        assert!(names.contains(&"alphaQueue"), "got: {names:?}");
3783        assert!(names.contains(&"betaQueue"), "got: {names:?}");
3784        let alpha = parsed
3785            .resources
3786            .iter()
3787            .find(|r| r.logical_id == "alphaQueue")
3788            .unwrap();
3789        assert_eq!(
3790            alpha.properties["QueueName"],
3791            Value::String("alpha".to_string())
3792        );
3793    }
3794
3795    #[test]
3796    fn fn_for_each_in_outputs_expands() {
3797        let template = r#"{
3798            "Resources": {
3799                "Q": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "q"}}
3800            },
3801            "Outputs": {
3802                "Fn::ForEach::OutputLoop": [
3803                    "I",
3804                    ["one", "two"],
3805                    {
3806                        "${I}Out": {"Value": "${I}-value"}
3807                    }
3808                ]
3809            }
3810        }"#;
3811        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
3812        let names: Vec<&str> = parsed
3813            .outputs
3814            .iter()
3815            .map(|o| o.logical_id.as_str())
3816            .collect();
3817        assert!(names.contains(&"oneOut"), "got: {names:?}");
3818        assert!(names.contains(&"twoOut"), "got: {names:?}");
3819        let one = parsed
3820            .outputs
3821            .iter()
3822            .find(|o| o.logical_id == "oneOut")
3823            .unwrap();
3824        assert_eq!(one.value, "one-value");
3825    }
3826}