Skip to main content

fakecloud_lambda/
filter.rs

1//! Lambda event source mapping filter criteria.
2//!
3//! Implements the EventBridge-style JSON pattern subset documented for
4//! Lambda ESM `FilterCriteria`. A record is delivered when *any*
5//! supplied pattern matches; a record is dropped when *every* pattern
6//! fails to match.
7//!
8//! Operators implemented (matches AWS's documented surface):
9//! - exact-string / number / boolean / null match
10//! - `{"exists": bool}` — field presence
11//! - `{"prefix": "..."}` / `{"suffix": "..."}` / `{"equals-ignore-case": "..."}`
12//! - `{"anything-but": value | [values]}`
13//! - `{"numeric": [op, n, op, n, ...]}` with `=`, `<`, `<=`, `>`, `>=`
14//! - SQS body decode: when the pattern contains a top-level `body`
15//!   key whose value is an object, the SQS message body is parsed as
16//!   JSON before pattern matching, mirroring AWS behavior.
17
18use serde_json::Value;
19
20/// Compiled filter set. `patterns` parses the raw `Filters: [{Pattern: "..."}]`
21/// strings into JSON objects once at create time.
22#[derive(Debug, Clone, Default)]
23pub struct FilterSet {
24    patterns: Vec<Value>,
25}
26
27impl FilterSet {
28    /// Build from the raw filter pattern strings stored on
29    /// [`crate::state::EventSourceMapping::filter_patterns`]. Patterns
30    /// that fail to parse are logged and dropped; pre-validation at
31    /// [`Self::validate`] (called from `CreateEventSourceMapping`)
32    /// keeps the live data clean.
33    pub fn from_strings<I, S>(raw: I) -> Self
34    where
35        I: IntoIterator<Item = S>,
36        S: AsRef<str>,
37    {
38        let patterns = raw
39            .into_iter()
40            .filter_map(|s| match serde_json::from_str::<Value>(s.as_ref()) {
41                Ok(v) => Some(v),
42                Err(err) => {
43                    tracing::warn!(
44                        pattern = s.as_ref(),
45                        error = %err,
46                        "lambda ESM filter pattern is invalid JSON; ignoring this pattern (other patterns still apply)"
47                    );
48                    None
49                }
50            })
51            .collect();
52        Self { patterns }
53    }
54
55    /// Validate raw filter patterns the same way real AWS rejects bad
56    /// `FilterCriteria` at `CreateEventSourceMapping`. Returns the
57    /// first invalid pattern's parse error so the service can surface
58    /// it as `InvalidParameterValueException`.
59    pub fn validate<I, S>(raw: I) -> Result<(), String>
60    where
61        I: IntoIterator<Item = S>,
62        S: AsRef<str>,
63    {
64        for s in raw {
65            serde_json::from_str::<Value>(s.as_ref())
66                .map_err(|err| format!("FilterCriteria pattern is invalid JSON: {err}"))?;
67        }
68        Ok(())
69    }
70
71    /// Returns `true` when the record matches at least one pattern, or
72    /// when the filter set is empty (no filtering = pass-through).
73    pub fn matches(&self, record: &Value) -> bool {
74        if self.patterns.is_empty() {
75            return true;
76        }
77        self.patterns.iter().any(|p| match_value(p, record))
78    }
79
80    pub fn is_empty(&self) -> bool {
81        self.patterns.is_empty()
82    }
83}
84
85fn match_value(pattern: &Value, value: &Value) -> bool {
86    // The top-level entry has the value present, so wrap as Some.
87    eval_field(pattern, Some(value))
88}
89
90/// Evaluate `pattern` against an optional `value`. `None` means the
91/// parent object did not contain the key the pattern targets — this
92/// matters for the `exists` operator, which is defined in terms of
93/// field *presence*, not value-is-null.
94fn eval_field(pattern: &Value, value: Option<&Value>) -> bool {
95    if let Value::Object(po) = pattern {
96        if is_operator_object(po) {
97            return apply_operator(po, value);
98        }
99    }
100    match pattern {
101        Value::Object(po) => match value {
102            Some(Value::Object(vo)) => po.iter().all(|(k, sub_pattern)| {
103                if k == "body" {
104                    if let Some(Value::String(s)) = vo.get("body") {
105                        if let Ok(parsed) = serde_json::from_str::<Value>(s) {
106                            return eval_field(sub_pattern, Some(&parsed));
107                        }
108                    }
109                }
110                eval_field(sub_pattern, vo.get(k))
111            }),
112            _ => false,
113        },
114        Value::Array(arr) => arr.iter().any(|p| eval_field(p, value)),
115        scalar => match (scalar, value) {
116            (Value::Null, Some(Value::Null)) => true,
117            (Value::Bool(a), Some(Value::Bool(b))) => a == b,
118            (Value::Number(a), Some(Value::Number(b))) => a == b,
119            (Value::String(a), Some(Value::String(b))) => a == b,
120            _ => false,
121        },
122    }
123}
124
125const OPERATOR_KEYS: &[&str] = &[
126    "exists",
127    "prefix",
128    "suffix",
129    "equals-ignore-case",
130    "anything-but",
131    "numeric",
132];
133
134fn is_operator_object(o: &serde_json::Map<String, Value>) -> bool {
135    o.keys().any(|k| OPERATOR_KEYS.contains(&k.as_str()))
136}
137
138fn apply_operator(o: &serde_json::Map<String, Value>, value: Option<&Value>) -> bool {
139    o.iter().all(|(op, arg)| match op.as_str() {
140        // `exists` is the only operator defined in terms of *field
141        // presence*. AWS treats `null` as present, so a literal
142        // `{"foo": null}` matches `{"foo": [{"exists": true}]}`.
143        "exists" => matches!(
144            (arg, value),
145            (Value::Bool(true), Some(_)) | (Value::Bool(false), None)
146        ),
147        op_name => match value {
148            Some(v) => apply_value_operator(op_name, arg, v),
149            None => false,
150        },
151    })
152}
153
154fn apply_value_operator(op: &str, arg: &Value, value: &Value) -> bool {
155    match op {
156        "prefix" => match (arg.as_str(), value.as_str()) {
157            (Some(p), Some(s)) => s.starts_with(p),
158            _ => false,
159        },
160        "suffix" => match (arg.as_str(), value.as_str()) {
161            (Some(p), Some(s)) => s.ends_with(p),
162            _ => false,
163        },
164        "equals-ignore-case" => match (arg.as_str(), value.as_str()) {
165            (Some(p), Some(s)) => p.eq_ignore_ascii_case(s),
166            _ => false,
167        },
168        "anything-but" => match arg {
169            Value::Array(arr) => !arr.iter().any(|v| v == value),
170            other => other != value,
171        },
172        "numeric" => apply_numeric(arg, value),
173        _ => false,
174    }
175}
176
177fn apply_numeric(arg: &Value, value: &Value) -> bool {
178    let Some(n) = value.as_f64() else {
179        return false;
180    };
181    let Some(arr) = arg.as_array() else {
182        return false;
183    };
184    // AWS rejects malformed numeric arrays at create time; defensively
185    // treat odd-length arrays here as a no-match instead of letting
186    // a leftover element silently pass.
187    if arr.len() % 2 != 0 || arr.is_empty() {
188        return false;
189    }
190    for chunk in arr.chunks(2) {
191        let Some(target) = chunk[1].as_f64() else {
192            return false;
193        };
194        let ok = match chunk[0].as_str() {
195            Some("=") => n == target,
196            Some("<") => n < target,
197            Some("<=") => n <= target,
198            Some(">") => n > target,
199            Some(">=") => n >= target,
200            _ => false,
201        };
202        if !ok {
203            return false;
204        }
205    }
206    true
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use serde_json::json;
213
214    fn fs(patterns: &[&str]) -> FilterSet {
215        FilterSet::from_strings(patterns.iter().map(|s| s.to_string()))
216    }
217
218    #[test]
219    fn empty_pattern_passes_through() {
220        let f = FilterSet::default();
221        assert!(f.matches(&json!({"any": "thing"})));
222    }
223
224    #[test]
225    fn exact_string_match() {
226        let f = fs(&[r#"{"foo": "bar"}"#]);
227        assert!(f.matches(&json!({"foo": "bar"})));
228        assert!(!f.matches(&json!({"foo": "baz"})));
229    }
230
231    #[test]
232    fn array_of_scalars_is_or() {
233        let f = fs(&[r#"{"foo": ["a", "b"]}"#]);
234        assert!(f.matches(&json!({"foo": "a"})));
235        assert!(f.matches(&json!({"foo": "b"})));
236        assert!(!f.matches(&json!({"foo": "c"})));
237    }
238
239    #[test]
240    fn exists_operator_treats_null_as_present() {
241        let exists_true = fs(&[r#"{"foo": [{"exists": true}]}"#]);
242        // AWS treats `{"foo": null}` as foo-is-present.
243        assert!(exists_true.matches(&json!({"foo": null})));
244        let exists_false = fs(&[r#"{"foo": [{"exists": false}]}"#]);
245        // ...and conversely a missing key is exists:false even though
246        // the value lookup returns the same Null sentinel under the
247        // hood.
248        assert!(exists_false.matches(&json!({})));
249        assert!(!exists_false.matches(&json!({"foo": null})));
250    }
251
252    #[test]
253    fn numeric_odd_length_is_no_match() {
254        let f = fs(&[r#"{"n": [{"numeric": [">", 0, "<"]}]}"#]);
255        assert!(!f.matches(&json!({"n": 5})));
256    }
257
258    #[test]
259    fn validate_rejects_invalid_json() {
260        assert!(FilterSet::validate(["{not json"].iter()).is_err());
261        assert!(FilterSet::validate([r#"{"ok": true}"#].iter()).is_ok());
262    }
263
264    #[test]
265    fn exists_operator() {
266        let exists_true = fs(&[r#"{"foo": [{"exists": true}]}"#]);
267        assert!(exists_true.matches(&json!({"foo": "x"})));
268        assert!(!exists_true.matches(&json!({"bar": "x"})));
269
270        let exists_false = fs(&[r#"{"foo": [{"exists": false}]}"#]);
271        assert!(!exists_false.matches(&json!({"foo": "x"})));
272        assert!(exists_false.matches(&json!({"bar": "x"})));
273    }
274
275    #[test]
276    fn sqs_body_decode() {
277        let f = fs(&[r#"{"body": {"action": "process"}}"#]);
278        let record = json!({
279            "body": "{\"action\": \"process\", \"id\": 42}",
280        });
281        assert!(f.matches(&record));
282        let other = json!({
283            "body": "{\"action\": \"skip\"}",
284        });
285        assert!(!f.matches(&other));
286    }
287
288    #[test]
289    fn nested_object_match() {
290        let f = fs(&[r#"{"order": {"status": "paid"}}"#]);
291        assert!(f.matches(&json!({"order": {"status": "paid", "id": 1}})));
292        assert!(!f.matches(&json!({"order": {"status": "pending"}})));
293    }
294
295    #[test]
296    fn multiple_patterns_or() {
297        let f = fs(&[r#"{"a": "x"}"#, r#"{"b": "y"}"#]);
298        assert!(f.matches(&json!({"a": "x"})));
299        assert!(f.matches(&json!({"b": "y"})));
300        assert!(!f.matches(&json!({"c": "z"})));
301    }
302}