1use crate::conditions::Satisfiability;
16use crate::model::SemanticModel;
17use crate::resolver::ResolvedValue;
18
19#[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
28type ValueKey = (Option<String>, Option<String>, String);
30
31fn 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
47fn 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
60fn 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 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 #[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 #[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 #[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 #[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}