Skip to main content

template_model/
lambda_sqs.rs

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}