Skip to main content

template_model/
hardcoded_az.rs

1//! Shared detection of hardcoded availability zones, so both rule engines emit
2//! identical findings from one implementation instead of two that can drift.
3
4use crate::consts::KEY_PROPERTIES;
5use crate::model::SemanticModel;
6use crate::resolver::ResolvedValue;
7use crate::value_patterns::AVAILABILITY_ZONE_PATTERN;
8use regex::Regex;
9use std::sync::LazyLock;
10
11/// Resource types mapped to the property descriptor whose value is a hardcoded
12/// availability-zone name to flag. A descriptor is a `.`-separated path under
13/// `Properties` with two wildcard segments:
14/// - `*` - the value at this path is a list whose every element is itself an AZ.
15/// - `{}` - the value at this path is a list; descend into every element and
16///   continue matching the remaining segments.
17pub const AZ_PATHS: &[(&str, &str)] = &[
18    ("AWS::AutoScaling::AutoScalingGroup", "AvailabilityZones.*"),
19    ("AWS::DAX::Cluster", "AvailabilityZones.*"),
20    ("AWS::DMS::ReplicationInstance", "AvailabilityZone"),
21    ("AWS::EC2::Host", "AvailabilityZone"),
22    ("AWS::EC2::Instance", "AvailabilityZone"),
23    ("AWS::EC2::LaunchTemplate", "LaunchTemplateData.Placement.AvailabilityZone"),
24    ("AWS::EC2::SpotFleet", "SpotFleetRequestConfigData.LaunchSpecifications.{}.Placement.AvailabilityZone"),
25    ("AWS::EC2::SpotFleet", "SpotFleetRequestConfigData.LaunchTemplateConfigs.{}.Overrides.{}.AvailabilityZone"),
26    ("AWS::EC2::Subnet", "AvailabilityZone"),
27    ("AWS::EC2::Volume", "AvailabilityZone"),
28    ("AWS::ElasticLoadBalancing::LoadBalancer", "AvailabilityZones.*"),
29    ("AWS::ElasticLoadBalancingV2::TargetGroup", "Targets.{}.AvailabilityZone"),
30    ("AWS::EMR::Cluster", "Instances.Placement.AvailabilityZone"),
31    ("AWS::Glue::Connection", "ConnectionInput.PhysicalConnectionRequirements.AvailabilityZone"),
32    ("AWS::OpsWorks::Instance", "AvailabilityZone"),
33    ("AWS::RDS::DBCluster", "AvailabilityZones.*"),
34    ("AWS::RDS::DBInstance", "AvailabilityZone"),
35];
36
37static AZ_RE: LazyLock<Regex> =
38    LazyLock::new(|| Regex::new(AVAILABILITY_ZONE_PATTERN).expect("AVAILABILITY_ZONE_PATTERN is a valid regex"));
39
40pub struct HardcodedAz {
41    pub path: String,
42    pub zone: String,
43}
44
45pub fn message(zone: &str) -> String {
46    format!("Avoid hardcoding availability zones '{zone}'")
47}
48
49/// Values produced by intrinsics (`Fn::GetAZs`, `Ref`, …) are skipped: those are
50/// not hardcoded even when they resolve to a concrete zone.
51pub fn find(model: &SemanticModel, resource_id: &str, resource_type: &str) -> Vec<HardcodedAz> {
52    let mut found = Vec::new();
53    for (rtype, descriptor) in AZ_PATHS {
54        if *rtype != resource_type {
55            continue;
56        }
57        let segments: Vec<&str> = descriptor.split('.').collect();
58        walk(model, resource_id, &segments, 0, KEY_PROPERTIES.to_string(), &mut found);
59    }
60    found
61}
62
63fn walk(model: &SemanticModel, name: &str, segments: &[&str], idx: usize, path: String, found: &mut Vec<HardcodedAz>) {
64    if idx == segments.len() {
65        push_if_hardcoded_az(model, name, path, found);
66        return;
67    }
68    match segments[idx] {
69        // Each list element is itself an availability zone.
70        "*" => {
71            if model.is_from_intrinsic(name, &path) {
72                return;
73            }
74            let Some(len) = resolve_array_len(model, name, &path) else {
75                return;
76            };
77            for i in 0..len {
78                push_if_hardcoded_az(model, name, format!("{path}.{i}"), found);
79            }
80        }
81        // Descend into every list element, then continue matching.
82        "{}" => {
83            let Some(len) = resolve_array_len(model, name, &path) else {
84                return;
85            };
86            for i in 0..len {
87                walk(model, name, segments, idx + 1, format!("{path}.{i}"), found);
88            }
89        }
90        seg => walk(model, name, segments, idx + 1, format!("{path}.{seg}"), found),
91    }
92}
93
94fn push_if_hardcoded_az(model: &SemanticModel, name: &str, path: String, found: &mut Vec<HardcodedAz>) {
95    if model.is_from_intrinsic(name, &path) {
96        return;
97    }
98    if let Some(zone) = resolve_concrete_string(model, name, &path)
99        && AZ_RE.is_match(&zone)
100    {
101        found.push(HardcodedAz { path, zone });
102    }
103}
104
105fn resolve_concrete_string(model: &SemanticModel, name: &str, path: &str) -> Option<String> {
106    match model.resolve_deep(name, path).or_else(|| model.resolve(name, path).cloned())? {
107        ResolvedValue::Concrete { value } => value.as_str().map(str::to_string),
108        _ => None,
109    }
110}
111
112fn resolve_array_len(model: &SemanticModel, name: &str, path: &str) -> Option<usize> {
113    match model.resolve_deep(name, path).or_else(|| model.resolve(name, path).cloned())? {
114        ResolvedValue::Concrete { value } => value.as_array().map(Vec::len),
115        ResolvedValue::List { items } => Some(items.len()),
116        _ => None,
117    }
118}