Skip to main content

fakecloud_cloudformation/template/
parser.rs

1//! `parser` concerns from template.rs (audit-2026-05-19).
2
3use super::*;
4
5/// Parse a CloudFormation template from a string (JSON or YAML).
6pub fn parse_template(
7    template_body: &str,
8    parameters: &BTreeMap<String, String>,
9) -> Result<ParsedTemplate, String> {
10    parse_template_with_physical_ids(template_body, parameters, &BTreeMap::new())
11}
12
13/// Parse a CloudFormation template, resolving Refs using known physical resource IDs.
14pub fn parse_template_with_physical_ids(
15    template_body: &str,
16    parameters: &BTreeMap<String, String>,
17    resource_physical_ids: &BTreeMap<String, String>,
18) -> Result<ParsedTemplate, String> {
19    parse_template_with_resolution(
20        template_body,
21        parameters,
22        resource_physical_ids,
23        &BTreeMap::new(),
24    )
25}
26
27/// Parse a CloudFormation template, resolving `Ref` via `resource_physical_ids`
28/// and `Fn::GetAtt` via `resource_attributes` (keyed by logical id, then
29/// attribute name).
30pub fn parse_template_with_resolution(
31    template_body: &str,
32    parameters: &BTreeMap<String, String>,
33    resource_physical_ids: &BTreeMap<String, String>,
34    resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
35) -> Result<ParsedTemplate, String> {
36    let value: Value = if template_body.trim_start().starts_with('{') {
37        serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
38    } else {
39        serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
40    };
41
42    // Expand `Fn::ForEach::*` macros (template transform). New resources
43    // and properties land in place before the rest of resolution sees the
44    // template, so a ForEach-emitted resource works exactly like a
45    // hand-authored one. Parameters flow in so the items list can be a
46    // `Ref` to a CommaDelimitedList parameter.
47    let value = expand_for_each(&value, &BTreeMap::new(), parameters)?;
48    let value = expand_sam(&value);
49
50    let description = value
51        .get("Description")
52        .and_then(|v| v.as_str())
53        .map(|s| s.to_string());
54
55    let conditions = evaluate_conditions(&value, parameters)?;
56    let mappings = parse_mappings(&value);
57
58    let resources_obj = value
59        .get("Resources")
60        .and_then(|v| v.as_object())
61        .ok_or("Template must contain a Resources section")?;
62
63    let mut resources = Vec::new();
64    for (logical_id, resource) in resources_obj {
65        // Skip resources whose Condition evaluates to false. Real CFN
66        // simply omits these resources from the stack.
67        if let Some(cond_name) = resource.get("Condition").and_then(|v| v.as_str()) {
68            if !conditions.get(cond_name).copied().unwrap_or(false) {
69                continue;
70            }
71        }
72        let resource_type = resource
73            .get("Type")
74            .and_then(|v| v.as_str())
75            .ok_or(format!("Resource {logical_id} must have a Type property"))?
76            .to_string();
77
78        let properties = resource
79            .get("Properties")
80            .cloned()
81            .unwrap_or(Value::Object(serde_json::Map::new()));
82
83        // Pre-resolve Fn::FindInMap before the main intrinsics pass so the
84        // existing resolver doesn't need to thread mappings through. We
85        // pass `conditions` so a FindInMap sitting in an unused Fn::If
86        // branch is skipped (CFN never executes the dropped branch).
87        let properties = apply_mappings(&properties, parameters, &mappings, &conditions)?;
88
89        // Resolve Ref and parameter substitutions in properties
90        let resolved = resolve_refs_full(
91            &properties,
92            parameters,
93            resources_obj,
94            resource_physical_ids,
95            resource_attributes,
96            &BTreeMap::new(),
97            &conditions,
98        );
99        let resolved = strip_no_value(resolved);
100
101        resources.push(ResourceDefinition {
102            logical_id: logical_id.clone(),
103            resource_type,
104            properties: resolved,
105        });
106    }
107
108    let outputs = parse_outputs(
109        &value,
110        parameters,
111        resources_obj,
112        resource_physical_ids,
113        resource_attributes,
114        &BTreeMap::new(),
115    )?;
116
117    Ok(ParsedTemplate {
118        description,
119        resources,
120        outputs,
121    })
122}
123
124/// Walk every `Fn::ImportValue` site in the parsed template (Resources +
125/// Outputs) and collect the static export names it references. Names that
126/// can only be resolved at runtime (e.g. `{ "Fn::Sub": "${Env}-arn" }`)
127/// resolve against `parameters` first; if they still aren't strings,
128/// they're skipped — the runtime resolver will surface the gap then.
129pub fn collect_import_value_names(
130    template: &Value,
131    parameters: &BTreeMap<String, String>,
132) -> Vec<String> {
133    let mut out: Vec<String> = Vec::new();
134    collect_imports_walk(template, parameters, &mut out);
135    out.sort();
136    out.dedup();
137    out
138}
139
140pub(super) fn collect_imports_walk(
141    value: &Value,
142    parameters: &BTreeMap<String, String>,
143    out: &mut Vec<String>,
144) {
145    match value {
146        Value::Object(map) => {
147            if let Some(arg) = map.get("Fn::ImportValue") {
148                if let Some(name) = static_import_name(arg, parameters) {
149                    out.push(name);
150                } else {
151                    // Recurse into the arg in case it contains nested ImportValues.
152                    collect_imports_walk(arg, parameters, out);
153                }
154            }
155            for (k, v) in map {
156                if k == "Fn::ImportValue" {
157                    continue;
158                }
159                collect_imports_walk(v, parameters, out);
160            }
161        }
162        Value::Array(arr) => {
163            for v in arr {
164                collect_imports_walk(v, parameters, out);
165            }
166        }
167        _ => {}
168    }
169}
170
171pub(super) fn static_import_name(
172    value: &Value,
173    parameters: &BTreeMap<String, String>,
174) -> Option<String> {
175    match value {
176        Value::String(s) => Some(s.clone()),
177        Value::Object(m) => {
178            if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
179                return parameters.get(name).cloned();
180            }
181            if let Some(s) = m.get("Fn::Sub").and_then(|v| v.as_str()) {
182                let mut result = s.to_string();
183                for (k, v) in parameters {
184                    result = result.replace(&format!("${{{k}}}"), v);
185                }
186                if !result.contains("${") {
187                    return Some(result);
188                }
189            }
190            None
191        }
192        _ => None,
193    }
194}
195
196/// Parse the template's `Outputs` block into resolved entries. Each
197/// `Value` is fully resolved (Ref / GetAtt / Sub / Join / Fn::ImportValue)
198/// to a string. Imports use `imports` for cross-stack lookups.
199pub fn parse_outputs(
200    template: &Value,
201    parameters: &BTreeMap<String, String>,
202    resources: &serde_json::Map<String, Value>,
203    resource_physical_ids: &BTreeMap<String, String>,
204    resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
205    imports: &BTreeMap<String, String>,
206) -> Result<Vec<TemplateOutput>, String> {
207    // Expand Fn::ForEach in Outputs so resolve picks up macro-emitted
208    // entries. Callers pass the raw template value, which may still
209    // contain unexpanded ForEach macros.
210    let template_owned = expand_for_each(template, &BTreeMap::new(), parameters)?;
211    let template = &template_owned;
212    let outputs_obj = match template.get("Outputs").and_then(|v| v.as_object()) {
213        Some(o) => o,
214        None => return Ok(Vec::new()),
215    };
216
217    let conditions = evaluate_conditions(template, parameters)?;
218    let mut out = Vec::new();
219    for (logical_id, body) in outputs_obj {
220        // Skip outputs gated on a Condition that resolves false. CFN
221        // simply omits these from the resolved Outputs set.
222        if let Some(cond_name) = body.get("Condition").and_then(|v| v.as_str()) {
223            if !conditions.get(cond_name).copied().unwrap_or(false) {
224                continue;
225            }
226        }
227        let raw_value = match body.get("Value") {
228            Some(v) => v,
229            None => continue,
230        };
231        let resolved = resolve_refs_full(
232            raw_value,
233            parameters,
234            resources,
235            resource_physical_ids,
236            resource_attributes,
237            imports,
238            &conditions,
239        );
240        let resolved = strip_no_value(resolved);
241        let value = match resolved {
242            Value::String(s) => s,
243            other => other.to_string(),
244        };
245        let description = body
246            .get("Description")
247            .and_then(|v| v.as_str())
248            .map(|s| s.to_string());
249        let export_name = body.get("Export").and_then(|e| e.get("Name")).map(|n| {
250            let resolved = resolve_refs_full(
251                n,
252                parameters,
253                resources,
254                resource_physical_ids,
255                resource_attributes,
256                imports,
257                &conditions,
258            );
259            match resolved {
260                Value::String(s) => s,
261                other => other.to_string(),
262            }
263        });
264        out.push(TemplateOutput {
265            logical_id: logical_id.clone(),
266            value,
267            description,
268            export_name,
269        });
270    }
271    Ok(out)
272}