use base64::Engine;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
const NO_VALUE_SENTINEL_KEY: &str = "__fakecloud_aws_no_value__";
#[derive(Debug, Clone)]
pub struct ParsedTemplate {
pub description: Option<String>,
pub resources: Vec<ResourceDefinition>,
pub outputs: Vec<TemplateOutput>,
}
#[derive(Debug, Clone)]
pub struct TemplateOutput {
pub logical_id: String,
pub value: String,
pub description: Option<String>,
pub export_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResourceDefinition {
pub logical_id: String,
pub resource_type: String,
pub properties: Value,
}
const PSEUDO_REFS: &[&str] = &[
"AWS::AccountId",
"AWS::NotificationARNs",
"AWS::NoValue",
"AWS::Partition",
"AWS::Region",
"AWS::StackId",
"AWS::StackName",
"AWS::URLSuffix",
];
pub fn parse_template(
template_body: &str,
parameters: &BTreeMap<String, String>,
) -> Result<ParsedTemplate, String> {
parse_template_with_physical_ids(template_body, parameters, &BTreeMap::new())
}
pub fn parse_template_with_physical_ids(
template_body: &str,
parameters: &BTreeMap<String, String>,
resource_physical_ids: &BTreeMap<String, String>,
) -> Result<ParsedTemplate, String> {
parse_template_with_resolution(
template_body,
parameters,
resource_physical_ids,
&BTreeMap::new(),
)
}
pub fn parse_template_with_resolution(
template_body: &str,
parameters: &BTreeMap<String, String>,
resource_physical_ids: &BTreeMap<String, String>,
resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
) -> Result<ParsedTemplate, String> {
let value: Value = if template_body.trim_start().starts_with('{') {
serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
} else {
serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
};
let value = expand_for_each(&value, &BTreeMap::new(), parameters)?;
let value = expand_sam(&value);
let description = value
.get("Description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let conditions = evaluate_conditions(&value, parameters)?;
let mappings = parse_mappings(&value);
let resources_obj = value
.get("Resources")
.and_then(|v| v.as_object())
.ok_or("Template must contain a Resources section")?;
let mut resources = Vec::new();
for (logical_id, resource) in resources_obj {
if let Some(cond_name) = resource.get("Condition").and_then(|v| v.as_str()) {
if !conditions.get(cond_name).copied().unwrap_or(false) {
continue;
}
}
let resource_type = resource
.get("Type")
.and_then(|v| v.as_str())
.ok_or(format!("Resource {logical_id} must have a Type property"))?
.to_string();
let properties = resource
.get("Properties")
.cloned()
.unwrap_or(Value::Object(serde_json::Map::new()));
let properties = apply_mappings(&properties, parameters, &mappings, &conditions)?;
let resolved = resolve_refs_full(
&properties,
parameters,
resources_obj,
resource_physical_ids,
resource_attributes,
&BTreeMap::new(),
&conditions,
);
let resolved = strip_no_value(resolved);
resources.push(ResourceDefinition {
logical_id: logical_id.clone(),
resource_type,
properties: resolved,
});
}
let outputs = parse_outputs(
&value,
parameters,
resources_obj,
resource_physical_ids,
resource_attributes,
&BTreeMap::new(),
)?;
Ok(ParsedTemplate {
description,
resources,
outputs,
})
}
pub fn collect_import_value_names(
template: &Value,
parameters: &BTreeMap<String, String>,
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
collect_imports_walk(template, parameters, &mut out);
out.sort();
out.dedup();
out
}
fn collect_imports_walk(
value: &Value,
parameters: &BTreeMap<String, String>,
out: &mut Vec<String>,
) {
match value {
Value::Object(map) => {
if let Some(arg) = map.get("Fn::ImportValue") {
if let Some(name) = static_import_name(arg, parameters) {
out.push(name);
} else {
collect_imports_walk(arg, parameters, out);
}
}
for (k, v) in map {
if k == "Fn::ImportValue" {
continue;
}
collect_imports_walk(v, parameters, out);
}
}
Value::Array(arr) => {
for v in arr {
collect_imports_walk(v, parameters, out);
}
}
_ => {}
}
}
fn static_import_name(value: &Value, parameters: &BTreeMap<String, String>) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Object(m) => {
if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
return parameters.get(name).cloned();
}
if let Some(s) = m.get("Fn::Sub").and_then(|v| v.as_str()) {
let mut result = s.to_string();
for (k, v) in parameters {
result = result.replace(&format!("${{{k}}}"), v);
}
if !result.contains("${") {
return Some(result);
}
}
None
}
_ => None,
}
}
pub fn parse_outputs(
template: &Value,
parameters: &BTreeMap<String, String>,
resources: &serde_json::Map<String, Value>,
resource_physical_ids: &BTreeMap<String, String>,
resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
imports: &BTreeMap<String, String>,
) -> Result<Vec<TemplateOutput>, String> {
let template_owned = expand_for_each(template, &BTreeMap::new(), parameters)?;
let template = &template_owned;
let outputs_obj = match template.get("Outputs").and_then(|v| v.as_object()) {
Some(o) => o,
None => return Ok(Vec::new()),
};
let conditions = evaluate_conditions(template, parameters)?;
let mut out = Vec::new();
for (logical_id, body) in outputs_obj {
if let Some(cond_name) = body.get("Condition").and_then(|v| v.as_str()) {
if !conditions.get(cond_name).copied().unwrap_or(false) {
continue;
}
}
let raw_value = match body.get("Value") {
Some(v) => v,
None => continue,
};
let resolved = resolve_refs_full(
raw_value,
parameters,
resources,
resource_physical_ids,
resource_attributes,
imports,
&conditions,
);
let resolved = strip_no_value(resolved);
let value = match resolved {
Value::String(s) => s,
other => other.to_string(),
};
let description = body
.get("Description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let export_name = body.get("Export").and_then(|e| e.get("Name")).map(|n| {
let resolved = resolve_refs_full(
n,
parameters,
resources,
resource_physical_ids,
resource_attributes,
imports,
&conditions,
);
match resolved {
Value::String(s) => s,
other => other.to_string(),
}
});
out.push(TemplateOutput {
logical_id: logical_id.clone(),
value,
description,
export_name,
});
}
Ok(out)
}
fn evaluate_conditions(
template: &Value,
parameters: &BTreeMap<String, String>,
) -> Result<BTreeMap<String, bool>, String> {
let mut memo: BTreeMap<String, bool> = BTreeMap::new();
let Some(conds) = template.get("Conditions").and_then(|v| v.as_object()) else {
return Ok(memo);
};
let mut in_progress: BTreeSet<String> = BTreeSet::new();
let names: Vec<String> = conds.keys().cloned().collect();
for name in names {
evaluate_condition_named(&name, conds, parameters, &mut memo, &mut in_progress)?;
}
Ok(memo)
}
fn evaluate_condition_named(
name: &str,
conds: &serde_json::Map<String, Value>,
parameters: &BTreeMap<String, String>,
memo: &mut BTreeMap<String, bool>,
in_progress: &mut BTreeSet<String>,
) -> Result<bool, String> {
if let Some(b) = memo.get(name) {
return Ok(*b);
}
if !in_progress.insert(name.to_string()) {
return Err(format!(
"Circular reference in Conditions: '{name}' transitively references itself"
));
}
let expr = conds.get(name).ok_or_else(|| {
format!("Condition '{name}' is referenced but not defined in Conditions block")
})?;
let result = eval_condition_expr(expr, conds, parameters, memo, in_progress)?;
in_progress.remove(name);
memo.insert(name.to_string(), result);
Ok(result)
}
type Mappings = BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>;
fn parse_mappings(template: &Value) -> Mappings {
let mut out: Mappings = BTreeMap::new();
let Some(maps) = template.get("Mappings").and_then(|v| v.as_object()) else {
return out;
};
for (map_name, top) in maps {
let Some(top_obj) = top.as_object() else {
continue;
};
let mut top_out = BTreeMap::new();
for (top_key, second) in top_obj {
let Some(second_obj) = second.as_object() else {
continue;
};
let mut second_out: BTreeMap<String, Value> = BTreeMap::new();
for (k, v) in second_obj {
second_out.insert(k.clone(), v.clone());
}
top_out.insert(top_key.clone(), second_out);
}
out.insert(map_name.clone(), top_out);
}
out
}
fn eval_condition_expr(
expr: &Value,
conds: &serde_json::Map<String, Value>,
parameters: &BTreeMap<String, String>,
memo: &mut BTreeMap<String, bool>,
in_progress: &mut BTreeSet<String>,
) -> Result<bool, String> {
if let Some(b) = expr.as_bool() {
return Ok(b);
}
let map = expr
.as_object()
.ok_or_else(|| format!("Invalid condition expression: {expr}"))?;
if let Some(args) = map.get("Fn::Equals").and_then(|v| v.as_array()) {
if args.len() != 2 {
return Err("Fn::Equals requires exactly 2 arguments".to_string());
}
let a = stringify_value(&args[0], parameters);
let b = stringify_value(&args[1], parameters);
return Ok(a == b);
}
if let Some(args) = map.get("Fn::And").and_then(|v| v.as_array()) {
if !(1..=10).contains(&args.len()) {
return Err("Fn::And requires between 1 and 10 conditions".to_string());
}
for a in args {
if !eval_condition_expr(a, conds, parameters, memo, in_progress)? {
return Ok(false);
}
}
return Ok(true);
}
if let Some(args) = map.get("Fn::Or").and_then(|v| v.as_array()) {
if !(1..=10).contains(&args.len()) {
return Err("Fn::Or requires between 1 and 10 conditions".to_string());
}
for a in args {
if eval_condition_expr(a, conds, parameters, memo, in_progress)? {
return Ok(true);
}
}
return Ok(false);
}
if let Some(arr) = map.get("Fn::Not").and_then(|v| v.as_array()) {
if arr.len() != 1 {
return Err("Fn::Not requires exactly 1 argument".to_string());
}
return Ok(!eval_condition_expr(
&arr[0],
conds,
parameters,
memo,
in_progress,
)?);
}
if let Some(name) = map.get("Condition").and_then(|v| v.as_str()) {
return evaluate_condition_named(name, conds, parameters, memo, in_progress);
}
Err(format!("Unknown condition operator in expression: {expr}"))
}
fn stringify_value(value: &Value, parameters: &BTreeMap<String, String>) -> String {
match value {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Object(m) => {
if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
if let Some(p) = parameters.get(name) {
return p.clone();
}
return name.to_string();
}
value.to_string()
}
_ => value.to_string(),
}
}
fn expand_for_each(
value: &Value,
bindings: &BTreeMap<String, String>,
parameters: &BTreeMap<String, String>,
) -> Result<Value, String> {
match value {
Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
if let Some(loop_name) = k.strip_prefix("Fn::ForEach::") {
let arr = v.as_array().ok_or_else(|| {
format!("Fn::ForEach::{loop_name} requires an array argument")
})?;
if arr.len() != 3 {
return Err(format!(
"Fn::ForEach::{loop_name} requires 3 arguments (loopVar, list, template), got {}",
arr.len()
));
}
let loop_var = arr[0].as_str().ok_or_else(|| {
format!("Fn::ForEach::{loop_name} loop variable must be a string")
})?;
let items_owned: Vec<Value> =
resolve_for_each_items(&arr[1], parameters).ok_or_else(|| {
format!(
"Fn::ForEach::{loop_name} second argument must be an array or a Ref to a CommaDelimitedList parameter"
)
})?;
let body = arr[2].as_object().ok_or_else(|| {
format!("Fn::ForEach::{loop_name} third argument must be an object")
})?;
for item in &items_owned {
let item_str = match item {
Value::String(s) => s.clone(),
other => other.to_string(),
};
let mut next = bindings.clone();
next.insert(loop_var.to_string(), item_str.clone());
let body_value = Value::Object(body.clone());
let substituted = substitute_loop_vars_in_value(&body_value, &next);
let expanded = expand_for_each(&substituted, &next, parameters)?;
if let Value::Object(emitted) = expanded {
for (ek, ev) in emitted {
out.insert(ek, ev);
}
}
}
continue;
}
out.insert(k.clone(), expand_for_each(v, bindings, parameters)?);
}
Ok(Value::Object(out))
}
Value::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
out.push(expand_for_each(v, bindings, parameters)?);
}
Ok(Value::Array(out))
}
other => Ok(other.clone()),
}
}
fn expand_sam(value: &Value) -> Value {
let transform = value.get("Transform");
let has_sam = match transform {
Some(Value::String(s)) => s == "AWS::Serverless-2016-10-31",
Some(Value::Array(arr)) => arr
.iter()
.any(|v| v.as_str() == Some("AWS::Serverless-2016-10-31")),
_ => false,
};
if !has_sam {
return value.clone();
}
let mut value = value.clone();
let Some(resources) = value.get_mut("Resources") else {
return value;
};
let Some(resources_map) = resources.as_object_mut() else {
return value;
};
let mut new_resources = serde_json::Map::new();
for (logical_id, resource) in resources_map.iter() {
let Some(resource_obj) = resource.as_object() else {
new_resources.insert(logical_id.clone(), resource.clone());
continue;
};
let Some(ty) = resource_obj.get("Type").and_then(|v| v.as_str()) else {
new_resources.insert(logical_id.clone(), resource.clone());
continue;
};
let properties = resource_obj
.get("Properties")
.cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
match ty {
"AWS::Serverless::Function" => {
let mut lambda_props = if let Some(p) = properties.as_object() {
p.clone()
} else {
serde_json::Map::new()
};
if let Some(code_uri) = lambda_props.get("CodeUri").cloned() {
lambda_props.remove("CodeUri");
let code = if let Some(s) = code_uri.as_str() {
if let Some(stripped) = s.strip_prefix("s3://") {
let parts: Vec<&str> = stripped.splitn(2, '/').collect();
if parts.len() == 2 {
json!({"S3Bucket": parts[0], "S3Key": parts[1]})
} else {
json!({"S3Bucket": "sam", "S3Key": s})
}
} else {
json!({"S3Bucket": "sam", "S3Key": s})
}
} else {
code_uri
};
lambda_props.insert("Code".to_string(), code);
} else if let Some(inline) = lambda_props.get("InlineCode").cloned() {
lambda_props.remove("InlineCode");
lambda_props.insert("Code".to_string(), json!({"ZipFile": inline}));
}
let mut lambda_resource = serde_json::Map::new();
lambda_resource.insert("Type".to_string(), json!("AWS::Lambda::Function"));
lambda_resource.insert("Properties".to_string(), Value::Object(lambda_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
lambda_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(lambda_resource));
}
"AWS::Serverless::Api" => {
let mut api_props = if let Some(p) = properties.as_object() {
p.clone()
} else {
serde_json::Map::new()
};
if let Some(def) = api_props.get("DefinitionBody").cloned() {
api_props.remove("DefinitionBody");
api_props.insert("Body".to_string(), def);
}
let mut api_resource = serde_json::Map::new();
api_resource.insert("Type".to_string(), json!("AWS::ApiGateway::RestApi"));
api_resource.insert("Properties".to_string(), Value::Object(api_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
api_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(api_resource));
}
"AWS::Serverless::HttpApi" => {
let mut httpapi_resource = serde_json::Map::new();
httpapi_resource.insert("Type".to_string(), json!("AWS::ApiGatewayV2::Api"));
httpapi_resource.insert("Properties".to_string(), properties);
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
httpapi_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(httpapi_resource));
}
"AWS::Serverless::SimpleTable" => {
let mut table_props = if let Some(p) = properties.as_object() {
p.clone()
} else {
serde_json::Map::new()
};
if let Some(pk) = table_props.get("PrimaryKey") {
if let Some(pk_obj) = pk.as_object() {
let name = pk_obj.get("Name").cloned().unwrap_or_else(|| json!("id"));
let ty = match pk_obj.get("Type").and_then(|v| v.as_str()) {
Some("String") => json!("S"),
Some("Number") => json!("N"),
Some("Binary") => json!("B"),
Some(other) => json!(other),
None => json!("S"),
};
table_props.remove("PrimaryKey");
table_props.insert(
"KeySchema".to_string(),
json!([{"AttributeName": name.clone(), "KeyType": "HASH"}]),
);
table_props.insert(
"AttributeDefinitions".to_string(),
json!([{"AttributeName": name, "AttributeType": ty}]),
);
}
}
if !table_props.contains_key("BillingMode") {
table_props.insert("BillingMode".to_string(), json!("PAY_PER_REQUEST"));
}
let mut table_resource = serde_json::Map::new();
table_resource.insert("Type".to_string(), json!("AWS::DynamoDB::Table"));
table_resource.insert("Properties".to_string(), Value::Object(table_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
table_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(table_resource));
}
"AWS::Serverless::LayerVersion" => {
let mut layer_props = if let Some(p) = properties.as_object() {
p.clone()
} else {
serde_json::Map::new()
};
if let Some(uri) = layer_props.get("ContentUri").cloned() {
layer_props.remove("ContentUri");
let content = if let Some(s) = uri.as_str() {
if let Some(stripped) = s.strip_prefix("s3://") {
let parts: Vec<&str> = stripped.splitn(2, '/').collect();
if parts.len() == 2 {
json!({"S3Bucket": parts[0], "S3Key": parts[1]})
} else {
json!({"S3Bucket": "sam", "S3Key": s})
}
} else {
json!({"S3Bucket": "sam", "S3Key": s})
}
} else {
uri
};
layer_props.insert("Content".to_string(), content);
}
let mut layer_resource = serde_json::Map::new();
layer_resource.insert("Type".to_string(), json!("AWS::Lambda::LayerVersion"));
layer_resource.insert("Properties".to_string(), Value::Object(layer_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
layer_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(layer_resource));
}
_ => {
new_resources.insert(logical_id.clone(), resource.clone());
}
}
}
resources_map.clear();
for (k, v) in new_resources {
resources_map.insert(k, v);
}
value
}
fn resolve_for_each_items(
value: &Value,
parameters: &BTreeMap<String, String>,
) -> Option<Vec<Value>> {
if let Some(arr) = value.as_array() {
return Some(arr.clone());
}
if let Some(map) = value.as_object() {
if let Some(name) = map.get("Ref").and_then(|v| v.as_str()) {
let raw = parameters.get(name)?;
return Some(
raw.split(',')
.map(|p| Value::String(p.trim().to_string()))
.collect(),
);
}
}
None
}
fn substitute_loop_vars(s: &str, bindings: &BTreeMap<String, String>) -> String {
let mut result = s.to_string();
for (k, v) in bindings {
result = result.replace(&format!("${{{k}}}"), v);
result = result.replace(&format!("&{{{k}}}"), v);
}
result
}
fn substitute_loop_vars_in_value(value: &Value, bindings: &BTreeMap<String, String>) -> Value {
match value {
Value::String(s) => Value::String(substitute_loop_vars(s, bindings)),
Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
let new_key = substitute_loop_vars(k, bindings);
out.insert(new_key, substitute_loop_vars_in_value(v, bindings));
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(
arr.iter()
.map(|v| substitute_loop_vars_in_value(v, bindings))
.collect(),
),
other => other.clone(),
}
}
fn apply_mappings(
value: &Value,
parameters: &BTreeMap<String, String>,
mappings: &Mappings,
conditions: &BTreeMap<String, bool>,
) -> Result<Value, String> {
match value {
Value::Object(map) => {
if let Some(arr) = map.get("Fn::If").and_then(|v| v.as_array()) {
if arr.len() == 3 {
let cond_name = arr[0].as_str().unwrap_or("");
if let Some(picked_idx) =
conditions
.get(cond_name)
.copied()
.map(|b| if b { 1 } else { 2 })
{
let mut new_arr = arr.clone();
new_arr[picked_idx] =
apply_mappings(&arr[picked_idx], parameters, mappings, conditions)?;
let mut rewritten = serde_json::Map::new();
rewritten.insert("Fn::If".to_string(), Value::Array(new_arr));
return Ok(Value::Object(rewritten));
}
}
}
if let Some(arr) = map.get("Fn::FindInMap").and_then(|v| v.as_array()) {
return resolve_find_in_map(arr, parameters, mappings, conditions);
}
let mut new_map = serde_json::Map::new();
for (k, v) in map {
new_map.insert(
k.clone(),
apply_mappings(v, parameters, mappings, conditions)?,
);
}
Ok(Value::Object(new_map))
}
Value::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
out.push(apply_mappings(v, parameters, mappings, conditions)?);
}
Ok(Value::Array(out))
}
other => Ok(other.clone()),
}
}
fn resolve_find_in_map(
arr: &[Value],
parameters: &BTreeMap<String, String>,
mappings: &Mappings,
conditions: &BTreeMap<String, bool>,
) -> Result<Value, String> {
if arr.len() != 3 && arr.len() != 4 {
return Err(format!(
"Fn::FindInMap requires 3 or 4 arguments, got {}",
arr.len()
));
}
let default_value: Option<Value> = if arr.len() == 4 {
let opts = arr[3].as_object().ok_or_else(|| {
"Fn::FindInMap fourth argument must be an object with a DefaultValue key".to_string()
})?;
let dv = opts.get("DefaultValue").ok_or_else(|| {
"Fn::FindInMap fourth argument must contain a DefaultValue key".to_string()
})?;
Some(apply_mappings(dv, parameters, mappings, conditions)?)
} else {
None
};
let map_name = stringify_findinmap_arg(&arr[0], parameters, mappings, conditions)?;
let top_key = stringify_findinmap_arg(&arr[1], parameters, mappings, conditions)?;
let second_key = stringify_findinmap_arg(&arr[2], parameters, mappings, conditions)?;
if let Some(top) = mappings.get(&map_name) {
if let Some(second) = top.get(&top_key) {
if let Some(leaf) = second.get(&second_key) {
return Ok(leaf.clone());
}
}
}
if let Some(dv) = default_value {
return Ok(dv);
}
Err(format!(
"Template error: Unable to get mapping for {map_name}::{top_key}::{second_key}"
))
}
fn stringify_findinmap_arg(
value: &Value,
parameters: &BTreeMap<String, String>,
mappings: &Mappings,
conditions: &BTreeMap<String, bool>,
) -> Result<String, String> {
match value {
Value::String(s) => Ok(s.clone()),
Value::Object(m) => {
if let Some(name) = m.get("Ref").and_then(|v| v.as_str()) {
if let Some(p) = parameters.get(name) {
return Ok(p.clone());
}
if let Some(Value::String(s)) = pseudo_value(name, parameters) {
return Ok(s);
}
return Ok(name.to_string());
}
if let Some(arr) = m.get("Fn::FindInMap").and_then(|v| v.as_array()) {
let resolved = resolve_find_in_map(arr, parameters, mappings, conditions)?;
return Ok(match resolved {
Value::String(s) => s,
other => other.to_string(),
});
}
Ok(value.to_string())
}
_ => Ok(value.to_string()),
}
}
pub fn resolve_resource_properties(
resource: &ResourceDefinition,
template_body: &str,
parameters: &BTreeMap<String, String>,
resource_physical_ids: &BTreeMap<String, String>,
) -> Result<ResourceDefinition, String> {
resolve_resource_properties_with_attrs(
resource,
template_body,
parameters,
resource_physical_ids,
&BTreeMap::new(),
)
}
pub fn resolve_resource_properties_with_attrs(
resource: &ResourceDefinition,
template_body: &str,
parameters: &BTreeMap<String, String>,
resource_physical_ids: &BTreeMap<String, String>,
resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
) -> Result<ResourceDefinition, String> {
let value: Value = if template_body.trim_start().starts_with('{') {
serde_json::from_str(template_body).map_err(|e| format!("Invalid JSON template: {e}"))?
} else {
serde_yaml::from_str(template_body).map_err(|e| format!("Invalid YAML template: {e}"))?
};
let value = expand_for_each(&value, &BTreeMap::new(), parameters)?;
let resources_obj = value
.get("Resources")
.and_then(|v| v.as_object())
.ok_or("Template must contain a Resources section")?;
let raw_props = resources_obj
.get(&resource.logical_id)
.and_then(|r| r.get("Properties"))
.cloned()
.unwrap_or(Value::Object(serde_json::Map::new()));
let conditions = evaluate_conditions(&value, parameters)?;
let mappings = parse_mappings(&value);
let raw_props = apply_mappings(&raw_props, parameters, &mappings, &conditions)?;
let resolved = resolve_refs_full(
&raw_props,
parameters,
resources_obj,
resource_physical_ids,
resource_attributes,
&BTreeMap::new(),
&conditions,
);
let resolved = strip_no_value(resolved);
Ok(ResourceDefinition {
logical_id: resource.logical_id.clone(),
resource_type: resource.resource_type.clone(),
properties: resolved,
})
}
fn pseudo_value(name: &str, parameters: &BTreeMap<String, String>) -> Option<Value> {
if name == "AWS::NotificationARNs" {
if let Some(raw) = parameters.get(name) {
if let Ok(parsed) = serde_json::from_str::<Vec<String>>(raw) {
return Some(Value::Array(
parsed.into_iter().map(Value::String).collect(),
));
}
}
return Some(Value::Array(Vec::new()));
}
if let Some(v) = parameters.get(name) {
return Some(Value::String(v.clone()));
}
let region = parameters
.get("AWS::Region")
.map(String::as_str)
.unwrap_or("us-east-1");
match name {
"AWS::Partition" => Some(Value::String(partition_for_region(region).to_string())),
"AWS::URLSuffix" => Some(Value::String(url_suffix_for_region(region).to_string())),
"AWS::Region" => Some(Value::String(region.to_string())),
"AWS::NoValue" => Some(no_value_sentinel()),
_ => None,
}
}
pub(crate) fn partition_for_region(region: &str) -> &'static str {
if region.starts_with("cn-") {
"aws-cn"
} else if region.starts_with("us-gov-") {
"aws-us-gov"
} else {
"aws"
}
}
pub(crate) fn url_suffix_for_region(region: &str) -> &'static str {
if region.starts_with("cn-") {
"amazonaws.com.cn"
} else {
"amazonaws.com"
}
}
fn no_value_sentinel() -> Value {
let mut m = serde_json::Map::new();
m.insert(NO_VALUE_SENTINEL_KEY.to_string(), Value::Bool(true));
Value::Object(m)
}
fn is_no_value(value: &Value) -> bool {
value
.as_object()
.map(|m| m.len() == 1 && m.contains_key(NO_VALUE_SENTINEL_KEY))
.unwrap_or(false)
}
fn strip_no_value(value: Value) -> Value {
match value {
Value::Object(map) => {
if is_no_value(&Value::Object(map.clone())) {
return Value::Null;
}
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
if is_no_value(&v) {
continue;
}
out.insert(k, strip_no_value(v));
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(
arr.into_iter()
.filter(|v| !is_no_value(v))
.map(strip_no_value)
.collect(),
),
other => other,
}
}
#[cfg(test)]
fn resolve_refs(
value: &Value,
parameters: &BTreeMap<String, String>,
_resources: &serde_json::Map<String, Value>,
resource_physical_ids: &BTreeMap<String, String>,
resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
) -> Value {
resolve_refs_full(
value,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
&BTreeMap::new(),
&BTreeMap::new(),
)
}
fn resolve_refs_full(
value: &Value,
parameters: &BTreeMap<String, String>,
_resources: &serde_json::Map<String, Value>,
resource_physical_ids: &BTreeMap<String, String>,
resource_attributes: &BTreeMap<String, BTreeMap<String, String>>,
imports: &BTreeMap<String, String>,
conditions: &BTreeMap<String, bool>,
) -> Value {
if let Some(map) = value.as_object() {
if let Some(arr) = map.get("Fn::If").and_then(|v| v.as_array()) {
if arr.len() == 3 {
let cond_name = arr[0].as_str().unwrap_or("");
let picked = if conditions.get(cond_name).copied().unwrap_or(false) {
&arr[1]
} else {
&arr[2]
};
return resolve_refs_full(
picked,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
}
}
}
match value {
Value::Object(map) => {
if let Some(ref_val) = map.get("Ref") {
if let Some(ref_name) = ref_val.as_str() {
if PSEUDO_REFS.contains(&ref_name) {
if let Some(v) = pseudo_value(ref_name, parameters) {
return v;
}
return Value::String(ref_name.to_string());
}
if let Some(param_val) = parameters.get(ref_name) {
return Value::String(param_val.clone());
}
if let Some(physical_id) = resource_physical_ids.get(ref_name) {
return Value::String(physical_id.clone());
}
if _resources.contains_key(ref_name) {
return Value::String(ref_name.to_string());
}
return Value::String(ref_name.to_string());
}
}
if let Some(import_val) = map.get("Fn::ImportValue") {
let resolved = resolve_refs_full(
import_val,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let key = match &resolved {
Value::String(s) => s.clone(),
other => other.to_string(),
};
if let Some(v) = imports.get(&key) {
return Value::String(v.clone());
}
return Value::String(String::new());
}
if let Some(getatt_val) = map.get("Fn::GetAtt") {
if let Some((logical_id, attr_name)) = parse_getatt(getatt_val) {
if let Some(attrs) = resource_attributes.get(&logical_id) {
if let Some(attr_value) = attrs.get(&attr_name) {
return Value::String(attr_value.clone());
}
}
return Value::String(format!("{logical_id}.{attr_name}"));
}
}
if let Some(join_val) = map.get("Fn::Join") {
if let Some(arr) = join_val.as_array() {
if arr.len() == 2 {
let delimiter = arr[0].as_str().unwrap_or("");
if let Some(parts) = arr[1].as_array() {
let resolved_parts: Vec<String> = parts
.iter()
.map(|p| {
let resolved = resolve_refs_full(
p,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
match resolved {
Value::String(s) => s,
other => other.to_string(),
}
})
.collect();
return Value::String(resolved_parts.join(delimiter));
}
}
}
}
if let Some(b64_val) = map.get("Fn::Base64") {
let resolved = resolve_refs_full(
b64_val,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let s = match &resolved {
Value::String(s) => s.clone(),
other => other.to_string(),
};
return Value::String(
base64::engine::general_purpose::STANDARD.encode(s.as_bytes()),
);
}
if let Some(len_val) = map.get("Fn::Length") {
let resolved = resolve_refs_full(
len_val,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let n: usize = match &resolved {
Value::Array(arr) => arr.len(),
Value::String(s) => s.chars().count(),
_ => 0,
};
return Value::Number(serde_json::Number::from(n));
}
if let Some(to_json) = map.get("Fn::ToJsonString") {
let resolved = resolve_refs_full(
to_json,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let s = serde_json::to_string(&resolved).unwrap_or_default();
return Value::String(s);
}
if let Some(split_val) = map.get("Fn::Split") {
if let Some(arr) = split_val.as_array() {
if arr.len() == 2 {
let delim = arr[0].as_str().unwrap_or("");
let src_resolved = resolve_refs_full(
&arr[1],
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let src = match src_resolved {
Value::String(s) => s,
other => other.to_string(),
};
let parts: Vec<Value> = src
.split(delim)
.map(|p| Value::String(p.to_string()))
.collect();
return Value::Array(parts);
}
}
}
if let Some(sel_val) = map.get("Fn::Select") {
if let Some(arr) = sel_val.as_array() {
if arr.len() == 2 {
let idx_val = resolve_refs_full(
&arr[0],
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let list_val = resolve_refs_full(
&arr[1],
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let idx: usize = match &idx_val {
Value::Number(n) => n.as_u64().unwrap_or(0) as usize,
Value::String(s) => s.parse().unwrap_or(0),
_ => 0,
};
if let Some(list) = list_val.as_array() {
if let Some(elt) = list.get(idx) {
return elt.clone();
}
}
return Value::Null;
}
}
}
if let Some(cidr_val) = map.get("Fn::Cidr") {
if let Some(arr) = cidr_val.as_array() {
if arr.len() == 3 {
let block_val = resolve_refs_full(
&arr[0],
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let count_val = resolve_refs_full(
&arr[1],
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let bits_val = resolve_refs_full(
&arr[2],
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let block_str = match &block_val {
Value::String(s) => s.clone(),
other => other.to_string(),
};
let count: u32 = match &count_val {
Value::Number(n) => n.as_u64().unwrap_or(0) as u32,
Value::String(s) => s.parse().unwrap_or(0),
_ => 0,
};
let cidr_bits: u32 = match &bits_val {
Value::Number(n) => n.as_u64().unwrap_or(0) as u32,
Value::String(s) => s.parse().unwrap_or(0),
_ => 0,
};
if let Some(sub_cidrs) = compute_cidr_subnets(&block_str, count, cidr_bits)
{
return Value::Array(
sub_cidrs.into_iter().map(Value::String).collect(),
);
}
}
}
}
if let Some(sub_val) = map.get("Fn::Sub") {
let (template_str, extra_vars): (Option<&str>, BTreeMap<String, String>) =
if let Some(s) = sub_val.as_str() {
(Some(s), BTreeMap::new())
} else if let Some(arr) = sub_val.as_array() {
let str_part = arr.first().and_then(|v| v.as_str());
let mut bindings: BTreeMap<String, String> = BTreeMap::new();
if let Some(obj) = arr.get(1).and_then(|v| v.as_object()) {
for (k, v) in obj {
let resolved = resolve_refs_full(
v,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
);
let s = match resolved {
Value::String(s) => s,
other => other.to_string(),
};
bindings.insert(k.clone(), s);
}
}
(str_part, bindings)
} else {
(None, BTreeMap::new())
};
if let Some(s) = template_str {
let mut result = s.to_string();
for (k, v) in &extra_vars {
result = result.replace(&format!("${{{k}}}"), v);
}
for pseudo in PSEUDO_REFS {
let token = format!("${{{pseudo}}}");
if !result.contains(&token) {
continue;
}
if *pseudo == "AWS::NoValue" {
result = result.replace(&token, "");
continue;
}
if let Some(v) = pseudo_value(pseudo, parameters) {
let s = match v {
Value::String(s) => s,
other => other.to_string(),
};
result = result.replace(&token, &s);
}
}
for (k, v) in parameters {
result = result.replace(&format!("${{{k}}}"), v);
}
for (k, v) in resource_physical_ids {
result = result.replace(&format!("${{{k}}}"), v);
}
for (logical, attrs) in resource_attributes {
for (attr, value) in attrs {
result = result.replace(&format!("${{{logical}.{attr}}}"), value);
}
}
return Value::String(result);
}
}
let mut new_map = serde_json::Map::new();
for (k, v) in map {
new_map.insert(
k.clone(),
resolve_refs_full(
v,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
),
);
}
Value::Object(new_map)
}
Value::Array(arr) => Value::Array(
arr.iter()
.map(|v| {
resolve_refs_full(
v,
parameters,
_resources,
resource_physical_ids,
resource_attributes,
imports,
conditions,
)
})
.collect(),
),
other => other.clone(),
}
}
fn compute_cidr_subnets(ip_block: &str, count: u32, cidr_bits: u32) -> Option<Vec<String>> {
let (ip_str, prefix_str) = ip_block.split_once('/')?;
let prefix: u32 = prefix_str.parse().ok()?;
let ip: std::net::Ipv4Addr = ip_str.parse().ok()?;
let base: u32 = ip.into();
let new_prefix = 32u32.checked_sub(cidr_bits)?;
if new_prefix <= prefix {
return None;
}
let step: u32 = 1u32 << cidr_bits;
let mut out = Vec::with_capacity(count as usize);
for i in 0..count {
let subnet_base = base.checked_add(step.checked_mul(i)?)?;
let addr = std::net::Ipv4Addr::from(subnet_base);
out.push(format!("{addr}/{new_prefix}"));
}
Some(out)
}
fn parse_getatt(value: &Value) -> Option<(String, String)> {
match value {
Value::Array(arr) if arr.len() >= 2 => {
let logical_id = arr[0].as_str()?.to_string();
let parts: Vec<String> = arr[1..]
.iter()
.map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect();
Some((logical_id, parts.join(".")))
}
Value::String(s) => {
let (logical_id, attr) = s.split_once('.')?;
Some((logical_id.to_string(), attr.to_string()))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_json_template() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "test-queue"
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(parsed.resources.len(), 1);
assert_eq!(parsed.resources[0].logical_id, "MyQueue");
assert_eq!(parsed.resources[0].resource_type, "AWS::SQS::Queue");
}
#[test]
fn parse_yaml_template() {
let template = r#"
Resources:
MyTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: test-topic
"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(parsed.resources.len(), 1);
assert_eq!(parsed.resources[0].logical_id, "MyTopic");
assert_eq!(parsed.resources[0].resource_type, "AWS::SNS::Topic");
}
#[test]
fn resolve_ref_parameters() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": { "Ref": "QueueNameParam" }
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("QueueNameParam".to_string(), "resolved-queue".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("resolved-queue".to_string())
);
}
#[test]
fn ref_resolves_physical_id_over_logical_id() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "my-topic"
}
},
"MySub": {
"Type": "AWS::SNS::Subscription",
"Properties": {
"TopicArn": { "Ref": "MyTopic" },
"Protocol": "sqs",
"Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
}
}
}
}"#;
let mut physical_ids = BTreeMap::new();
physical_ids.insert(
"MyTopic".to_string(),
"arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
);
let parsed =
parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
let sub = parsed
.resources
.iter()
.find(|r| r.logical_id == "MySub")
.unwrap();
assert_eq!(
sub.properties["TopicArn"],
Value::String("arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
);
}
#[test]
fn ref_without_physical_id_returns_logical_id_for_known_resource() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "my-topic"
}
},
"MySub": {
"Type": "AWS::SNS::Subscription",
"Properties": {
"TopicArn": { "Ref": "MyTopic" },
"Protocol": "sqs",
"Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let sub = parsed
.resources
.iter()
.find(|r| r.logical_id == "MySub")
.unwrap();
assert_eq!(
sub.properties["TopicArn"],
Value::String("MyTopic".to_string())
);
}
#[test]
fn pseudo_ref_substitutes_when_param_provided() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueArn": {
"Fn::Join": ["", [
"arn:", {"Ref": "AWS::Partition"}, ":sqs:",
{"Ref": "AWS::Region"}, ":", {"Ref": "AWS::AccountId"},
":", {"Ref": "AWS::StackName"}, "-q"
]]
}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "us-west-2".to_string());
params.insert("AWS::AccountId".to_string(), "111122223333".to_string());
params.insert("AWS::Partition".to_string(), "aws".to_string());
params.insert("AWS::StackName".to_string(), "demo".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueArn"],
Value::String("arn:aws:sqs:us-west-2:111122223333:demo-q".to_string())
);
}
#[test]
fn pseudo_ref_partition_default_when_unset() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"Partition": {"Ref": "AWS::Partition"},
"Suffix": {"Ref": "AWS::URLSuffix"}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["Partition"],
Value::String("aws".to_string())
);
assert_eq!(
parsed.resources[0].properties["Suffix"],
Value::String("amazonaws.com".to_string())
);
}
#[test]
fn pseudo_ref_passes_through() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": { "Ref": "AWS::StackName" }
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("AWS::StackName".to_string())
);
}
#[test]
fn bb6_ref_aws_region_returns_seeded_region() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"Region": {"Ref": "AWS::Region"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "us-east-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Region"],
Value::String("us-east-1".to_string())
);
}
#[test]
fn bb6_fn_sub_substitutes_aws_account_id() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"Owner": {"Fn::Sub": "owner-${AWS::AccountId}"}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::AccountId".to_string(), "123456789012".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Owner"],
Value::String("owner-123456789012".to_string())
);
}
#[test]
fn bb6_partition_for_china_region_is_aws_cn() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"P": {"Ref": "AWS::Partition"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["P"],
Value::String("aws-cn".to_string())
);
}
#[test]
fn bb6_partition_for_govcloud_region_is_aws_us_gov() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"P": {"Ref": "AWS::Partition"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "us-gov-west-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["P"],
Value::String("aws-us-gov".to_string())
);
}
#[test]
fn bb6_url_suffix_for_china_is_amazonaws_com_cn() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"S": {"Ref": "AWS::URLSuffix"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["S"],
Value::String("amazonaws.com.cn".to_string())
);
}
#[test]
fn bb6_url_suffix_for_govcloud_stays_amazonaws_com() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"S": {"Ref": "AWS::URLSuffix"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "us-gov-east-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["S"],
Value::String("amazonaws.com".to_string())
);
}
#[test]
fn bb6_no_value_omits_property_from_resource_input() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "q",
"OptionalProp": {"Ref": "AWS::NoValue"}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let props = parsed.resources[0].properties.as_object().unwrap();
assert!(
!props.contains_key("OptionalProp"),
"OptionalProp should be omitted, got: {props:?}"
);
assert_eq!(
props.get("QueueName"),
Some(&Value::String("q".to_string()))
);
}
#[test]
fn bb6_notification_arns_returns_seeded_array() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert(
"AWS::NotificationARNs".to_string(),
r#"["arn:aws:sns:us-east-1:111122223333:topic"]"#.to_string(),
);
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Targets"],
serde_json::json!(["arn:aws:sns:us-east-1:111122223333:topic"])
);
}
#[test]
fn bb6_notification_arns_defaults_to_empty_array() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["Targets"],
serde_json::json!([])
);
}
#[test]
fn bb6_fn_sub_array_form_substitutes_extra_vars() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"Path": {"Fn::Sub": ["${AWS::Region}/${Suffix}", {"Suffix": "tail"}]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "eu-west-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Path"],
Value::String("eu-west-1/tail".to_string())
);
}
#[test]
fn bb6_partition_helper_classifies_regions() {
assert_eq!(partition_for_region("us-east-1"), "aws");
assert_eq!(partition_for_region("eu-central-1"), "aws");
assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
assert_eq!(partition_for_region("us-gov-west-1"), "aws-us-gov");
assert_eq!(partition_for_region("us-gov-east-1"), "aws-us-gov");
}
#[test]
fn bb6_url_suffix_helper_classifies_regions() {
assert_eq!(url_suffix_for_region("us-east-1"), "amazonaws.com");
assert_eq!(url_suffix_for_region("us-gov-west-1"), "amazonaws.com");
assert_eq!(url_suffix_for_region("cn-north-1"), "amazonaws.com.cn");
}
#[test]
fn fn_sub_resolves_physical_ids() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "my-topic"
}
},
"MyParam": {
"Type": "AWS::SSM::Parameter",
"Properties": {
"Name": "/app/topic",
"Type": "String",
"Value": { "Fn::Sub": "Topic is ${MyTopic}" }
}
}
}
}"#;
let mut physical_ids = BTreeMap::new();
physical_ids.insert(
"MyTopic".to_string(),
"arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
);
let parsed =
parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
let param = parsed
.resources
.iter()
.find(|r| r.logical_id == "MyParam")
.unwrap();
assert_eq!(
param.properties["Value"],
Value::String("Topic is arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
);
}
#[test]
fn parse_template_invalid_json_errors() {
let params = BTreeMap::new();
let result = parse_template("{not-json}", ¶ms);
assert!(result.is_err());
}
#[test]
fn parse_template_missing_resources_errors() {
let params = BTreeMap::new();
let result = parse_template(r#"{"Description":"no resources"}"#, ¶ms);
assert!(result.is_err());
}
#[test]
fn parse_template_resources_not_object_errors() {
let params = BTreeMap::new();
let result = parse_template(r#"{"Resources": []}"#, ¶ms);
assert!(result.is_err());
}
#[test]
fn parse_template_missing_type_errors() {
let params = BTreeMap::new();
let result = parse_template(r#"{"Resources":{"R":{"Properties":{}}}}"#, ¶ms);
assert!(result.is_err());
}
#[test]
fn fn_getatt_resolves_attribute_in_array_form() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": { "QueueName": "q1" }
},
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "t1",
"DataProtectionPolicy": {
"Fn::GetAtt": ["MyQueue", "Arn"]
}
}
}
}
}"#;
let mut attrs = BTreeMap::new();
let mut q_attrs = BTreeMap::new();
q_attrs.insert(
"Arn".to_string(),
"arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
);
attrs.insert("MyQueue".to_string(), q_attrs);
let parsed =
parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
.unwrap();
let topic = parsed
.resources
.iter()
.find(|r| r.logical_id == "MyTopic")
.unwrap();
assert_eq!(
topic.properties["DataProtectionPolicy"],
Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
);
}
#[test]
fn fn_getatt_resolves_attribute_in_short_string_form() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "t1",
"PolicyArn": { "Fn::GetAtt": "MyQueue.Arn" }
}
}
}
}"#;
let mut attrs = BTreeMap::new();
let mut q_attrs = BTreeMap::new();
q_attrs.insert(
"Arn".to_string(),
"arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
);
attrs.insert("MyQueue".to_string(), q_attrs);
let parsed =
parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
.unwrap();
assert_eq!(
parsed.resources[0].properties["PolicyArn"],
Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
);
}
#[test]
fn fn_getatt_unknown_resource_returns_placeholder() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": { "Fn::GetAtt": ["MyQueue", "Arn"] }
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["TopicName"],
Value::String("MyQueue.Arn".to_string())
);
}
#[test]
fn fn_getatt_inside_fn_join_resolves() {
let template = r#"{
"Resources": {
"MyParam": {
"Type": "AWS::SSM::Parameter",
"Properties": {
"Name": "/app/q",
"Type": "String",
"Value": {
"Fn::Join": [":", ["queue", { "Fn::GetAtt": ["MyQueue", "Arn"] }]]
}
}
}
}
}"#;
let mut attrs = BTreeMap::new();
let mut q_attrs = BTreeMap::new();
q_attrs.insert(
"Arn".to_string(),
"arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
);
attrs.insert("MyQueue".to_string(), q_attrs);
let parsed =
parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
.unwrap();
assert_eq!(
parsed.resources[0].properties["Value"],
Value::String("queue:arn:aws:sqs:us-east-1:123456789012:q1".to_string())
);
}
#[test]
fn fn_sub_resolves_getatt_style_substitution() {
let template = r#"{
"Resources": {
"MyParam": {
"Type": "AWS::SSM::Parameter",
"Properties": {
"Name": "/app/q",
"Type": "String",
"Value": { "Fn::Sub": "Queue arn is ${MyQueue.Arn}" }
}
}
}
}"#;
let mut attrs = BTreeMap::new();
let mut q_attrs = BTreeMap::new();
q_attrs.insert(
"Arn".to_string(),
"arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
);
attrs.insert("MyQueue".to_string(), q_attrs);
let parsed =
parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
.unwrap();
assert_eq!(
parsed.resources[0].properties["Value"],
Value::String("Queue arn is arn:aws:sqs:us-east-1:123456789012:q1".to_string())
);
}
#[test]
fn parse_template_with_description() {
let params = BTreeMap::new();
let parsed = parse_template(
r#"{"Description":"My template","Resources":{"R":{"Type":"AWS::SQS::Queue"}}}"#,
¶ms,
)
.unwrap();
assert_eq!(parsed.description.as_deref(), Some("My template"));
assert_eq!(parsed.resources.len(), 1);
}
type EmptyCtx = (
BTreeMap<String, String>,
serde_json::Map<String, Value>,
BTreeMap<String, String>,
BTreeMap<String, BTreeMap<String, String>>,
);
fn empty() -> EmptyCtx {
(
BTreeMap::new(),
serde_json::Map::new(),
BTreeMap::new(),
BTreeMap::new(),
)
}
#[test]
fn fn_base64_encodes_string() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Base64": "hello"}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String("aGVsbG8=".to_string()));
}
#[test]
fn fn_split_emits_array() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "a,b,c"]}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, serde_json::json!(["a", "b", "c"]));
}
#[test]
fn fn_select_picks_index() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::Select": [1, {"Fn::Split": [",", "a,b,c"]}]}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String("b".to_string()));
}
#[test]
fn fn_length_counts_array() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Length": [1,2,3,4]}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::Number(4.into()));
}
#[test]
fn fn_to_json_string_serializes() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::ToJsonString": {"a": 1, "b": [2, 3]}}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
let s = resolved.as_str().unwrap();
let parsed: Value = serde_json::from_str(s).unwrap();
assert_eq!(parsed["a"], serde_json::json!(1));
assert_eq!(parsed["b"], serde_json::json!([2, 3]));
}
#[test]
fn fn_cidr_carves_subnets() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 4, 8]}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(
resolved,
serde_json::json!(["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",])
);
}
#[test]
fn condition_skips_resource_when_false() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
},
"Resources": {
"ProdQueue": {
"Type": "AWS::SQS::Queue",
"Condition": "IsProd",
"Properties": {"QueueName": "prod-q"}
},
"AlwaysQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "always-q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "dev".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
assert!(names.contains(&"AlwaysQueue"));
assert!(!names.contains(&"ProdQueue"));
}
#[test]
fn condition_includes_resource_when_true() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
},
"Resources": {
"ProdQueue": {
"Type": "AWS::SQS::Queue",
"Condition": "IsProd",
"Properties": {"QueueName": "prod-q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(parsed.resources.len(), 1);
}
#[test]
fn fn_if_picks_branch_based_on_condition() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::If": ["IsProd", "prod-q", "dev-q"]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "dev".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("dev-q".to_string())
);
}
#[test]
fn fn_and_or_not_combine_conditions() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}, "Region": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"IsUsEast": {"Fn::Equals": [{"Ref": "Region"}, "us-east-1"]},
"IsProdInUsEast": {"Fn::And": [{"Condition": "IsProd"}, {"Condition": "IsUsEast"}]},
"IsNotProd": {"Fn::Not": [{"Condition": "IsProd"}]},
"IsAny": {"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "IsNotProd"}]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"P1": {"Fn::If": ["IsProdInUsEast", "yes", "no"]},
"P2": {"Fn::If": ["IsNotProd", "yes", "no"]},
"P3": {"Fn::If": ["IsAny", "yes", "no"]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
params.insert("Region".to_string(), "us-east-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let p = &parsed.resources[0].properties;
assert_eq!(p["P1"], Value::String("yes".to_string()));
assert_eq!(p["P2"], Value::String("no".to_string()));
assert_eq!(p["P3"], Value::String("yes".to_string()));
}
#[test]
fn fn_find_in_map_resolves_leaf_value() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"},
"us-west-2": {"AMI": "ami-west"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-east".to_string())
);
}
#[test]
fn fn_find_in_map_resolves_keys_via_ref() {
let template = r#"{
"Parameters": {"Region": {"Type": "String"}},
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"},
"us-west-2": {"AMI": "ami-west"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": ["RegionMap", {"Ref": "Region"}, "AMI"]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Region".to_string(), "us-west-2".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-west".to_string())
);
}
#[test]
fn fn_find_in_map_unknown_keys_returns_error() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]}
}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(
err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
"got: {err}"
);
}
#[test]
fn fn_find_in_map_four_arg_returns_default_when_missing() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": [
"RegionMap",
"ap-south-1",
"AMI",
{"DefaultValue": "ami-fallback"}
]}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-fallback".to_string())
);
}
#[test]
fn fn_find_in_map_four_arg_prefers_match_over_default() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": [
"RegionMap",
"us-east-1",
"AMI",
{"DefaultValue": "ami-fallback"}
]}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-east".to_string())
);
}
#[test]
fn fn_find_in_map_default_value_is_resolved_intrinsic() {
let template = r#"{
"Parameters": {"Fallback": {"Type": "String"}},
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": [
"RegionMap",
"ap-south-1",
"AMI",
{"DefaultValue": {"Ref": "Fallback"}}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Fallback".to_string(), "ami-default".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-default".to_string())
);
}
#[test]
fn fn_find_in_map_unknown_map_name_errors() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": ["DoesNotExist", "us-east-1", "AMI"]}
}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(
err.contains("Unable to get mapping for DoesNotExist::us-east-1::AMI"),
"got: {err}"
);
}
#[test]
fn fn_find_in_map_wrong_arg_count_errors() {
let template = r#"{
"Mappings": {"M": {"a": {"b": "c"}}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::FindInMap": ["M", "a"]}
}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(
err.contains("Fn::FindInMap requires 3 or 4 arguments"),
"got: {err}"
);
}
#[test]
fn fn_find_in_map_resolves_via_pseudo_region() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"},
"us-west-2": {"AMI": "ami-west"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": [
"RegionMap",
{"Ref": "AWS::Region"},
"AMI"
]}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-east".to_string())
);
}
#[test]
fn fn_find_in_map_in_unused_if_branch_does_not_error() {
let template = r#"{
"Parameters": {"WantAlt": {"Type": "String"}},
"Conditions": {
"UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
},
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::If": [
"UseAlt",
{"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
{"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantAlt".to_string(), "no".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-east".to_string())
);
}
#[test]
fn fn_find_in_map_in_active_if_branch_still_errors_on_miss() {
let template = r#"{
"Parameters": {"WantAlt": {"Type": "String"}},
"Conditions": {
"UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
},
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::If": [
"UseAlt",
{"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
{"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantAlt".to_string(), "yes".to_string());
let err = parse_template(template, ¶ms).unwrap_err();
assert!(
err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
"got: {err}"
);
}
#[test]
fn fn_find_in_map_alongside_ref_and_sub_still_resolve() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Mappings": {
"EnvMap": {
"prod": {"Suffix": "live"},
"dev": {"Suffix": "test"}
}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::FindInMap": ["EnvMap", {"Ref": "Env"}, "Suffix"]},
"Tags": [
{"Key": "EnvRef", "Value": {"Ref": "Env"}},
{"Key": "Subbed", "Value": {"Fn::Sub": "env-${Env}"}}
]
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let p = &parsed.resources[0].properties;
assert_eq!(p["QueueName"], Value::String("live".to_string()));
assert_eq!(p["Tags"][0]["Value"], Value::String("prod".to_string()));
assert_eq!(p["Tags"][1]["Value"], Value::String("env-prod".to_string()));
}
#[test]
fn cyclic_conditions_self_reference_errors() {
let template = r#"{
"Conditions": {
"A": {"Condition": "A"}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "A",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Circular reference"), "got: {err}");
assert!(err.contains("'A'"), "got: {err}");
}
#[test]
fn cyclic_conditions_two_step_errors() {
let template = r#"{
"Conditions": {
"A": {"Condition": "B"},
"B": {"Condition": "A"}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "A",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Circular reference"), "got: {err}");
}
#[test]
fn condition_referencing_undefined_name_errors() {
let template = r#"{
"Conditions": {
"A": {"Condition": "DoesNotExist"}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "A",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("DoesNotExist"), "got: {err}");
}
#[test]
fn fn_if_no_value_removes_property_from_parent_map() {
let template = r#"{
"Parameters": {"WantTags": {"Type": "String"}},
"Conditions": {
"HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "q",
"Tags": {"Fn::If": [
"HasTags",
[{"Key": "a", "Value": "b"}],
{"Ref": "AWS::NoValue"}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantTags".to_string(), "no".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let props = parsed.resources[0].properties.as_object().unwrap();
assert!(
!props.contains_key("Tags"),
"Tags should be omitted when AWS::NoValue picked, got: {props:?}"
);
assert_eq!(
props.get("QueueName"),
Some(&Value::String("q".to_string()))
);
}
#[test]
fn fn_if_no_value_keeps_property_when_branch_concrete() {
let template = r#"{
"Parameters": {"WantTags": {"Type": "String"}},
"Conditions": {
"HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "q",
"Tags": {"Fn::If": [
"HasTags",
[{"Key": "a", "Value": "b"}],
{"Ref": "AWS::NoValue"}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantTags".to_string(), "yes".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let tags = &parsed.resources[0].properties["Tags"];
assert_eq!(
tags,
&serde_json::json!([{"Key": "a", "Value": "b"}]),
"tags should be the true branch's array"
);
}
#[test]
fn fn_if_no_value_in_array_drops_element() {
let template = r#"{
"Parameters": {"Extra": {"Type": "String"}},
"Conditions": {
"HasExtra": {"Fn::Equals": [{"Ref": "Extra"}, "yes"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"Items": [
"first",
{"Fn::If": ["HasExtra", "second", {"Ref": "AWS::NoValue"}]},
"third"
]
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Extra".to_string(), "no".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Items"],
serde_json::json!(["first", "third"])
);
}
#[test]
fn condition_skips_output_when_false() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "q"}
}
},
"Outputs": {
"ProdName": {
"Condition": "IsProd",
"Value": "prod-only"
},
"Always": {
"Value": "shown"
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "dev".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let names: Vec<&str> = parsed
.outputs
.iter()
.map(|o| o.logical_id.as_str())
.collect();
assert!(names.contains(&"Always"));
assert!(!names.contains(&"ProdName"));
}
#[test]
fn fn_and_short_circuits_on_false() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"Combined": {"Fn::And": [
{"Condition": "IsProd"},
{"Fn::Equals": [{"Ref": "Env"}, "prod"]}
]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "Combined",
"Properties": {"QueueName": "q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "dev".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(parsed.resources.len(), 0);
}
#[test]
fn fn_or_short_circuits_on_true() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"AnyEnv": {"Fn::Or": [
{"Condition": "IsProd"},
{"Fn::Equals": [{"Ref": "Env"}, "dev"]},
{"Fn::Equals": [{"Ref": "Env"}, "stage"]}
]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "AnyEnv",
"Properties": {"QueueName": "q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "stage".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(parsed.resources.len(), 1);
}
#[test]
fn fn_and_rejects_arity_outside_1_to_10() {
let template = r#"{
"Conditions": {
"Empty": {"Fn::And": []}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "Empty",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Fn::And"), "got: {err}");
}
#[test]
fn condition_evaluation_memoizes_complex_expression() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"Inner": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"OuterA": {"Fn::And": [{"Condition": "Inner"}, {"Condition": "Inner"}]},
"OuterB": {"Fn::Or": [{"Condition": "Inner"}, {"Condition": "OuterA"}]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "OuterB",
"Properties": {"QueueName": "q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(parsed.resources.len(), 1);
}
#[test]
fn fn_not_rejects_multiple_arguments() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"Bad": {"Fn::Not": [
{"Condition": "IsProd"},
{"Condition": "IsProd"}
]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "Bad",
"Properties": {"QueueName": "q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let err = parse_template(template, ¶ms).unwrap_err();
assert!(err.contains("Fn::Not"), "got: {err}");
}
#[test]
fn fn_not_rejects_zero_arguments() {
let template = r#"{
"Conditions": {
"Bad": {"Fn::Not": []}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "Bad",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Fn::Not"), "got: {err}");
}
#[test]
fn resolve_resource_properties_strips_no_value_at_provision_time() {
let template = r#"{
"Parameters": {"WantTags": {"Type": "String"}},
"Conditions": {
"HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "q",
"Tags": {"Fn::If": [
"HasTags",
[{"Key": "a", "Value": "b"}],
{"Ref": "AWS::NoValue"}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantTags".to_string(), "no".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let resource = parsed
.resources
.iter()
.find(|r| r.logical_id == "Q")
.unwrap();
assert!(!resource
.properties
.as_object()
.unwrap()
.contains_key("Tags"));
let reresolved = resolve_resource_properties_with_attrs(
resource,
template,
¶ms,
&BTreeMap::new(),
&BTreeMap::new(),
)
.unwrap();
let props = reresolved.properties.as_object().unwrap();
assert!(
!props.contains_key("Tags"),
"Tags should be stripped on re-resolve, got: {props:?}"
);
let serialized = serde_json::to_string(&reresolved.properties).unwrap();
assert!(
!serialized.contains(NO_VALUE_SENTINEL_KEY),
"sentinel leaked: {serialized}"
);
}
#[test]
fn fn_select_string_index_resolves() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Select": ["2", ["a", "b", "c", "d"]]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String("c".to_string()));
}
#[test]
fn fn_select_out_of_range_returns_null() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Select": [10, ["a", "b"]]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::Null);
}
#[test]
fn fn_select_resolves_ref_inside_list() {
let template = r#"{
"Parameters": {"AZs": {"Type": "CommaDelimitedList"}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "AZs"}]}]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert(
"AZs".to_string(),
"us-east-1a,us-east-1b,us-east-1c".to_string(),
);
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("us-east-1a".to_string())
);
}
#[test]
fn fn_split_empty_delimiter_returns_full_string_split_per_char() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::Split": ["", "abc"]}"#).expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert!(resolved.is_array());
}
#[test]
fn fn_split_no_match_returns_single_element_array() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "no-commas-here"]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, serde_json::json!(["no-commas-here"]));
}
#[test]
fn fn_base64_encodes_unicode() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::Base64": "héllo"}"#).expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String("aMOpbGxv".to_string()));
}
#[test]
fn fn_base64_resolves_nested_intrinsic() {
let template = r#"{
"Parameters": {"Greeting": {"Type": "String"}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::Base64": {"Ref": "Greeting"}}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Greeting".to_string(), "hello".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("aGVsbG8=".to_string())
);
}
#[test]
fn fn_length_counts_string_chars() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::Length": "héllo"}"#).expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::Number(5.into()));
}
#[test]
fn fn_length_resolves_nested_split() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Length": {"Fn::Split": [",", "a,b,c,d,e"]}}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::Number(5.into()));
}
#[test]
fn fn_to_json_string_serializes_array() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::ToJsonString": ["a", "b", "c"]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String(r#"["a","b","c"]"#.to_string()));
}
#[test]
fn fn_to_json_string_resolves_inner_ref() {
let template = r#"{
"Parameters": {"Name": {"Type": "String"}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {
"Fn::ToJsonString": {"k": {"Ref": "Name"}}
}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Name".to_string(), "abc".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String(r#"{"k":"abc"}"#.to_string())
);
}
#[test]
fn fn_cidr_count_matches_request() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 2, 8]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, serde_json::json!(["10.0.0.0/24", "10.0.1.0/24"]));
}
#[test]
fn fn_cidr_resolves_via_ref() {
let template = r#"{
"Parameters": {"Vpc": {"Type": "String"}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::Select": [
0,
{"Fn::Cidr": [{"Ref": "Vpc"}, 4, 8]}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Vpc".to_string(), "172.16.0.0/16".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("172.16.0.0/24".to_string())
);
}
#[test]
fn fn_for_each_expands_resources() {
let template = r#"{
"Resources": {
"Fn::ForEach::TopicLoop": [
"TopicName",
["alpha", "beta", "gamma"],
{
"${TopicName}Topic": {
"Type": "AWS::SNS::Topic",
"Properties": {"TopicName": "${TopicName}-topic"}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
assert!(names.contains(&"alphaTopic"), "got: {names:?}");
assert!(names.contains(&"betaTopic"), "got: {names:?}");
assert!(names.contains(&"gammaTopic"), "got: {names:?}");
let alpha = parsed
.resources
.iter()
.find(|r| r.logical_id == "alphaTopic")
.unwrap();
assert_eq!(
alpha.properties["TopicName"],
Value::String("alpha-topic".to_string())
);
}
#[test]
fn fn_for_each_substitutes_in_nested_values() {
let template = r#"{
"Resources": {
"Fn::ForEach::Q": [
"QName",
["one", "two"],
{
"${QName}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "${QName}",
"Tags": [
{"Key": "name", "Value": "${QName}"}
]
}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let one = parsed
.resources
.iter()
.find(|r| r.logical_id == "oneQueue")
.unwrap();
assert_eq!(
one.properties["QueueName"],
Value::String("one".to_string())
);
assert_eq!(
one.properties["Tags"][0]["Value"],
Value::String("one".to_string())
);
}
#[test]
fn fn_for_each_nested_loops_expand_cartesian() {
let template = r#"{
"Resources": {
"Fn::ForEach::Outer": [
"Env",
["dev", "prod"],
{
"Fn::ForEach::Inner": [
"Region",
["us-east-1", "eu-west-1"],
{
"${Env}${Region}Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "${Env}-${Region}"}
}
}
]
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
for env in ["dev", "prod"] {
for region in ["us-east-1", "eu-west-1"] {
let expected = format!("{env}{region}Q");
assert!(
names.contains(&expected.as_str()),
"missing {expected} in {names:?}"
);
}
}
let dev_us = parsed
.resources
.iter()
.find(|r| r.logical_id == "devus-east-1Q")
.unwrap();
assert_eq!(
dev_us.properties["QueueName"],
Value::String("dev-us-east-1".to_string())
);
}
#[test]
fn fn_for_each_keeps_other_resources_untouched() {
let template = r#"{
"Resources": {
"Static": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "static-q"}
},
"Fn::ForEach::Loop": [
"I",
["a", "b"],
{
"${I}Topic": {
"Type": "AWS::SNS::Topic",
"Properties": {"TopicName": "${I}"}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
assert!(names.contains(&"Static"));
assert!(names.contains(&"aTopic"));
assert!(names.contains(&"bTopic"));
assert_eq!(parsed.resources.len(), 3);
}
#[test]
fn fn_for_each_invalid_arity_errors() {
let template = r#"{
"Resources": {
"Fn::ForEach::Bad": [
"Var",
["a"]
]
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Fn::ForEach"), "got: {err}");
}
#[test]
fn fn_for_each_resolves_intrinsics_in_emitted_resources() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Resources": {
"Fn::ForEach::Q": [
"Name",
["alpha", "beta"],
{
"${Name}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::Sub": "${Env}-${Name}"}
}
}
}
]
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let alpha = parsed
.resources
.iter()
.find(|r| r.logical_id == "alphaQueue")
.unwrap();
assert_eq!(
alpha.properties["QueueName"],
Value::String("prod-alpha".to_string())
);
}
#[test]
fn fn_for_each_re_resolves_at_provision_time() {
let template = r#"{
"Resources": {
"Fn::ForEach::Q": [
"Name",
["alpha"],
{
"${Name}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "${Name}-q"}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let resource = parsed
.resources
.iter()
.find(|r| r.logical_id == "alphaQueue")
.unwrap();
let reresolved = resolve_resource_properties_with_attrs(
resource,
template,
&BTreeMap::new(),
&BTreeMap::new(),
&BTreeMap::new(),
)
.unwrap();
assert_eq!(
reresolved.properties["QueueName"],
Value::String("alpha-q".to_string())
);
}
#[test]
fn fn_for_each_resolves_ref_to_comma_delimited_list_param() {
let template = r#"{
"Parameters": {"Names": {"Type": "CommaDelimitedList"}},
"Resources": {
"Fn::ForEach::Q": [
"N",
{"Ref": "Names"},
{
"${N}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "${N}-q"}
}
}
]
}
}"#;
let mut params = BTreeMap::new();
params.insert("Names".to_string(), "alpha,beta,gamma".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
for v in ["alphaQueue", "betaQueue", "gammaQueue"] {
assert!(names.contains(&v), "missing {v} in {names:?}");
}
}
#[test]
fn fn_for_each_ampersand_substitution_form() {
let template = r#"{
"Resources": {
"Fn::ForEach::Q": [
"Name",
["alpha", "beta"],
{
"&{Name}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "&{Name}"}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
assert!(names.contains(&"alphaQueue"), "got: {names:?}");
assert!(names.contains(&"betaQueue"), "got: {names:?}");
let alpha = parsed
.resources
.iter()
.find(|r| r.logical_id == "alphaQueue")
.unwrap();
assert_eq!(
alpha.properties["QueueName"],
Value::String("alpha".to_string())
);
}
#[test]
fn fn_for_each_in_outputs_expands() {
let template = r#"{
"Resources": {
"Q": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "q"}}
},
"Outputs": {
"Fn::ForEach::OutputLoop": [
"I",
["one", "two"],
{
"${I}Out": {"Value": "${I}-value"}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.outputs
.iter()
.map(|o| o.logical_id.as_str())
.collect();
assert!(names.contains(&"oneOut"), "got: {names:?}");
assert!(names.contains(&"twoOut"), "got: {names:?}");
let one = parsed
.outputs
.iter()
.find(|o| o.logical_id == "oneOut")
.unwrap();
assert_eq!(one.value, "one-value");
}
}