use std::collections::HashMap;
use template_model::resolver::ResolvedValue;
use template_model::{ParseConfig, SemanticModel, SpanProvider};
const TEMPLATES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../resources/templates");
fn model_from_fixture(path: &str) -> SemanticModel {
let full = format!("{}/{}", TEMPLATES, path);
let bytes = std::fs::read(&full).unwrap_or_else(|e| panic!("Failed to read {}: {}", full, e));
SemanticModel::from_bytes(&bytes).unwrap_or_else(|e| panic!("Failed to parse {}: {}", full, e))
}
#[test]
fn model_resources_by_type() {
let model = model_from_fixture("good/generic.yaml");
let iam_roles = model.resources_of_type("AWS::IAM::Role");
assert!(!iam_roles.is_empty(), "Expected IAM roles in generic template");
}
#[test]
fn model_rego_input_valid_json() {
let model = model_from_fixture("good/generic.yaml");
let json = serde_json::to_value(model.to_diagnostic_json()).unwrap();
assert!(json.get("resources").is_some(), "expected 'resources' in diagnostic JSON");
assert!(json.get("parameters").is_some(), "expected 'parameters' in diagnostic JSON");
assert!(json.get("edges").is_some(), "expected 'edges' in diagnostic JSON");
}
#[test]
fn fixture_both_intrinsic_forms() {
let model = model_from_fixture("good/both_forms.yaml");
assert_eq!(model.resources.len(), 13);
let bucket_short = model.resource("BucketShort").unwrap();
match bucket_short.properties.get("BucketName") {
Some(ResolvedValue::Enum { variants: _ }) => {}
other => panic!("BucketShort.BucketName: expected Enum from Sub, got {:?}", other),
}
let bucket_long = model.resource("BucketLong").unwrap();
match bucket_long.properties.get("BucketName") {
Some(ResolvedValue::Enum { variants: _ }) => {}
other => panic!("BucketLong.BucketName: expected Enum from Sub, got {:?}", other),
}
let with_getatt = model.resource("WithGetAtt").unwrap();
match with_getatt.properties.get("ShortForm") {
Some(ResolvedValue::Reference { target: t, .. }) => {
assert_eq!(t, "BucketShort")
}
other => panic!("WithGetAtt.ShortForm: expected Reference, got {:?}", other),
}
match with_getatt.properties.get("LongFormDotted") {
Some(ResolvedValue::Reference { target: t, .. }) => {
assert_eq!(t, "BucketShort")
}
other => panic!("WithGetAtt.LongFormDotted: expected Reference, got {:?}", other),
}
match with_getatt.properties.get("LongFormArray") {
Some(ResolvedValue::Reference { target: t, .. }) => {
assert_eq!(t, "BucketLong")
}
other => panic!("WithGetAtt.LongFormArray: expected Reference, got {:?}", other),
}
let with_join = model.resource("WithJoin").unwrap();
match with_join.properties.get("Short") {
Some(ResolvedValue::Concrete { value: v }) => {
assert_eq!(v.as_str().unwrap(), "a-b-c");
}
other => panic!("WithJoin.Short: expected Concrete 'a-b-c', got {:?}", other),
}
match with_join.properties.get("Long") {
Some(ResolvedValue::Concrete { value: v }) => {
assert_eq!(v.as_str().unwrap(), "x-y-z");
}
other => panic!("WithJoin.Long: expected Concrete 'x-y-z', got {:?}", other),
}
let with_select = model.resource("WithSelect").unwrap();
match with_select.properties.get("Short") {
Some(ResolvedValue::Concrete { value: v }) => {
assert_eq!(v.as_str().unwrap(), "a")
}
other => panic!("WithSelect.Short: expected Concrete 'a', got {:?}", other),
}
match with_select.properties.get("Long") {
Some(ResolvedValue::Concrete { value: v }) => {
assert_eq!(v.as_str().unwrap(), "b")
}
other => panic!("WithSelect.Long: expected Concrete 'b', got {:?}", other),
}
let with_if = model.resource("WithIf").unwrap();
assert!(
matches!(with_if.properties.get("Short"), Some(ResolvedValue::Conditional { condition: c, .. }) if c == "IsProd")
);
assert!(
matches!(with_if.properties.get("Long"), Some(ResolvedValue::Conditional { condition: c, .. }) if c == "IsProd")
);
let with_b64 = model.resource("WithBase64").unwrap();
match with_b64.properties.get("Short") {
Some(ResolvedValue::Concrete { value: v }) => {
assert_eq!(v.as_str().unwrap(), "aGVsbG8=")
}
other => panic!("WithBase64.Short: expected Concrete base64, got {:?}", other),
}
let sub_block = model.resource("SubBlock").unwrap();
match sub_block.properties.get("UserData") {
Some(ResolvedValue::Enum { variants: _ }) => {}
other => panic!("SubBlock.UserData: expected Enum from Sub, got {:?}", other),
}
assert!(model.conditions.conditions.contains_key("IsProd"));
assert!(model.conditions.conditions.contains_key("IsDevShort"));
assert!(model.conditions.conditions.contains_key("Combined"));
assert!(model.mappings.contains_key("MyMap"));
assert_eq!(model.outputs.len(), 1);
}
#[test]
fn parser_auto_detects_json() {
let input = r#"{"Resources":{"R":{"Type":"T"}}}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.resources.len(), 1);
}
#[test]
fn parser_auto_detects_yaml() {
let input = "Resources:\n R:\n Type: T\n";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.resources.len(), 1);
}
#[test]
fn parser_json_with_leading_whitespace() {
let input = " \n {\"Resources\":{\"R\":{\"Type\":\"T\"}}}";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.resources.len(), 1);
}
#[test]
fn parser_yaml_with_comments() {
let input = "# comment\nResources:\n R:\n Type: T\n # inline comment\n";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.resources.len(), 1);
}
#[test]
fn parser_rejects_empty_input() {
assert!(SemanticModel::from_bytes(b"").is_err(), "empty input should be rejected");
}
#[test]
fn parser_rejects_whitespace_only() {
assert!(SemanticModel::from_bytes(b" \n \n ").is_err(), "whitespace-only input should be rejected");
}
#[test]
fn parser_rejects_non_object_json() {
assert!(SemanticModel::from_bytes(b"[1,2,3]").is_err(), "non-object JSON should be rejected");
}
#[test]
fn parser_rejects_scalar_yaml() {
assert!(SemanticModel::from_bytes(b"just a string").is_err(), "scalar YAML should be rejected");
}
#[test]
fn parser_json_preserves_all_sections() {
let input = r#"{
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "Test template",
"Parameters": {"P": {"Type": "String", "Default": "val"}},
"Mappings": {"M": {"k1": {"k2": "v"}}},
"Conditions": {"C": {"Fn::Equals": ["a", "a"]}},
"Resources": {"R": {"Type": "T", "Properties": {"V": "x"}}},
"Outputs": {"O": {"Value": {"Ref": "R"}}}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.format_version.as_deref(), Some("2010-09-09"));
assert_eq!(model.description.as_deref(), Some("Test template"));
assert_eq!(model.parameters.len(), 1);
assert_eq!(model.mappings.len(), 1);
assert_eq!(model.conditions.conditions.len(), 1);
assert_eq!(model.resources.len(), 1);
assert_eq!(model.outputs.len(), 1);
}
#[test]
fn parser_yaml_preserves_all_sections() {
let input = r#"
AWSTemplateFormatVersion: "2010-09-09"
Description: Test template
Parameters:
P:
Type: String
Default: val
Mappings:
M:
k1:
k2: v
Conditions:
C:
Fn::Equals: [a, a]
Resources:
R:
Type: T
Properties:
V: x
Outputs:
O:
Value: !Ref R
"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.format_version.as_deref(), Some("2010-09-09"));
assert_eq!(model.description.as_deref(), Some("Test template"));
assert_eq!(model.parameters.len(), 1);
assert_eq!(model.mappings.len(), 1);
assert_eq!(model.conditions.conditions.len(), 1);
assert_eq!(model.resources.len(), 1);
assert_eq!(model.outputs.len(), 1);
}
#[test]
fn parser_json_yaml_produce_same_resource_types() {
let json_input = r#"{"Resources":{"Bucket":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"test"}}}}"#;
let yaml_input = "Resources:\n Bucket:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: test\n";
let json_model = SemanticModel::from_bytes(json_input.as_bytes()).unwrap();
let yaml_model = SemanticModel::from_bytes(yaml_input.as_bytes()).unwrap();
assert_eq!(
json_model.resource("Bucket").unwrap().resource_type,
yaml_model.resource("Bucket").unwrap().resource_type
);
}
#[test]
fn parser_yaml_intrinsic_short_forms() {
let input = r#"
Resources:
R:
Type: T
Properties:
A: !Ref Param
B: !Sub "hello-${AWS::Region}"
C: !GetAtt Other.Arn
D: !Join ["-", ["a", "b"]]
E: !Select [0, ["x", "y"]]
F: !Base64 hello
G: !Split [",", "a,b"]
"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let r = model.resource("R").unwrap();
assert_eq!(r.properties.len(), 7);
}
#[test]
fn parser_transform_single_string() {
let input = "Transform: AWS::Serverless-2016-10-31\nResources:\n R:\n Type: T\n";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.transforms.len(), 1);
assert_eq!(model.transforms[0], "AWS::Serverless-2016-10-31");
}
#[test]
fn parser_transform_list() {
let input = r#"{"Transform":["AWS::Serverless-2016-10-31","AWS::Include"],"Resources":{"R":{"Type":"T"}}}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.transforms.len(), 2);
}
#[test]
fn parser_minimal_template_no_properties() {
let input = r#"{"Resources":{"R":{"Type":"T"}}}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let r = model.resource("R").unwrap();
assert!(r.properties.is_empty());
assert_eq!(r.resource_type, "T");
}
#[test]
fn parser_fn_if_undefined_condition_produces_e1028() {
let input = "Resources:\n R:\n Type: T\n Properties:\n V: !If [NonExistent, 1, 2]\n";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let e1028: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E1028").collect();
assert_eq!(e1028.len(), 1, "exactly one E1028 for an undefined Fn::If condition");
assert!(e1028[0].message.contains("NonExistent"));
assert_eq!(e1028[0].resource_id.as_deref(), Some("R"));
assert_eq!(e1028[0].property_path.as_deref(), Some("Properties.V.Fn::If.0"));
assert_eq!((e1028[0].span.start_line, e1028[0].span.start_column), (5, 15));
assert!(!model.diagnostics.iter().any(|d| d.rule_id == "F1104"), "F1104 must no longer fire for this case");
}
#[test]
fn dynamic_reference_e1050_matches_reference_cases() {
let fires = |props: &str| {
let input = format!(r#"{{"Resources":{{"B":{{"Type":"AWS::S3::Bucket","Properties":{props}}}}}}}"#);
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
model.diagnostics.iter().any(|d| d.rule_id == "E1050")
};
assert!(fires(r#"{"BucketName":"{{resolve:ssm:/p:notanum}}"}"#), "ssm non-numeric version");
assert!(fires(r#"{"BucketName":"{{resolve:not-a-service:foo}}"}"#), "unknown service");
assert!(
fires(r#"{"BucketName":"{{resolve:secretsmanager:arn:aws:s3:us-east-1:1:notsecret:n}}"}"#),
"secretsmanager ARN missing 'secret' segment"
);
assert!(!fires(r#"{"BucketName":"{{resolve:ssm:/my/param}}"}"#), "valid ssm");
assert!(!fires(r#"{"BucketName":"{{resolve:ssm:my param with spaces}}"}"#), "ssm name with spaces is tolerated");
assert!(!fires(r#"{"BucketName":"{{resolve:secretsmanager:}}"}"#), "empty secret-id is valid");
assert!(
!fires(r#"{"BucketName":"{{resolve:secretsmanager:s:SecretString:k:stage:id}}"}"#),
"full non-ARN secretsmanager tail is valid"
);
}
#[test]
fn dynamic_reference_inside_function_is_not_format_checked() {
let input = r#"{"Resources":{"B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":{"Fn::Sub":"x-{{resolve:ssm:p:notanum}}"}}}}}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert!(!model.diagnostics.iter().any(|d| d.rule_id == "E1050"), "dynamic ref inside Fn::Sub must not fire E1050");
}
#[test]
fn bare_ref_condition_body_produces_e8001_not_e8007() {
let input = r#"{
"Parameters":{"MyParam":{"Type":"String"}},
"Conditions":{"MyCond":{"Ref":"MyParam"}},
"Resources":{"B":{"Type":"AWS::S3::Bucket","Condition":"MyCond"}}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert!(model.diagnostics.iter().any(|d| d.rule_id == "E8001"), "bare Fn::Ref condition body must be E8001");
assert!(!model.diagnostics.iter().any(|d| d.rule_id == "E8007"), "must not be reported as an undefined condition");
}
#[test]
fn undefined_output_condition_produces_e6005_with_location() {
let input = r#"{
"Conditions":{"IsProd":{"Fn::Equals":[{"Ref":"AWS::Region"},"us-east-1"]}},
"Resources":{"R":{"Type":"AWS::S3::Bucket","Condition":"Missing"}},
"Outputs":{"Out":{"Condition":"AlsoMissing","Value":"x"}}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let e6005: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E6005").collect();
let e8002: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E8002").collect();
assert_eq!(e6005.len(), 1, "output undefined condition -> E6005");
assert!(e6005[0].message.contains("AlsoMissing"));
assert_eq!(e8002.len(), 1, "resource undefined condition -> E8002");
assert!(e8002[0].message.contains("Missing"));
}
#[test]
fn raw_pseudo_parameter_in_output_and_parameter_default_produce_w1054() {
let input = r#"{
"Parameters":{"P":{"Type":"String","Default":"AWS::Region"}},
"Resources":{"B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"AWS::NoValue"}}},
"Outputs":{"Out":{"Value":"AWS::AccountId"}}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let w1054: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "W1054").collect();
assert!(w1054.iter().any(|d| d.message.contains("AWS::Region")), "param Default pseudo-param -> W1054");
assert!(w1054.iter().any(|d| d.message.contains("AWS::AccountId")), "output pseudo-param -> W1054");
}
#[test]
fn yaml_merge_key_produces_w1100() {
let input = "\
.base: &base
BucketName: my-bucket
Resources:
B:
Type: AWS::S3::Bucket
Properties:
<<: *base
";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert!(model.diagnostics.iter().any(|d| d.rule_id == "W1100"), "YAML merge key must produce W1100");
}
#[test]
fn equals_operand_producing_non_scalar_produces_e8003() {
let cases = [
r#"{"Conditions":{"C":{"Fn::Equals":[{"Fn::GetAtt":["R","Arn"]},"x"]}},"Resources":{"R":{"Type":"AWS::S3::Bucket"}}}"#,
r#"{"Conditions":{"C":{"Fn::Equals":[{"Fn::Base64":"abc"},"x"]}},"Resources":{"R":{"Type":"AWS::S3::Bucket"}}}"#,
r#"{"Conditions":{"C":{"Fn::Equals":[{"Fn::ImportValue":"E"},"x"]}},"Resources":{"R":{"Type":"AWS::S3::Bucket"}}}"#,
r#"{"Conditions":{"C":{"Fn::Equals":[{"Fn::GetAZs":""},"x"]}},"Resources":{"R":{"Type":"AWS::S3::Bucket"}}}"#,
];
for input in cases {
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert!(
model.diagnostics.iter().any(|d| d.rule_id == "E8003"),
"expected E8003 for a non-scalar Fn::Equals operand in: {input}"
);
}
}
#[test]
fn malformed_condition_operands_anchor_at_the_offending_item() {
let input = r#"
Conditions:
Target:
Fn::Equals: [x, x]
InvalidEquals:
Fn::Equals:
- x
- [not-a-scalar]
InvalidAnd:
Fn::And:
- !Condition Target
- Bad: Target
Resources:
R:
Type: AWS::S3::Bucket
"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let equals = model
.diagnostics
.iter()
.find(|diagnostic| diagnostic.rule_id == "E8003" && diagnostic.message.contains("argument 1"))
.expect("the malformed Equals operand is reported");
let and = model
.diagnostics
.iter()
.find(|diagnostic| diagnostic.rule_id == "E8004" && diagnostic.message.contains("element 1"))
.expect("the malformed And operand is reported");
assert_eq!(equals.property_path.as_deref(), Some("Conditions/InvalidEquals/Fn::Equals/1"));
assert_eq!(and.property_path.as_deref(), Some("Conditions/InvalidAnd/Fn::And/1"));
}
#[test]
fn equals_operand_scalar_producing_function_is_accepted() {
let input = r#"{
"Parameters":{"X":{"Type":"String"}},
"Conditions":{
"C1":{"Fn::Equals":[{"Ref":"X"},"a"]},
"C2":{"Fn::Equals":[{"Fn::Select":[0,{"Fn::Split":[",","a,b"]}]},"a"]},
"C3":{"Fn::Equals":[{"Fn::Sub":"${X}"},"a"]}
},
"Resources":{"R":{"Type":"AWS::S3::Bucket","Condition":"C1"}}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert!(
!model.diagnostics.iter().any(|d| d.rule_id == "E8003"),
"E8003 must not fire for scalar-producing Fn::Equals operands"
);
}
#[test]
fn parser_tautological_condition_produces_w8003() {
let input = r#"
Conditions:
AlwaysTrue:
Fn::Equals: ["same", "same"]
Resources:
R:
Type: T
"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert!(model.diagnostics.iter().any(|d| d.rule_id == "W8003" && d.message.contains("AlwaysTrue")));
}
#[test]
fn parser_span_index_populated() {
let input = "Resources:\n MyBucket:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: test\n";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert!(model.span_index.contains_key("Resources/MyBucket"));
assert!(model.span_index.contains_key("Resources/MyBucket/Properties/BucketName"));
}
#[test]
fn sam_globals_merged_into_function() {
let model = model_from_fixture("good/transform_serverless_globals.yaml");
let func = model.resource("myFunction").unwrap();
assert!(model.sam_globals.contains_key("Function"));
assert!(model.sam_globals["Function"].contains_key("Runtime"));
assert!(func.properties.contains_key("Runtime"));
let runtime_span = model
.source_location("Resources/myFunction/Properties/Runtime")
.expect("inherited Runtime should retain its Globals source span");
assert_eq!(runtime_span.start_line, 5, "inherited Runtime should point to Globals.Function.Runtime");
}
#[test]
fn sam_implicit_resources_detected() {
let model = model_from_fixture("good/transform_serverless_globals.yaml");
assert!(model.sam_implicit_resources.contains("myFunctionRole"));
assert!(model.sam_implicit_resources.contains("ServerlessRestApi"));
}
#[test]
fn sam_globals_param_refs_collected() {
let model = model_from_fixture("good/transform_serverless_globals.yaml");
assert_eq!(model.globals_param_refs.len(), 0, "expected no param refs in globals for this template");
}
#[test]
fn dynamic_reference_resolves_to_typed_dynamic() {
let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"{{resolve:ssm:my-param}}"}}}}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
match model.resolve("R", "Properties.V") {
Some(ResolvedValue::TypedDynamic { reason: msg, param_type: t }) => {
assert_eq!(t, "String");
assert!(msg.contains("dynamic reference"));
assert!(msg.contains("{{resolve:ssm:my-param}}"), "reason should carry the literal, got {msg:?}");
}
other => panic!("Expected TypedDynamic, got {:?}", other),
}
}
#[test]
fn sub_with_implicit_getatt() {
let input =
r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"arn:${Other.Arn}"}}},"Other":{"Type":"T2"}}}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert!(model.graph.depends_on("R", "Other"));
}
#[test]
fn fn_if_edges_have_condition_context() {
let input = r#"
Conditions:
C:
Fn::Equals: ["a", "a"]
Resources:
R:
Type: T
Properties:
V:
Fn::If:
- C
- !Ref A
- !Ref B
A:
Type: T
B:
Type: T
"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let edges = model.graph.outgoing("R");
let a_edge = edges.iter().find(|e| e.target == "A").unwrap();
assert!(a_edge.condition_context.as_ref().unwrap().contains("C"));
let b_edge = edges.iter().find(|e| e.target == "B").unwrap();
assert!(b_edge.condition_context.as_ref().unwrap().contains("!C"));
}
#[test]
fn lifecycle_parameter_edges_keep_lifecycle_paths() {
let input = r#"
Transform: AWS::LanguageExtensions
Parameters:
Policy:
Type: String
Default: Retain
Resources:
R:
Type: T
DeletionPolicy: !Ref Policy
UpdateReplacePolicy: !Ref Policy
Properties:
Cpu: 256
"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let mut paths = model
.graph
.outgoing("R")
.into_iter()
.filter(|edge| edge.target == "Policy")
.map(|edge| edge.source_path.as_str())
.collect::<Vec<_>>();
paths.sort_unstable();
assert_eq!(paths, ["DeletionPolicy", "UpdateReplacePolicy"]);
assert!(!model.scenario_uses_unresolved_parameter("R", "Properties.Cpu", &HashMap::new()));
}
#[test]
fn resolve_scenarios_nested_conditionals() {
let input = r#"
Conditions:
A:
Fn::Equals: ["x", "x"]
B:
Fn::Equals: ["y", "y"]
Resources:
R:
Type: T
Properties:
V:
Fn::If:
- A
- Fn::If:
- B
- 1
- 2
- 3
"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let scenarios = model.resolve_scenarios("R", "Properties.V");
assert_eq!(scenarios.len(), 3, "expected 3 scenarios, got {:?}", scenarios);
}
const SCENARIO_PARAMETER_PROVENANCE_TEMPLATE: &str = r#"
Parameters:
Cpu:
Type: Number
Default: 8192
AllowedValues: [256, 8192]
CpuList:
Type: CommaDelimitedList
Default: "2048"
Environment:
Type: String
Default: small
AllowedValues: [small, large]
Prefix:
Type: String
Default: "1"
Suffix:
Type: String
Default: "024"
RuntimeCpu:
Type: AWS::SSM::Parameter::Value<String>
Default: /ecs/task/cpu
Mappings:
TaskSizes:
small: {Cpu: 256}
large: {Cpu: 8192}
Conditions:
UseParameter: !Equals [!Ref AWS::Region, us-east-1]
Resources:
R:
Type: T
Properties:
Direct: !Ref Cpu
List: !Ref CpuList
Split: !Split [",", !Ref Suffix]
Ssm: !Ref RuntimeCpu
Literal: 512
Joined: !Join ["", [!Ref Cpu]]
Mapped: !FindInMap [TaskSizes, !Ref Environment, Cpu]
Conditional: !If [UseParameter, !Ref Cpu, 512]
HoistedSelect:
Fn::Select:
- 0
- - Fn::If: [UseParameter, !Ref Cpu, 512]
HoistedJoin:
Fn::Join:
- ""
- Fn::If: [UseParameter, !Ref CpuList, ["512"]]
Substituted: !Sub "${Prefix}${Suffix}"
SubstitutedReverse: !Sub "${Suffix}${Prefix}"
Nested:
- Cpu: !Ref Cpu
"#;
fn parameter_provenance_model(overrides: &[(&str, &str)]) -> SemanticModel {
let config = ParseConfig {
parameters: overrides.iter().map(|(name, value)| ((*name).into(), (*value).into())).collect(),
..Default::default()
};
SemanticModel::parse(SCENARIO_PARAMETER_PROVENANCE_TEMPLATE.as_bytes(), config).unwrap().model
}
#[test]
fn scenario_unresolved_parameter_provenance_follows_intrinsics_and_condition_branches() {
let model = parameter_provenance_model(&[]);
let no_conditions = HashMap::new();
for path in [
"Properties.Direct",
"Properties.List.0",
"Properties.List.{}",
"Properties.Split.0",
"Properties.Ssm",
"Properties.Joined",
"Properties.Mapped",
"Properties.Substituted",
"Properties.SubstitutedReverse",
"Properties.Nested.{}.Cpu",
] {
assert!(
model.scenario_uses_unresolved_parameter("R", path, &no_conditions),
"{path} must retain unresolved parameter provenance"
);
}
assert!(!model.scenario_uses_unresolved_parameter("R", "Properties.Literal", &no_conditions));
for path in ["Properties.Conditional", "Properties.HoistedSelect", "Properties.HoistedJoin"] {
assert!(model.scenario_uses_unresolved_parameter("R", path, &HashMap::from([("UseParameter".into(), true)]),));
assert!(
!model.scenario_uses_unresolved_parameter("R", path, &HashMap::from([("UseParameter".into(), false)]),)
);
}
}
#[test]
fn scenario_unresolved_parameter_provenance_excludes_only_supplied_overrides() {
let all_overridden = parameter_provenance_model(&[
("Cpu", "256"),
("CpuList", "2048"),
("Environment", "small"),
("Prefix", "1"),
("Suffix", "024"),
("RuntimeCpu", "/ecs/task/cpu"),
]);
let no_conditions = HashMap::new();
for path in [
"Properties.Direct",
"Properties.List.0",
"Properties.List.{}",
"Properties.Split.0",
"Properties.Joined",
"Properties.Mapped",
"Properties.Substituted",
"Properties.SubstitutedReverse",
"Properties.Nested.{}.Cpu",
] {
assert!(
!all_overridden.scenario_uses_unresolved_parameter("R", path, &no_conditions),
"{path} must treat caller overrides as concrete values"
);
}
assert!(all_overridden.scenario_uses_unresolved_parameter("R", "Properties.Ssm", &no_conditions));
assert!(!all_overridden.scenario_uses_unresolved_parameter("R", "Properties.Literal", &no_conditions));
for path in ["Properties.Conditional", "Properties.HoistedSelect", "Properties.HoistedJoin"] {
assert!(!all_overridden.scenario_uses_unresolved_parameter(
"R",
path,
&HashMap::from([("UseParameter".into(), true)]),
));
}
for partial_overrides in [[("Prefix", "1")], [("Suffix", "024")]] {
let partially_overridden = parameter_provenance_model(&partial_overrides);
for path in ["Properties.Substituted", "Properties.SubstitutedReverse"] {
assert!(
partially_overridden.scenario_uses_unresolved_parameter("R", path, &no_conditions),
"{path} must retain the parameter that was not overridden"
);
}
}
}
#[test]
fn source_location_returns_span() {
let input = "Resources:\n R:\n Type: T\n Properties:\n Name: hello\n";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let span = model.source_location("Resources/R").expect("expected span for Resources/R");
assert!(span.start_line > 0, "start_line should be > 0, got {}", span.start_line);
}
#[test]
fn source_location_missing_returns_none() {
let input = "Resources:\n R:\n Type: T\n";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
assert_eq!(model.source_location("Resources/NonExistent"), None, "nonexistent path should return None");
}
#[test]
fn span_provider_trait_works() {
let input = "Resources:\n R:\n Type: T\n";
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let span = SpanProvider::source_location(&model, "Resources/R");
assert!(span.is_some(), "SpanProvider should return span for Resources/R");
}
#[test]
fn verify_diagnostic_json_contract() {
let model = model_from_fixture("good/minimal.yaml");
let diag = model.to_diagnostic_json();
let json = serde_json::to_value(&diag).unwrap();
let obj = json.as_object().unwrap();
let expected_keys: Vec<&str> = vec![
"template",
"parameters",
"conditions",
"conditionParamRefs",
"conditionImplications",
"conditionMutexGroups",
"conditionExclusions",
"resourceConditionMap",
"mappings",
"resources",
"outputs",
"edges",
"cycles",
"outputEmptyJoins",
"samImplicitResources",
"globalsParamRefs",
"isCdk",
"fnIfConditions",
"findInMapNames",
"paramsReferencedInDefinitions",
"hasDynamicFindinmapName",
"hasParseErrors",
"parsedRules",
"resolutionSources",
];
for key in &expected_keys {
assert!(obj.contains_key(*key), "Missing top-level key: {}", key);
}
for key in obj.keys() {
assert!(expected_keys.contains(&key.as_str()), "Unexpected top-level key: {}", key);
}
let tmpl = obj["template"].as_object().unwrap();
for key in &["formatVersion", "description", "transforms"] {
assert!(tmpl.contains_key(*key), "Template missing key: {}", key);
}
for (_id, res) in obj["resources"].as_object().unwrap() {
let r = res.as_object().unwrap();
for key in &[
"resourceType",
"condition",
"dependsOn",
"deletionPolicy",
"updateReplacePolicy",
"properties",
"outgoingRefs",
"incomingRefs",
"findInMapRefs",
"simpleSubs",
"redundantSubs",
"emptyJoins",
"hardcodedPartitionArns",
"conditionallyNullProps",
"conditionRefs",
"forEachExpansions",
"unsubstitutedVariables",
"invalidRefs",
] {
assert!(r.contains_key(*key), "Resource missing key: {}", key);
}
}
for (_id, out) in obj["outputs"].as_object().unwrap() {
let o = out.as_object().unwrap();
for key in &["value", "description", "condition", "exportName", "getattRefs"] {
assert!(o.contains_key(*key), "Output missing key: {}", key);
}
}
for edge in obj["edges"].as_array().unwrap() {
let e = edge.as_object().unwrap();
for key in &["source", "sourcePath", "target", "kind"] {
assert!(e.contains_key(*key), "Edge missing key: {}", key);
}
}
}
#[test]
fn rules_section_ref_inside_fn_contains_emits_edge() {
let input = r#"{
"Parameters": {
"BootstrapVersion": {"Type": "String"}
},
"Resources": {
"B": {"Type": "AWS::S3::Bucket"}
},
"Rules": {
"CheckBootstrapVersion": {
"Assertions": [{
"Assert": {
"Fn::Not": [{
"Fn::Contains": [
["1", "2", "3", "4", "5"],
{"Ref": "BootstrapVersion"}
]
}]
}
}]
}
}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let edges = model
.graph
.outgoing("__rule__CheckBootstrapVersion")
.into_iter()
.filter(|e| e.target == "BootstrapVersion")
.count();
assert_eq!(edges, 1, "expected a single Ref edge from __rule__CheckBootstrapVersion to BootstrapVersion");
}
#[test]
fn rules_section_ref_in_rule_condition_emits_edge() {
let input = r#"{
"Parameters": {
"Env": {"Type": "String"}
},
"Resources": {
"B": {"Type": "AWS::S3::Bucket"}
},
"Rules": {
"ProdOnly": {
"RuleCondition": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"Assertions": [{
"Assert": {"Fn::Equals": ["a", "a"]}
}]
}
}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let env_edges = model.graph.outgoing("__rule__ProdOnly").into_iter().filter(|e| e.target == "Env").count();
assert!(env_edges >= 1, "expected Ref edge from __rule__ProdOnly to Env (RuleCondition)");
}
#[test]
fn rules_section_value_of_emits_parameter_ref_edge() {
let input = r#"{
"Parameters": {
"Subnets": {"Type": "List<AWS::EC2::Subnet::Id>"}
},
"Resources": {
"B": {"Type": "AWS::S3::Bucket"}
},
"Rules": {
"VpcCheck": {
"Assertions": [{
"Assert": {
"Fn::EachMemberIn": [
{"Fn::ValueOfAll": ["Subnets", "VpcId"]},
{"Fn::RefAll": "AWS::EC2::VPC::Id"}
]
}
}]
}
}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let subnet_edges = model.graph.outgoing("__rule__VpcCheck").into_iter().filter(|e| e.target == "Subnets").count();
assert!(subnet_edges >= 1, "expected Ref edge from __rule__VpcCheck to Subnets (Fn::ValueOfAll first arg)");
}
#[test]
fn rules_section_ref_appears_in_diagnostic_edges_array() {
let input = r#"{
"Parameters": {
"BootstrapVersion": {"Type": "String"}
},
"Resources": {
"B": {"Type": "AWS::S3::Bucket"}
},
"Rules": {
"CheckBootstrapVersion": {
"Assertions": [{
"Assert": {
"Fn::Not": [{
"Fn::Contains": [
["1", "2", "3", "4", "5"],
{"Ref": "BootstrapVersion"}
]
}]
}
}]
}
}
}"#;
let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
let json = serde_json::to_value(model.to_diagnostic_json()).unwrap();
let edges = json["edges"].as_array().expect("edges array");
let referenced = edges.iter().any(|e| {
e.get("target").and_then(|t| t.as_str()) == Some("BootstrapVersion")
&& e.get("source").and_then(|s| s.as_str()).is_some_and(|s| s.starts_with("__rule__"))
});
assert!(
referenced,
"diagnostic edges array must contain a Ref to BootstrapVersion sourced from a Rules pseudo-resource"
);
}
#[test]
fn rules_equals_getatt_operand_reports_once() {
let yaml = b"
Rules:
R1:
Assertions:
- Assert: !Equals [!GetAtt B.Arn, x]
Resources:
B:
Type: AWS::S3::Bucket
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let e8003 = model.diagnostics.iter().filter(|d| d.rule_id == "E8003").count();
let f8611 = model.diagnostics.iter().filter(|d| d.rule_id == "F8611").count();
assert_eq!(e8003, 1, "operand type finding fires once: {:?}", model.diagnostics);
assert_eq!(f8611, 0, "allowlist walk must not double-report the operand: {:?}", model.diagnostics);
}
#[test]
fn e3001_missing_type_produces_diagnostic() {
let input = br#"{"Resources":{"R":{"Properties":{"K":"V"}}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "expected one E3001 for missing Type: {:?}", findings);
assert!(findings[0].message.contains("missing required property 'Type'"), "message: {}", findings[0].message);
}
#[test]
fn e3001_non_object_resource_body_produces_diagnostic() {
let yaml = b"
Resources:
R: a string value
S:
Type: AWS::S3::Bucket
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "expected one E3001 for non-object body: {:?}", findings);
assert!(findings[0].message.contains("must be an object"), "message: {}", findings[0].message);
}
#[test]
fn e3001_non_string_type_produces_diagnostic() {
let input = br#"{"Resources":{"R":{"Type":42,"Properties":{"K":"V"}}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "expected one E3001 for non-string Type: {:?}", findings);
assert!(findings[0].message.contains("'Type' must be a string"), "message: {}", findings[0].message);
}
#[test]
fn e3001_unknown_attribute_produces_diagnostic() {
let input = br#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","Bogus":"x","Properties":{}}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "expected one E3001 for unknown attribute: {:?}", findings);
assert!(findings[0].message.contains("invalid property 'Bogus'"), "message: {}", findings[0].message);
}
#[test]
fn e3001_condition_must_be_string() {
let yaml = b"
Resources:
R:
Type: AWS::S3::Bucket
Condition: true
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "expected one E3001 for boolean Condition: {:?}", findings);
assert!(findings[0].message.contains("'Condition' must be a string"), "message: {}", findings[0].message);
}
#[test]
fn e3001_depends_on_must_be_string_or_list() {
let input = br#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","DependsOn":123}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "expected one E3001 for numeric DependsOn: {:?}", findings);
assert!(findings[0].message.contains("must be a string or list of strings"), "message: {}", findings[0].message);
}
#[test]
fn e3001_depends_on_list_elements_must_be_strings() {
let input = br#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","DependsOn":["A",123]}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "expected one E3001 for non-string list element: {:?}", findings);
assert!(findings[0].message.contains("list elements must be strings"), "message: {}", findings[0].message);
}
#[test]
fn e3001_valid_resource_no_findings() {
let input = br#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"b"},"Condition":"C","DependsOn":["X"],"Metadata":{},"DeletionPolicy":"Retain","UpdateReplacePolicy":"Retain","UpdatePolicy":{},"CreationPolicy":{}}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert!(findings.is_empty(), "no E3001 for valid resource: {:?}", findings);
}
#[test]
fn e3001_version_attribute_accepted_for_custom_resource() {
let input = br#"{"Resources":{"R":{"Type":"Custom::Thing","Version":"1.0"}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert!(findings.is_empty(), "Version is valid for a custom resource: {:?}", findings);
}
#[test]
fn e3001_version_attribute_rejected_for_standard_resource() {
let input = br#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","Version":"1.0"}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "Version is not valid for a standard resource: {:?}", findings);
assert!(findings[0].message.contains("only valid for custom resources"), "message: {}", findings[0].message);
}
#[test]
fn e3001_plain_transform_attribute_is_rejected() {
let input =
br#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","Transform":{"Name":"AWS::Include"},"Properties":{}}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "plain Transform is not a resource attribute: {:?}", findings);
assert!(findings[0].message.contains("invalid property 'Transform'"), "message: {}", findings[0].message);
}
#[test]
fn e3001_lifecycle_policies_must_be_objects() {
let yaml = b"
Resources:
Creation:
Type: AWS::AutoScaling::AutoScalingGroup
CreationPolicy: invalid
Update:
Type: AWS::AutoScaling::AutoScalingGroup
UpdatePolicy: 7
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 2, "both scalar lifecycle policies must be rejected: {:?}", findings);
assert!(findings.iter().any(|finding| finding.message.contains("'CreationPolicy' must be an object")));
assert!(findings.iter().any(|finding| finding.message.contains("'UpdatePolicy' must be an object")));
}
#[test]
fn lifecycle_attribute_status_tracks_effective_presence_and_shape() {
let yaml = b"
Parameters:
Env:
Type: String
Policy:
Type: String
Conditions:
Never: !Equals [always, never]
Maybe: !Equals [!Ref Env, enabled]
Resources:
Absent:
Type: AWS::S3::Bucket
Object:
Type: AWS::S3::Bucket
UpdatePolicy: {}
Scalar:
Type: AWS::AutoScaling::AutoScalingGroup
UpdatePolicy: 7
Intrinsic:
Type: AWS::AutoScaling::AutoScalingGroup
UpdatePolicy: !Ref Policy
Impossible:
Type: AWS::S3::Bucket
UpdatePolicy: !If [Never, {AutoScalingRollingUpdate: {}}, !Ref AWS::NoValue]
ImpossibleResource:
Type: AWS::S3::Bucket
Condition: Never
UpdatePolicy: {}
Conditional:
Type: AWS::S3::Bucket
UpdatePolicy: !If [Maybe, {AutoScalingRollingUpdate: {}}, !Ref AWS::NoValue]
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let absent = model.lifecycle_attribute_status("Absent", "UpdatePolicy");
assert!(!absent.may_be_present);
assert_eq!(absent.invalid_value, None);
let object = model.lifecycle_attribute_status("Object", "UpdatePolicy");
assert!(object.may_be_present);
assert_eq!(object.invalid_value, None);
let scalar = model.lifecycle_attribute_status("Scalar", "UpdatePolicy");
assert!(scalar.may_be_present);
assert_eq!(scalar.invalid_value.as_deref(), Some("7"));
let intrinsic = model.lifecycle_attribute_status("Intrinsic", "UpdatePolicy");
assert!(intrinsic.may_be_present);
assert_eq!(intrinsic.invalid_value, None);
let impossible = model.lifecycle_attribute_status("Impossible", "UpdatePolicy");
assert!(!impossible.may_be_present);
assert_eq!(impossible.invalid_value, None);
let impossible_resource = model.lifecycle_attribute_status("ImpossibleResource", "UpdatePolicy");
assert!(!impossible_resource.may_be_present);
assert_eq!(impossible_resource.invalid_value, None);
let conditional = model.lifecycle_attribute_status("Conditional", "UpdatePolicy");
assert!(conditional.may_be_present);
assert_eq!(conditional.invalid_value, None);
}
#[test]
fn e3001_custom_resources_reject_lifecycle_policies() {
let yaml = b"
Resources:
R:
Type: AWS::CloudFormation::CustomResource
CreationPolicy: {}
UpdatePolicy: {}
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 2, "custom resources cannot use lifecycle policies: {:?}", findings);
assert!(findings.iter().all(|finding| finding.message.contains("not valid for custom resources")));
}
#[test]
fn e3001_intrinsic_lifecycle_policy_shape_is_deferred() {
let yaml = b"
Parameters:
Policy:
Type: String
Resources:
R:
Type: AWS::AutoScaling::AutoScalingGroup
CreationPolicy: !Ref Policy
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert!(findings.is_empty(), "an intrinsic policy shape is not statically known: {:?}", findings);
}
#[test]
fn e3001_sam_connectors_accepted_with_transform() {
let yaml = b"
Transform: AWS::Serverless-2016-10-31
Resources:
F:
Type: AWS::Serverless::Function
Connectors:
MyConn:
Properties:
Destination:
Id: F
Permissions:
- Read
Properties:
Runtime: python3.11
Handler: index.handler
CodeUri: ./src
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert!(findings.is_empty(), "Connectors is valid under SAM transform: {:?}", findings);
}
#[test]
fn e3001_sam_ignore_globals_accepted_with_transform() {
let yaml = b"
Transform: AWS::Serverless-2016-10-31
Resources:
F:
Type: AWS::Serverless::Function
IgnoreGlobals: true
Properties:
Runtime: python3.11
Handler: index.handler
CodeUri: ./src
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert!(findings.is_empty(), "IgnoreGlobals is valid under SAM transform: {:?}", findings);
}
#[test]
fn e3001_sam_connectors_rejected_without_transform() {
let yaml = b"
Resources:
R:
Type: AWS::S3::Bucket
Connectors:
MyConn:
Properties:
Destination:
Id: R
Properties:
BucketName: b
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "Connectors without SAM transform is invalid: {:?}", findings);
assert!(findings[0].message.contains("invalid property 'Connectors'"), "message: {}", findings[0].message);
}
#[test]
fn e3001_multiple_violations_per_resource() {
let input = br#"{"Resources":{"R":{"Properties":{"K":"V"},"Bogus":"x"}}}"#;
let model = SemanticModel::from_bytes(input).unwrap();
let findings: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E3001").collect();
assert!(findings.len() >= 2, "expected at least 2 E3001 (missing Type + unknown attribute): {:?}", findings);
}
#[test]
fn resource_macros_are_not_resource_shape_or_logical_id_errors() {
let yaml = b"
Resources:
Bucket:
Type: AWS::S3::Bucket
Fn::Transform:
Name: AWS::Include
Parameters:
Location: s3://example/fragment.yaml
Fn::Transform:
Name: AWS::Include
Parameters:
Location: s3://example/resources.yaml
";
let model = SemanticModel::from_bytes(yaml).expect("resource macros parse");
let resource_shape_findings: Vec<_> =
model.diagnostics.iter().filter(|finding| finding.rule_id == "E3001").collect();
assert!(
resource_shape_findings.is_empty(),
"valid resource macros must not be rejected: {resource_shape_findings:?}"
);
assert!(
model.resources.get("Bucket").is_some_and(|resource| resource.properties_dynamic),
"a resource-level macro can supply or replace Properties"
);
}
#[test]
fn fn_transform_list_value_is_a_structural_error() {
let yaml = b"
Resources:
Bucket:
Type: AWS::S3::Bucket
Metadata:
Included:
Fn::Transform:
- Name: AWS::Include
Parameters:
Location: s3://example/fragment.yaml
";
let model = SemanticModel::from_bytes(yaml).expect("malformed transform still produces a semantic model");
let findings: Vec<_> = model.diagnostics.iter().filter(|finding| finding.rule_id == "F1101").collect();
assert_eq!(findings.len(), 1, "list-form Fn::Transform must produce one structural finding: {findings:?}");
assert_eq!(findings[0].message, "Fn::Transform: Fn::Transform value must be an object");
}
#[test]
fn fn_transform_parameters_emit_reference_edges() {
let yaml = b"
Parameters:
IncludeBaseUrl:
Type: String
Resources:
Bucket:
Type: AWS::S3::Bucket
Metadata:
Included:
Fn::Transform:
Name: AWS::Include
Parameters:
Location: !Sub '${IncludeBaseUrl}/fragment.yaml'
";
let model = SemanticModel::from_bytes(yaml).expect("valid transform parses");
let matching_edges: Vec<_> =
model.graph.outgoing("Bucket").into_iter().filter(|edge| edge.target == "IncludeBaseUrl").collect();
assert_eq!(matching_edges.len(), 1, "transform parameter must reference IncludeBaseUrl: {matching_edges:?}");
assert_eq!(matching_edges[0].source_path, "Metadata.Included.Fn::Transform.Parameters.Location");
}
#[test]
fn custom_resource_version_must_be_string_or_integer() {
let yaml = b"
Resources:
ProviderBacked:
Type: Custom::Thing
Version: false
Properties:
ServiceToken: arn:aws:lambda:us-east-1:123456789012:function:provider
";
let model = SemanticModel::from_bytes(yaml).expect("custom resource parses");
let findings: Vec<_> = model.diagnostics.iter().filter(|finding| finding.rule_id == "E3001").collect();
assert_eq!(findings.len(), 1, "invalid Version shape must produce one finding: {findings:?}");
assert_eq!(findings[0].resource_id.as_deref(), Some("ProviderBacked"));
assert!(findings[0].message.contains("must be a string or integer"), "unexpected message: {}", findings[0].message);
}
#[test]
fn every_malformed_condition_body_is_reported_at_its_condition_path() {
let yaml = b"
Parameters:
P:
Type: String
Conditions:
ScalarBody: not-a-condition
EmptyBody: {}
MultiBody:
Fn::Equals: [!Ref P, prod]
Fn::Not: [!Equals [!Ref P, dev]]
UnknownFunction:
Fn::Of: [true]
BareRef: !Ref P
Resources:
Topic:
Type: AWS::SNS::Topic
";
let model = SemanticModel::from_bytes(yaml).expect("condition template parses");
let mut paths: Vec<_> = model
.diagnostics
.iter()
.filter(|finding| finding.rule_id == "E8001")
.filter_map(|finding| finding.property_path.clone())
.collect();
paths.sort();
assert_eq!(
paths,
[
"Conditions/BareRef",
"Conditions/EmptyBody",
"Conditions/MultiBody",
"Conditions/ScalarBody",
"Conditions/UnknownFunction",
]
);
}
#[test]
fn foreach_generated_condition_body_is_validated_after_expansion() {
let yaml = b"
Transform: AWS::LanguageExtensions
Conditions:
Fn::ForEach::Conditions:
- Identifier
- [One]
- Bad${Identifier}: not-a-condition
Resources:
Topic:
Type: AWS::SNS::Topic
";
let model = SemanticModel::from_bytes(yaml).expect("ForEach condition template parses");
let findings: Vec<_> = model.diagnostics.iter().filter(|finding| finding.rule_id == "E8001").collect();
assert_eq!(findings.len(), 1, "expanded malformed condition must be reported once: {findings:?}");
assert_eq!(findings[0].property_path.as_deref(), Some("Conditions/BadOne"));
}
#[test]
fn intrinsic_argument_shape_errors_have_precise_paths() {
let yaml = b"
Conditions:
IsProd: !Equals [!Ref AWS::Region, us-east-1]
Resources:
Bucket:
Type: AWS::S3::Bucket
Metadata:
Encoded:
Fn::Base64: [not, a-string]
Properties:
BucketName:
Fn::If:
- Fn::Equals: [!Ref AWS::Region, us-east-1]
- prod-bucket
- other-bucket
";
let model = SemanticModel::from_bytes(yaml).expect("intrinsic shape template parses");
let base64 = model.diagnostics.iter().find(|finding| finding.rule_id == "E1021").expect("Base64 list is rejected");
assert_eq!(base64.property_path.as_deref(), Some("Metadata.Encoded.Fn::Base64"));
let condition =
model.diagnostics.iter().find(|finding| finding.rule_id == "E1028").expect("If expression is rejected");
assert_eq!(condition.property_path.as_deref(), Some("Properties.BucketName.Fn::If.0"));
}
#[test]
fn sub_literal_escapes_resolve_and_unresolved_variables_record_sub_edges() {
let yaml = b"
Resources:
R:
Type: Test::Resource
Properties:
Escaped: !Sub 'literal-${!NotAReference}-end'
Missing: !Sub 'prefix-${MissingTarget}'
";
let model = SemanticModel::from_bytes(yaml).expect("Sub template parses");
match model.resolve("R", "Properties.Escaped") {
Some(ResolvedValue::Concrete { value }) => {
assert_eq!(value.as_str(), Some("literal-${NotAReference}-end"));
}
other => panic!("literal escape must resolve concretely, got {other:?}"),
}
let missing_edge = model
.graph
.outgoing("R")
.into_iter()
.find(|edge| edge.target == "MissingTarget")
.expect("unresolved Sub variable records an edge");
assert!(matches!(missing_edge.kind, template_model::resolver::RefKind::Sub { .. }));
assert_eq!(missing_edge.source_path, "Properties.Missing");
}
#[test]
fn parameter_allowed_values_preserve_float_and_boolean_scalars() {
let yaml = b"
Parameters:
Fraction:
Type: Number
Default: 1.5
AllowedValues: [1.5, 2.5]
Flag:
Type: String
Default: true
AllowedValues: [true, false]
Resources:
R:
Type: Test::Resource
";
let model = SemanticModel::from_bytes(yaml).expect("parameter template parses");
assert_eq!(
model.parameters["Fraction"].allowed_values.as_deref(),
Some(&["1.5".to_string(), "2.5".to_string()][..])
);
assert_eq!(
model.parameters["Flag"].allowed_values.as_deref(),
Some(&["true".to_string(), "false".to_string()][..])
);
}
#[test]
fn lifecycle_policy_scenarios_expands_conditional_branches() {
let yaml = b"
Parameters:
Policy:
Type: String
Conditions:
Never: !Equals [always, never]
Maybe: !Equals [!Ref AWS::Region, us-east-1]
Resources:
ValidConditional:
Type: AWS::S3::Bucket
DeletionPolicy: !If [Maybe, Retain, Delete]
DirectNoValue:
Type: AWS::S3::Bucket
DeletionPolicy: !Ref AWS::NoValue
JoinPolicy:
Type: AWS::S3::Bucket
DeletionPolicy: !Join ['', [Re, tain]]
StackIdPolicy:
Type: AWS::S3::Bucket
DeletionPolicy: !Ref AWS::StackId
ImpossibleBranch:
Type: AWS::S3::Bucket
DeletionPolicy: !If [Never, InvalidPolicy, !Ref AWS::NoValue]
ImpossibleResource:
Type: AWS::S3::Bucket
Condition: Never
DeletionPolicy: NotValid
BothBranchesLiteral:
Type: AWS::S3::Bucket
DeletionPolicy: !If [Maybe, WrongOne, AlsoWrong]
CorrelatedResource:
Type: AWS::S3::Bucket
Condition: Maybe
DeletionPolicy: !If [Maybe, Retain, InvalidPolicy]
DynamicObject:
Type: AWS::S3::Bucket
DeletionPolicy:
Value: !Ref Policy
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let valid = model.lifecycle_policy_scenarios("ValidConditional", "DeletionPolicy");
assert_eq!(valid.len(), 2, "both branches are reachable: {:?}", valid);
let values: Vec<&str> = valid.iter().filter_map(|(v, _)| v.as_str()).collect();
assert!(values.contains(&"Retain"));
assert!(values.contains(&"Delete"));
let novalue = model.lifecycle_policy_scenarios("DirectNoValue", "DeletionPolicy");
assert_eq!(novalue.len(), 1, "direct NoValue remains an invalid null value: {:?}", novalue);
assert!(novalue[0].0.is_null());
let join = model.lifecycle_policy_scenarios("JoinPolicy", "DeletionPolicy");
assert_eq!(join.len(), 1, "Join resolves before lifecycle value validation: {join:?}");
assert_eq!(join[0].0.as_str(), Some("Retain"));
let stack_id = model.lifecycle_policy_scenarios("StackIdPolicy", "DeletionPolicy");
assert_eq!(stack_id.len(), 1, "StackId has a deterministic pseudo-parameter value: {stack_id:?}");
assert!(
stack_id[0].0.as_str().is_some_and(|value| value.starts_with("arn:") && value.contains(":stack/")),
"StackId must reach final-value validation as a concrete ARN: {stack_id:?}"
);
let impossible = model.lifecycle_policy_scenarios("ImpossibleBranch", "DeletionPolicy");
assert_eq!(impossible.len(), 1, "the reachable NoValue branch remains invalid: {:?}", impossible);
assert!(impossible[0].0.is_null());
let unreachable = model.lifecycle_policy_scenarios("ImpossibleResource", "DeletionPolicy");
assert!(unreachable.is_empty(), "unreachable resource: {:?}", unreachable);
let both_invalid = model.lifecycle_policy_scenarios("BothBranchesLiteral", "DeletionPolicy");
assert_eq!(both_invalid.len(), 2, "both branches are reachable: {:?}", both_invalid);
let correlated = model.lifecycle_policy_scenarios("CorrelatedResource", "DeletionPolicy");
assert_eq!(correlated.len(), 1, "resource condition excludes the false branch: {:?}", correlated);
assert_eq!(correlated[0].0.as_str(), Some("Retain"));
let dynamic_object = model.lifecycle_policy_scenarios("DynamicObject", "DeletionPolicy");
assert_eq!(dynamic_object.len(), 1, "an authored object remains a concrete invalid shape");
assert!(dynamic_object[0].0.is_object());
}
#[test]
fn lifecycle_policy_scenarios_defers_on_dynamic_ref() {
let yaml = b"
Parameters:
Policy:
Type: String
Resources:
RefPolicy:
Type: AWS::S3::Bucket
DeletionPolicy: !Ref Policy
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let scenarios = model.lifecycle_policy_scenarios("RefPolicy", "DeletionPolicy");
assert!(scenarios.is_empty(), "dynamic ref should defer (no scenarios): {:?}", scenarios);
}
#[test]
fn lifecycle_attribute_status_respects_conditions_for_all_attributes() {
let yaml = b"
Parameters:
Policy:
Type: String
Conditions:
Never: !Equals [always, never]
Maybe: !Equals [!Ref AWS::Region, us-east-1]
Resources:
DirectNoValue:
Type: AWS::S3::Bucket
CreationPolicy: !Ref AWS::NoValue
ImpossibleBranch:
Type: AWS::S3::Bucket
CreationPolicy: !If [Never, {ResourceSignal: {Count: 1}}, !Ref AWS::NoValue]
ImpossibleResource:
Type: AWS::S3::Bucket
Condition: Never
CreationPolicy:
ResourceSignal: {Count: 1}
MaybePresent:
Type: AWS::S3::Bucket
CreationPolicy: !If [Maybe, {ResourceSignal: {Count: 1}}, !Ref AWS::NoValue]
DeletionNoValue:
Type: AWS::S3::Bucket
DeletionPolicy: !Ref AWS::NoValue
DeletionAlwaysAbsent:
Type: AWS::S3::Bucket
DeletionPolicy: !If [Maybe, !Ref AWS::NoValue, !Ref AWS::NoValue]
DeletionDynamic:
Type: AWS::S3::Bucket
DeletionPolicy: !Ref Policy
UpdateAlwaysAbsent:
Type: AWS::AutoScaling::AutoScalingGroup
UpdatePolicy: !If [Maybe, !Ref AWS::NoValue, !Ref AWS::NoValue]
UpdateMaybePresent:
Type: AWS::AutoScaling::AutoScalingGroup
UpdatePolicy: !If [Maybe, {AutoScalingRollingUpdate: {}}, !Ref AWS::NoValue]
";
let model = SemanticModel::from_bytes(yaml).unwrap();
let direct = model.lifecycle_attribute_status("DirectNoValue", "CreationPolicy");
assert!(direct.may_be_present, "illegal NoValue remains an authored CreationPolicy");
let impossible = model.lifecycle_attribute_status("ImpossibleBranch", "CreationPolicy");
assert!(impossible.may_be_present, "a root CreationPolicy Fn::If remains authored");
let resource = model.lifecycle_attribute_status("ImpossibleResource", "CreationPolicy");
assert!(!resource.may_be_present, "impossible resource means absent");
let maybe = model.lifecycle_attribute_status("MaybePresent", "CreationPolicy");
assert!(maybe.may_be_present, "reachable branch is present");
let deletion_novalue = model.lifecycle_attribute_status("DeletionNoValue", "DeletionPolicy");
assert!(deletion_novalue.may_be_present, "illegal NoValue is not policy omission");
let deletion_always_absent = model.lifecycle_attribute_status("DeletionAlwaysAbsent", "DeletionPolicy");
assert!(deletion_always_absent.may_be_present, "NoValue branches are not policy omission");
let deletion_dynamic = model.lifecycle_attribute_status("DeletionDynamic", "DeletionPolicy");
assert!(deletion_dynamic.may_be_present);
let update_absent = model.lifecycle_attribute_status("UpdateAlwaysAbsent", "UpdatePolicy");
assert!(!update_absent.may_be_present, "UpdatePolicy still supports conditional NoValue omission");
let update_maybe = model.lifecycle_attribute_status("UpdateMaybePresent", "UpdatePolicy");
assert!(update_maybe.may_be_present, "a reachable UpdatePolicy object remains present");
}
#[test]
fn lifecycle_policy_intrinsics_and_creation_policy_if_do_not_require_a_transform() {
let yaml = b"
Parameters:
Policy:
Type: String
AllowedValues: [Retain, Delete]
Conditions:
Never: !Equals [always, never]
Maybe: !Equals [!Ref Policy, Retain]
Resources:
CreationConditional:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !If
- Maybe
- ResourceSignal: {Count: 1}
- ResourceSignal: {Count: 2}
DeletionConditional:
Type: AWS::CloudFormation::WaitConditionHandle
DeletionPolicy: !If [Maybe, Retain, Delete]
UpdateRef:
Type: AWS::CloudFormation::WaitConditionHandle
UpdateReplacePolicy: !Ref Policy
Unreachable:
Type: AWS::CloudFormation::WaitConditionHandle
Condition: Never
CreationPolicy: !Ref Policy
DeletionPolicy: !Ref Policy
";
let model = SemanticModel::from_bytes(yaml).expect("lifecycle placement template parses");
let findings: Vec<_> = model.diagnostics.iter().filter(|finding| finding.rule_id == "F1101").collect();
assert!(findings.is_empty(), "valid lifecycle intrinsics must not produce placement findings: {findings:?}");
}
#[test]
fn lifecycle_policy_intrinsics_have_no_function_or_ref_placement_allowlist() {
let yaml = b"
Parameters:
Policy:
Type: String
AllowedValues: [Retain, Delete]
Mappings:
Policies:
us-east-1:
Deletion: Retain
Conditions:
Never: !Equals [always, never]
Maybe: !Equals [!Ref Policy, Retain]
Resources:
Target:
Type: AWS::CloudFormation::WaitConditionHandle
ValidConditional:
Type: AWS::CloudFormation::WaitConditionHandle
DeletionPolicy: !If
- Maybe
- !FindInMap [Policies, !Ref AWS::Region, Deletion]
- !Ref Policy
ValidSub:
Type: AWS::CloudFormation::WaitConditionHandle
DeletionPolicy: !Sub
- '${PolicyName}'
- PolicyName: Retain
ValidSelect:
Type: AWS::CloudFormation::WaitConditionHandle
UpdateReplacePolicy: !Select [1, [Delete, Retain]]
ValidJoin:
Type: AWS::CloudFormation::WaitConditionHandle
DeletionPolicy: !Join ['', [Re, tain]]
CreationConditional:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !If [Maybe, {ResourceSignal: {Count: 1}}, {ResourceSignal: {Count: 2}}]
NoValueRef:
Type: AWS::CloudFormation::WaitConditionHandle
UpdateReplacePolicy: !Ref AWS::NoValue
StackIdRef:
Type: AWS::CloudFormation::WaitConditionHandle
DeletionPolicy: !Ref AWS::StackId
ResourceRef:
Type: AWS::CloudFormation::WaitConditionHandle
DeletionPolicy: !Ref Target
UnreachableJoin:
Type: AWS::CloudFormation::WaitConditionHandle
Condition: Never
DeletionPolicy: !Join ['', [Re, tain]]
";
let model = SemanticModel::from_bytes(yaml).expect("lifecycle intrinsic template parses");
let findings: Vec<_> = model.diagnostics.iter().filter(|finding| finding.rule_id == "F1101").collect();
assert!(findings.is_empty(), "lifecycle policy functions and Ref targets are not placement errors: {findings:?}");
}
#[test]
fn creation_policy_root_intrinsics_match_service_shape_contract() {
let yaml = b"
Parameters:
CreationValue:
Type: String
Default: not-an-object
Toggle:
Type: String
AllowedValues: [enabled, disabled]
Mappings:
CreationValues:
Primary:
Policy: not-an-object
Conditions:
Maybe: !Equals [!Ref Toggle, enabled]
Never: !Equals [always, never]
Resources:
ValidIf:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !If
- Maybe
- ResourceSignal: {Count: 1}
- ResourceSignal: {Count: 2}
NestedIf:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !If
- Maybe
- !If [Never, invalid, {ResourceSignal: {Count: 1}}]
- ResourceSignal: {Count: 2}
ImpossibleScalarBranch:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !If [Never, invalid, {ResourceSignal: {Count: 1}}]
CorrelatedScalarBranch:
Type: AWS::CloudFormation::WaitCondition
Condition: Maybe
CreationPolicy: !If [Maybe, {ResourceSignal: {Count: 1}}, invalid]
ReachableScalarBranch:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !If [Maybe, {ResourceSignal: {Count: 1}}, invalid]
RootRef:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !Ref CreationValue
RootFindInMap:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !FindInMap [CreationValues, Primary, Policy]
RootSelect:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !Select
- 0
- - ResourceSignal: {Count: 1}
- ResourceSignal: {Count: 2}
RootSub:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy: !Sub
- '${Value}'
- Value: not-an-object
NestedFunctions:
Type: AWS::CloudFormation::WaitCondition
CreationPolicy:
ResourceSignal:
Count: !Select [0, [1, 2]]
Timeout: !Sub
- 'PT${Minutes}M'
- Minutes: 10
";
let model = SemanticModel::from_bytes(yaml).expect("CreationPolicy matrix parses");
let findings: Vec<_> = model.diagnostics.iter().filter(|finding| finding.rule_id == "F1101").collect();
assert_eq!(findings.len(), 5, "four rejected roots and one reachable scalar branch: {findings:?}");
for resource_id in ["RootRef", "RootFindInMap", "RootSelect", "RootSub"] {
assert!(
findings.iter().any(|finding| {
finding.resource_id.as_deref() == Some(resource_id)
&& finding.message.contains("not supported as a top-level CreationPolicy")
}),
"missing root CreationPolicy finding for {resource_id}: {findings:?}"
);
}
assert!(
findings.iter().any(|finding| {
finding.resource_id.as_deref() == Some("ReachableScalarBranch")
&& finding.message.contains("Fn::If branch in CreationPolicy must be an object")
}),
"missing reachable branch-shape finding: {findings:?}"
);
for resource_id in ["ValidIf", "NestedIf", "ImpossibleScalarBranch", "CorrelatedScalarBranch", "NestedFunctions"] {
assert!(
!findings.iter().any(|finding| finding.resource_id.as_deref() == Some(resource_id)),
"unexpected CreationPolicy finding for {resource_id}: {findings:?}"
);
}
}