Skip to main content

template_model/
route_table.rs

1//! Duplicate subnet/route-table association detection.
2//!
3//! EC2 allows exactly one route table per subnet, so two
4//! `AWS::EC2::SubnetRouteTableAssociation` resources naming the same subnet
5//! fail at deploy time. A subnet's identity here is the complete authored or
6//! resolved value: identical expressions prove one subnet, while expressions
7//! that merely share a component reference do not, evaluated through `Fn::If`
8//! branches.
9//!
10//! Two associations clash only when their combined resource-level and
11//! property-level condition assumptions are co-satisfiable — if no parameter
12//! assignment can make both branches simultaneously true, they are mutually
13//! exclusive and cannot conflict at deploy time.
14
15use crate::conditions::Satisfiability;
16use crate::model::SemanticModel;
17use crate::resolver::ResolvedValue;
18
19/// A clash finding anchored at `Properties.SubnetId`.
20#[derive(Debug, Clone)]
21pub struct AssociationFinding {
22    pub resource_id: String,
23    pub message: String,
24}
25
26const ASSOCIATION_TYPE: &str = "AWS::EC2::SubnetRouteTableAssociation";
27
28/// Resource condition, property condition, and authored subnet identity.
29type ValueKey = (Option<String>, Option<String>, String);
30
31/// Parses a comma-separated condition string (e.g. `"IsProd,!UseVPC"`) into
32/// the assumption pairs the satisfiability solver expects.
33fn parse_assumptions(conditions: &str) -> Vec<(String, bool)> {
34    conditions
35        .split(',')
36        .filter(|segment| !segment.is_empty())
37        .map(|segment| {
38            if let Some(negated) = segment.strip_prefix('!') {
39                (negated.to_string(), false)
40            } else {
41                (segment.to_string(), true)
42            }
43        })
44        .collect()
45}
46
47/// Builds the full assumption list from a resource condition and a property
48/// condition string (both optional).
49fn build_assumptions(resource_condition: Option<&str>, property_condition: Option<&str>) -> Vec<(String, bool)> {
50    let mut assumptions = Vec::new();
51    if let Some(condition) = resource_condition {
52        assumptions.push((condition.to_string(), true));
53    }
54    if let Some(property_cond) = property_condition {
55        assumptions.extend(parse_assumptions(property_cond));
56    }
57    assumptions
58}
59
60/// Checks whether the combined assumptions from two value keys can be
61/// simultaneously satisfied. Empty assumptions on one side still require the
62/// other side to be satisfiable.
63fn are_co_satisfiable(model: &SemanticModel, left: &ValueKey, right: &ValueKey) -> bool {
64    let mut combined = build_assumptions(left.0.as_deref(), left.1.as_deref());
65    combined.extend(build_assumptions(right.0.as_deref(), right.1.as_deref()));
66    matches!(model.conditions.satisfiability(&combined), Satisfiability::Satisfiable)
67}
68
69pub fn duplicate_subnet_associations(model: &SemanticModel) -> Vec<AssociationFinding> {
70    let mut names: Vec<&String> = model
71        .resources
72        .iter()
73        .filter(|(_, resource)| resource.resource_type == ASSOCIATION_TYPE)
74        .map(|(name, _)| name)
75        .collect();
76    names.sort_by_key(|name| {
77        model
78            .source_location(&format!("Resources/{}", name))
79            .map(|span| (span.start_line, span.start_column))
80            .unwrap_or((u32::MAX, u32::MAX))
81    });
82
83    let mut per_resource: Vec<(&String, Vec<ValueKey>)> = Vec::new();
84    for name in &names {
85        let resource = &model.resources[*name];
86        let mut values = Vec::new();
87        if let Some(subnet) = resource.properties.get("SubnetId") {
88            collect_subnet_values(
89                model,
90                name,
91                subnet,
92                resource.condition.as_deref(),
93                None,
94                "Properties.SubnetId",
95                &mut values,
96            );
97        }
98        per_resource.push((name, values));
99    }
100
101    let mut findings = Vec::new();
102    for (idx, (name, values)) in per_resource.iter().enumerate() {
103        let resource = &model.resources[*name];
104        for value in values {
105            let mut others: Vec<&String> = Vec::new();
106            for (other_idx, (other_name, other_values)) in per_resource.iter().enumerate() {
107                if other_idx == idx {
108                    continue;
109                }
110                for other_value in other_values {
111                    if value.2 != other_value.2 {
112                        continue;
113                    }
114                    // Preserve one-sided unconditional behavior: when this
115                    // resource is unconditional and the other is conditioned,
116                    // only the conditioned resource should report the clash
117                    // (it is the one that would fail when its condition is
118                    // true alongside the always-present unconditional one).
119                    let this_is_unconditional = resource.condition.is_none() && value.1.is_none();
120                    let other_resource = &model.resources[*other_name];
121                    let other_is_conditioned = other_resource.condition.is_some() || other_value.1.is_some();
122                    if this_is_unconditional && other_is_conditioned {
123                        continue;
124                    }
125                    if are_co_satisfiable(model, value, other_value) {
126                        others.push(other_name);
127                        break;
128                    }
129                }
130            }
131            others.sort_by_key(|other| names.iter().position(|n| n == other));
132            others.dedup();
133            if !others.is_empty() {
134                findings.push(AssociationFinding {
135                    resource_id: (*name).clone(),
136                    message: format!(
137                        "SubnetId in {} is also associated with {}",
138                        name,
139                        others.iter().map(|other| other.as_str()).collect::<Vec<_>>().join(", ")
140                    ),
141                });
142            }
143        }
144    }
145    findings
146}
147
148fn collect_subnet_values(
149    model: &SemanticModel,
150    resource_id: &str,
151    value: &ResolvedValue,
152    resource_condition: Option<&str>,
153    property_condition: Option<&str>,
154    path: &str,
155    out: &mut Vec<ValueKey>,
156) {
157    match value {
158        ResolvedValue::Conditional { condition, if_true, if_false } => {
159            let true_condition = append_condition(property_condition, condition, true);
160            let false_condition = append_condition(property_condition, condition, false);
161            collect_subnet_values(
162                model,
163                resource_id,
164                if_true,
165                resource_condition,
166                Some(&true_condition),
167                &format!("{}.Fn::If.1", path),
168                out,
169            );
170            collect_subnet_values(
171                model,
172                resource_id,
173                if_false,
174                resource_condition,
175                Some(&false_condition),
176                &format!("{}.Fn::If.2", path),
177                out,
178            );
179        }
180        ResolvedValue::Concrete { value } if value.as_str().is_none() => {}
181        ResolvedValue::List { .. } | ResolvedValue::Map { .. } => {}
182        ResolvedValue::Dynamic { .. } if !model.is_from_intrinsic(resource_id, path) => {}
183        _ => {
184            if let Some(identity) = model.resolved_value_identity(resource_id, path, value) {
185                let key = (resource_condition.map(String::from), property_condition.map(String::from), identity);
186                if !out.contains(&key) {
187                    out.push(key);
188                }
189            }
190        }
191    }
192}
193
194fn append_condition(parent: Option<&str>, condition: &str, value: bool) -> String {
195    let assumption = if value { condition.to_string() } else { format!("!{}", condition) };
196    parent.map_or(assumption.clone(), |parent| format!("{},{}", parent, assumption))
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn findings(template: &str) -> Vec<(String, String)> {
204        let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
205        duplicate_subnet_associations(&model)
206            .into_iter()
207            .map(|finding| (finding.resource_id, finding.message))
208            .collect()
209    }
210
211    #[test]
212    fn distinct_ref_targets_are_clean() {
213        let template = "Parameters:\n  A:\n    Type: String\n  B:\n    Type: String\nResources:\n  One:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-1\n      SubnetId: !Ref A\n  Two:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-1\n      SubnetId: !Ref B\n";
214        assert!(findings(template).is_empty());
215    }
216
217    #[test]
218    fn distinct_ref_targets_with_equal_defaults_are_clean() {
219        let template = "Parameters:\n  A:\n    Type: String\n    Default: subnet-1\n  B:\n    Type: String\n    Default: subnet-1\nResources:\n  One:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-1\n      SubnetId: !Ref A\n  Two:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-2\n      SubnetId: !Ref B\n";
220        assert!(findings(template).is_empty());
221    }
222
223    #[test]
224    fn distinct_compound_substitutions_sharing_a_component_are_clean() {
225        let template = r#"
226Parameters:
227  Prefix:
228    Type: String
229    Default: subnet-
230    AllowedValues: [subnet-]
231  SuffixA:
232    Type: String
233    Default: aaaaaaaaaaaaaaaaa
234    AllowedValues: [aaaaaaaaaaaaaaaaa]
235  SuffixB:
236    Type: String
237    Default: bbbbbbbbbbbbbbbbb
238    AllowedValues: [bbbbbbbbbbbbbbbbb]
239Resources:
240  One:
241    Type: AWS::EC2::SubnetRouteTableAssociation
242    Properties:
243      RouteTableId: rt-1
244      SubnetId: !Sub '${Prefix}${SuffixA}'
245  Two:
246    Type: AWS::EC2::SubnetRouteTableAssociation
247    Properties:
248      RouteTableId: rt-2
249      SubnetId: !Sub '${Prefix}${SuffixB}'
250"#;
251        assert!(findings(template).is_empty());
252    }
253
254    #[test]
255    fn identical_compound_substitutions_report_both_associations() {
256        let template = r#"
257Parameters:
258  Prefix:
259    Type: String
260  Suffix:
261    Type: String
262Resources:
263  One:
264    Type: AWS::EC2::SubnetRouteTableAssociation
265    Properties:
266      RouteTableId: rt-1
267      SubnetId: !Sub '${Prefix}${Suffix}'
268  Two:
269    Type: AWS::EC2::SubnetRouteTableAssociation
270    Properties:
271      RouteTableId: rt-2
272      SubnetId: !Sub '${Prefix}${Suffix}'
273"#;
274        assert_eq!(
275            findings(template),
276            [
277                ("One".to_string(), "SubnetId in One is also associated with Two".to_string()),
278                ("Two".to_string(), "SubnetId in Two is also associated with One".to_string()),
279            ]
280        );
281    }
282
283    #[test]
284    fn same_ref_target_reports_both_associations() {
285        let template = "Parameters:\n  A:\n    Type: String\nResources:\n  One:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-1\n      SubnetId: !Ref A\n  Two:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-2\n      SubnetId: !Ref A\n";
286        assert_eq!(
287            findings(template),
288            [
289                ("One".to_string(), "SubnetId in One is also associated with Two".to_string()),
290                ("Two".to_string(), "SubnetId in Two is also associated with One".to_string()),
291            ]
292        );
293    }
294
295    #[test]
296    fn impossible_condition_does_not_clash_with_unconditioned_association() {
297        let template = r#"
298Parameters:
299  Subnet:
300    Type: String
301Conditions:
302  Never: !Equals [always, never]
303Resources:
304  Plain:
305    Type: AWS::EC2::SubnetRouteTableAssociation
306    Properties:
307      RouteTableId: rt-1
308      SubnetId: !Ref Subnet
309  Impossible:
310    Type: AWS::EC2::SubnetRouteTableAssociation
311    Condition: Never
312    Properties:
313      RouteTableId: rt-2
314      SubnetId: !Ref Subnet
315"#;
316        assert!(findings(template).is_empty());
317    }
318
319    #[test]
320    fn opposite_ref_branches_are_clean() {
321        let template = "Parameters:\n  A:\n    Type: String\n  B:\n    Type: String\n  Env:\n    Type: String\nConditions:\n  IsProd: !Equals [!Ref Env, prod]\nResources:\n  One:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-1\n      SubnetId: !If [IsProd, !Ref A, !Ref B]\n  Two:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-2\n      SubnetId: !If [IsProd, !Ref B, !Ref A]\n";
322        assert!(findings(template).is_empty());
323    }
324
325    #[test]
326    fn opposite_literal_branches_are_clean() {
327        let template = "Parameters:\n  Env:\n    Type: String\nConditions:\n  IsProd: !Equals [!Ref Env, prod]\nResources:\n  One:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-1\n      SubnetId: !If [IsProd, subnet-1, subnet-2]\n  Two:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-2\n      SubnetId: !If [IsProd, subnet-2, subnet-1]\n";
328        assert!(findings(template).is_empty());
329    }
330
331    #[test]
332    fn conditioned_association_clashes_with_unconditioned_association() {
333        let template = "Parameters:\n  A:\n    Type: String\n  Env:\n    Type: String\nConditions:\n  IsProd: !Equals [!Ref Env, prod]\nResources:\n  Plain:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Properties:\n      RouteTableId: rt-1\n      SubnetId: !Ref A\n  Gated:\n    Type: AWS::EC2::SubnetRouteTableAssociation\n    Condition: IsProd\n    Properties:\n      RouteTableId: rt-2\n      SubnetId: !Ref A\n";
334        assert_eq!(
335            findings(template),
336            [("Gated".to_string(), "SubnetId in Gated is also associated with Plain".to_string())]
337        );
338    }
339
340    /// Two resources with mutually exclusive resource-level conditions and
341    /// the same subnet identity cannot coexist at deploy time.
342    #[test]
343    fn mutually_exclusive_resource_conditions_are_clean() {
344        let template = r#"
345Parameters:
346  Env:
347    Type: String
348  Subnet:
349    Type: String
350Conditions:
351  IsProd: !Equals [!Ref Env, prod]
352  IsNotProd: !Not [!Condition IsProd]
353Resources:
354  ProdAssoc:
355    Type: AWS::EC2::SubnetRouteTableAssociation
356    Condition: IsProd
357    Properties:
358      RouteTableId: rt-prod
359      SubnetId: !Ref Subnet
360  DevAssoc:
361    Type: AWS::EC2::SubnetRouteTableAssociation
362    Condition: IsNotProd
363    Properties:
364      RouteTableId: rt-dev
365      SubnetId: !Ref Subnet
366"#;
367        assert!(findings(template).is_empty());
368    }
369
370    /// Two resources sharing the same subnet identity and compatible
371    /// (co-satisfiable) resource-level conditions report a conflict.
372    #[test]
373    fn compatible_resource_conditions_report_conflict() {
374        let template = r#"
375Parameters:
376  Env:
377    Type: String
378  Feature:
379    Type: String
380  Subnet:
381    Type: String
382Conditions:
383  IsProd: !Equals [!Ref Env, prod]
384  UseFeature: !Equals [!Ref Feature, yes]
385Resources:
386  ProdAssoc:
387    Type: AWS::EC2::SubnetRouteTableAssociation
388    Condition: IsProd
389    Properties:
390      RouteTableId: rt-prod
391      SubnetId: !Ref Subnet
392  FeatureAssoc:
393    Type: AWS::EC2::SubnetRouteTableAssociation
394    Condition: UseFeature
395    Properties:
396      RouteTableId: rt-feature
397      SubnetId: !Ref Subnet
398"#;
399        let found = findings(template);
400        assert!(
401            found.iter().any(|(id, _)| id == "ProdAssoc"),
402            "compatible resource conditions must report conflict: {:?}",
403            found
404        );
405        assert!(
406            found.iter().any(|(id, _)| id == "FeatureAssoc"),
407            "compatible resource conditions must report conflict: {:?}",
408            found
409        );
410    }
411
412    /// Two resources with mutually exclusive property-level conditions
413    /// (opposite Fn::If branches) and the same subnet identity are clean.
414    #[test]
415    fn mutually_exclusive_property_conditions_are_clean() {
416        let template = r#"
417Parameters:
418  Env:
419    Type: String
420  SubnetA:
421    Type: String
422  SubnetB:
423    Type: String
424Conditions:
425  IsProd: !Equals [!Ref Env, prod]
426Resources:
427  One:
428    Type: AWS::EC2::SubnetRouteTableAssociation
429    Properties:
430      RouteTableId: rt-1
431      SubnetId: !If [IsProd, !Ref SubnetA, !Ref SubnetB]
432  Two:
433    Type: AWS::EC2::SubnetRouteTableAssociation
434    Properties:
435      RouteTableId: rt-2
436      SubnetId: !If [IsProd, !Ref SubnetB, !Ref SubnetA]
437"#;
438        assert!(findings(template).is_empty());
439    }
440
441    /// When a resource-level condition on one resource is mutually exclusive
442    /// with a property-level condition on another, the subnet identities
443    /// cannot overlap at deploy time.
444    #[test]
445    fn mutually_exclusive_resource_and_property_conditions_are_clean() {
446        let template = r#"
447Parameters:
448  Env:
449    Type: String
450  Subnet:
451    Type: String
452  OtherSubnet:
453    Type: String
454Conditions:
455  IsProd: !Equals [!Ref Env, prod]
456Resources:
457  Gated:
458    Type: AWS::EC2::SubnetRouteTableAssociation
459    Condition: IsProd
460    Properties:
461      RouteTableId: rt-1
462      SubnetId: !Ref Subnet
463  Branched:
464    Type: AWS::EC2::SubnetRouteTableAssociation
465    Properties:
466      RouteTableId: rt-2
467      SubnetId: !If [IsProd, !Ref OtherSubnet, !Ref Subnet]
468"#;
469        assert!(findings(template).is_empty());
470    }
471}