1use crate::SemanticModel;
2use crate::coercion::coerce_to_integer;
3use crate::conditions::Satisfiability;
4use crate::consts::{FN_TRANSFORM, KEY_PROPERTIES};
5use crate::resolver::ResolvedValue;
6use std::collections::{BTreeSet, HashMap};
7
8const SQS_DEFAULT_VISIBILITY_TIMEOUT_SECONDS: i64 = 30;
9const LAMBDA_DEFAULT_TIMEOUT_SECONDS: i64 = 3;
10const SQS_QUEUE_RESOURCE_TYPE: &str = "AWS::SQS::Queue";
11const LAMBDA_FUNCTION_RESOURCE_TYPE: &str = "AWS::Lambda::Function";
12const LAMBDA_EVENT_SOURCE_MAPPING_RESOURCE_TYPE: &str = "AWS::Lambda::EventSourceMapping";
13const VISIBILITY_TIMEOUT_PROPERTY: &str = "VisibilityTimeout";
14const FUNCTION_TIMEOUT_PROPERTY: &str = "Timeout";
15const TIMEOUT_SUGGESTED_FIX: &str = "Set the SQS VisibilityTimeout to at least the Lambda function Timeout";
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
18pub struct SqsEventSourceTimeoutViolation {
19 pub queue_id: String,
20 pub visibility_timeout: i64,
21 pub function_timeout: i64,
22 pub message: String,
23 pub suggested_fix: String,
24}
25
26struct IntegerPropertyScenario {
27 value: i64,
28 conditions: HashMap<String, bool>,
29}
30
31enum ScenarioPropertyValue<'a> {
32 Absent,
33 Concrete(&'a serde_json::Value),
34 Unknown,
35}
36
37#[must_use]
38pub fn sqs_event_source_timeout_violations(model: &SemanticModel) -> Vec<SqsEventSourceTimeoutViolation> {
39 let mut violations = BTreeSet::new();
40 for mapping_id in model.resources_of_type(LAMBDA_EVENT_SOURCE_MAPPING_RESOURCE_TYPE) {
41 let Some(mapping) = model.resources.get(mapping_id) else {
42 continue;
43 };
44 if mapping.properties_dynamic || mapping.properties.contains_key(FN_TRANSFORM) {
45 continue;
46 }
47 let (Some(queue_id), Some(function_id)) = (
48 model.follow_ref(mapping_id, "Properties.EventSourceArn"),
49 model.follow_ref(mapping_id, "Properties.FunctionName"),
50 ) else {
51 continue;
52 };
53 let (Some(queue), Some(function)) = (model.resources.get(queue_id), model.resources.get(function_id)) else {
54 continue;
55 };
56 if queue.resource_type != SQS_QUEUE_RESOURCE_TYPE || function.resource_type != LAMBDA_FUNCTION_RESOURCE_TYPE {
57 continue;
58 }
59
60 let visibility_scenarios = integer_property_scenarios_with_default(
61 model,
62 queue_id,
63 VISIBILITY_TIMEOUT_PROPERTY,
64 SQS_DEFAULT_VISIBILITY_TIMEOUT_SECONDS,
65 );
66 let timeout_scenarios = integer_property_scenarios_with_default(
67 model,
68 function_id,
69 FUNCTION_TIMEOUT_PROPERTY,
70 LAMBDA_DEFAULT_TIMEOUT_SECONDS,
71 );
72 for visibility in &visibility_scenarios {
73 for timeout in &timeout_scenarios {
74 if visibility.value < timeout.value
75 && scenarios_are_jointly_reachable(
76 model,
77 &[mapping_id, queue_id, function_id],
78 &[&visibility.conditions, &timeout.conditions],
79 )
80 {
81 violations.insert(SqsEventSourceTimeoutViolation {
82 queue_id: queue_id.to_string(),
83 visibility_timeout: visibility.value,
84 function_timeout: timeout.value,
85 message: format!(
86 "Queue visibility timeout ({}) is less than Function timeout ({}) seconds",
87 visibility.value, timeout.value
88 ),
89 suggested_fix: TIMEOUT_SUGGESTED_FIX.to_string(),
90 });
91 }
92 }
93 }
94 }
95 violations.into_iter().collect()
96}
97
98fn integer_property_scenarios_with_default(
99 model: &SemanticModel,
100 resource_id: &str,
101 property_name: &str,
102 service_default: i64,
103) -> Vec<IntegerPropertyScenario> {
104 let property_path = format!("{KEY_PROPERTIES}.{property_name}");
105 let mut scenarios: Vec<IntegerPropertyScenario> = model
106 .resolve_properties_scenarios_projected(resource_id, &[property_name, FN_TRANSFORM])
107 .iter()
108 .filter_map(|(properties, conditions)| {
109 if model.scenario_uses_unresolved_parameter(resource_id, &property_path, conditions) {
110 return None;
111 }
112 let value = match scenario_property_value(properties, property_name) {
113 ScenarioPropertyValue::Absent => service_default,
114 ScenarioPropertyValue::Concrete(value) => coerce_to_integer(value)?,
115 ScenarioPropertyValue::Unknown => return None,
116 };
117 Some(IntegerPropertyScenario { value, conditions: conditions.clone() })
118 })
119 .collect();
120 scenarios.sort_by_key(|scenario| (scenario.value, canonical_condition_assignment(&scenario.conditions)));
121 scenarios.dedup_by(|left, right| left.value == right.value && left.conditions == right.conditions);
122 scenarios
123}
124
125fn scenario_property_value<'a>(properties: &'a ResolvedValue, property_name: &str) -> ScenarioPropertyValue<'a> {
126 match properties {
127 ResolvedValue::Map { entries } => {
128 if entries.iter().any(|entry| entry.key == FN_TRANSFORM) {
129 return ScenarioPropertyValue::Unknown;
130 }
131 match entries.iter().find(|entry| entry.key == property_name).map(|entry| &entry.value) {
132 None => ScenarioPropertyValue::Absent,
133 Some(ResolvedValue::Concrete { value }) if value.is_null() => ScenarioPropertyValue::Absent,
134 Some(ResolvedValue::Concrete { value }) => ScenarioPropertyValue::Concrete(value),
135 Some(_) => ScenarioPropertyValue::Unknown,
136 }
137 }
138 ResolvedValue::Concrete { value } => {
139 if value.is_null() {
140 return ScenarioPropertyValue::Absent;
141 }
142 let Some(properties) = value.as_object() else {
143 return ScenarioPropertyValue::Unknown;
144 };
145 if properties.contains_key(FN_TRANSFORM) {
146 return ScenarioPropertyValue::Unknown;
147 }
148 match properties.get(property_name) {
149 None | Some(serde_json::Value::Null) => ScenarioPropertyValue::Absent,
150 Some(value) => ScenarioPropertyValue::Concrete(value),
151 }
152 }
153 _ => ScenarioPropertyValue::Unknown,
154 }
155}
156
157fn scenarios_are_jointly_reachable(
158 model: &SemanticModel,
159 resource_ids: &[&str],
160 scenario_conditions: &[&HashMap<String, bool>],
161) -> bool {
162 let scenario_literals = scenario_conditions
163 .iter()
164 .flat_map(|conditions| conditions.iter().map(|(condition, value)| (condition.clone(), *value)));
165 let resource_literals = resource_ids.iter().filter_map(|resource_id| {
166 model
167 .resources
168 .get(*resource_id)
169 .and_then(|resource| resource.condition.clone())
170 .map(|condition| (condition, true))
171 });
172 let mut combined = HashMap::new();
173 for (condition, value) in scenario_literals.chain(resource_literals) {
174 if combined.insert(condition, value).is_some_and(|previous| previous != value) {
175 return false;
176 }
177 }
178 let assumptions: Vec<(String, bool)> = combined.into_iter().collect();
179 assumptions.is_empty() || model.conditions.satisfiability(&assumptions) == Satisfiability::Satisfiable
180}
181
182fn canonical_condition_assignment(conditions: &HashMap<String, bool>) -> Vec<(String, bool)> {
183 let mut assignment: Vec<(String, bool)> =
184 conditions.iter().map(|(condition, value)| (condition.clone(), *value)).collect();
185 assignment.sort_unstable();
186 assignment
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn violations(template: &str) -> Vec<SqsEventSourceTimeoutViolation> {
194 let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
195 sqs_event_source_timeout_violations(&model)
196 }
197
198 #[test]
199 fn macro_owned_branch_is_skipped_without_hiding_concrete_sibling_branch() {
200 let findings = violations(
201 r#"
202Parameters:
203 Stage:
204 Type: String
205Conditions:
206 IsProduction: !Equals [!Ref Stage, production]
207Resources:
208 Queue:
209 Type: AWS::SQS::Queue
210 Properties: !If
211 - IsProduction
212 - DelaySeconds: 0
213 Fn::Transform:
214 Name: AWS::Include
215 Parameters:
216 Location: s3://example-bucket/queue-properties.yaml
217 - VisibilityTimeout: 10
218 Function:
219 Type: AWS::Lambda::Function
220 Properties:
221 Timeout: 60
222 Mapping:
223 Type: AWS::Lambda::EventSourceMapping
224 Properties:
225 FunctionName: !Ref Function
226 EventSourceArn: !GetAtt Queue.Arn
227"#,
228 );
229 assert_eq!(findings.len(), 1);
230 assert_eq!(findings[0].queue_id, "Queue");
231 assert_eq!(findings[0].visibility_timeout, 10);
232 assert_eq!(findings[0].function_timeout, 60);
233 }
234
235 #[test]
236 fn no_value_branch_uses_service_default() {
237 let findings = violations(
238 r#"
239Parameters:
240 Stage:
241 Type: String
242Conditions:
243 IsProduction: !Equals [!Ref Stage, production]
244Resources:
245 Queue:
246 Type: AWS::SQS::Queue
247 Properties:
248 VisibilityTimeout: 1
249 Function:
250 Type: AWS::Lambda::Function
251 Properties:
252 Timeout: !If [IsProduction, 60, !Ref AWS::NoValue]
253 Mapping:
254 Type: AWS::Lambda::EventSourceMapping
255 Properties:
256 FunctionName: !Ref Function
257 EventSourceArn: !GetAtt Queue.Arn
258"#,
259 );
260 assert_eq!(findings.len(), 2, "both the explicit and default function timeouts violate the queue timeout");
261 assert!(findings.iter().any(|finding| finding.function_timeout == 3));
262 assert!(findings.iter().any(|finding| finding.function_timeout == 60));
263 }
264
265 #[test]
266 fn unresolved_parameter_default_is_not_a_fixed_deployment_value() {
267 let findings = violations(
268 r#"
269Parameters:
270 FunctionTimeout:
271 Type: Number
272 Default: 60
273Resources:
274 Queue:
275 Type: AWS::SQS::Queue
276 Properties:
277 VisibilityTimeout: 20
278 Function:
279 Type: AWS::Lambda::Function
280 Properties:
281 Timeout: !Ref FunctionTimeout
282 Mapping:
283 Type: AWS::Lambda::EventSourceMapping
284 Properties:
285 FunctionName: !Ref Function
286 EventSourceArn: !GetAtt Queue.Arn
287"#,
288 );
289 assert!(findings.is_empty(), "a caller can override the parameter before deployment");
290 }
291
292 #[test]
293 fn indeterminate_condition_reachability_does_not_produce_a_violation() {
294 let template = r#"
295Parameters:
296 P1: {Type: String}
297 P2: {Type: String}
298 P3: {Type: String}
299 P4: {Type: String}
300 P5: {Type: String}
301 P6: {Type: String}
302 P7: {Type: String}
303 P8: {Type: String}
304 P9: {Type: String}
305 P10: {Type: String}
306 P11: {Type: String}
307 P12: {Type: String}
308 P13: {Type: String}
309 P14: {Type: String}
310 P15: {Type: String}
311 P16: {Type: String}
312 P17: {Type: String}
313 P18: {Type: String}
314 P19: {Type: String}
315 P20: {Type: String}
316 P21: {Type: String}
317Conditions:
318 FirstGroup: !And
319 - !Equals [!Ref P2, x]
320 - !Equals [!Ref P3, x]
321 - !Equals [!Ref P4, x]
322 - !Equals [!Ref P5, x]
323 - !Equals [!Ref P6, x]
324 - !Equals [!Ref P7, x]
325 - !Equals [!Ref P8, x]
326 - !Equals [!Ref P9, x]
327 - !Equals [!Ref P10, x]
328 - !Equals [!Ref P11, x]
329 SecondGroup: !And
330 - !Equals [!Ref P12, x]
331 - !Equals [!Ref P13, x]
332 - !Equals [!Ref P14, x]
333 - !Equals [!Ref P15, x]
334 - !Equals [!Ref P16, x]
335 - !Equals [!Ref P17, x]
336 - !Equals [!Ref P18, x]
337 - !Equals [!Ref P19, x]
338 - !Equals [!Ref P20, x]
339 - !Equals [!Ref P21, x]
340 Big: !And
341 - !Condition FirstGroup
342 - !Condition SecondGroup
343 - !Equals [!Ref P1, a]
344 NotBig: !Not [!Condition Big]
345Resources:
346 Queue:
347 Type: AWS::SQS::Queue
348 Properties:
349 VisibilityTimeout: !If [Big, 10, 900]
350 Function:
351 Type: AWS::Lambda::Function
352 Properties:
353 Timeout: !If [NotBig, 60, 5]
354 Mapping:
355 Type: AWS::Lambda::EventSourceMapping
356 Properties:
357 FunctionName: !Ref Function
358 EventSourceArn: !GetAtt Queue.Arn
359"#;
360 let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
361 let findings = sqs_event_source_timeout_violations(&model);
362 assert!(
363 !model.conditions.budget_exhausted_queries().is_empty(),
364 "the template must exercise indeterminate satisfiability"
365 );
366 assert!(findings.is_empty(), "an unproven scenario must not produce an error: {findings:?}");
367 }
368}