Skip to main content

template_model/
event_pattern.rs

1use std::collections::{BTreeSet, HashMap};
2use std::net::Ipv6Addr;
3
4use crate::SemanticModel;
5use crate::resolver::ResolvedValue;
6
7const EVENT_PATTERN_RESOURCE_TYPES: [&str; 2] = ["AWS::Events::Rule", "AWS::Events::Archive"];
8const EVENT_PATTERN_PROPERTY_PATH: &str = "Properties.EventPattern";
9const EVENT_PATTERN_ROOT_LABEL: &str = "EventPattern";
10const OR_KEY: &str = "$or";
11
12const EXACTLY_OPERATOR: &str = "exactly";
13const PREFIX_OPERATOR: &str = "prefix";
14const SUFFIX_OPERATOR: &str = "suffix";
15const ANYTHING_BUT_OPERATOR: &str = "anything-but";
16const NUMERIC_OPERATOR: &str = "numeric";
17const EXISTS_OPERATOR: &str = "exists";
18const CIDR_OPERATOR: &str = "cidr";
19const WILDCARD_OPERATOR: &str = "wildcard";
20const EQUALS_IGNORE_CASE_OPERATOR: &str = "equals-ignore-case";
21
22const NUMERIC_COMPARISONS: [&str; 5] = ["<", "<=", "=", ">=", ">"];
23const LOWER_BOUND_COMPARISONS: [&str; 2] = [">", ">="];
24const UPPER_BOUND_COMPARISONS: [&str; 2] = ["<", "<="];
25const ANYTHING_BUT_NESTED_OPERATORS: [&str; 4] =
26    [PREFIX_OPERATOR, SUFFIX_OPERATOR, WILDCARD_OPERATOR, EQUALS_IGNORE_CASE_OPERATOR];
27const MIN_OR_BRANCHES: usize = 2;
28const OR_BRANCH_RESERVED_FIELD_NAMES: &[&str] = &[
29    EXACTLY_OPERATOR,
30    EQUALS_IGNORE_CASE_OPERATOR,
31    PREFIX_OPERATOR,
32    SUFFIX_OPERATOR,
33    ANYTHING_BUT_OPERATOR,
34    EXISTS_OPERATOR,
35    WILDCARD_OPERATOR,
36    NUMERIC_OPERATOR,
37    CIDR_OPERATOR,
38    "=",
39    "<",
40    "<=",
41    ">",
42    ">=",
43    "regex",
44    "not-wildcard",
45    "not-equals-ignore-case",
46    "date-after",
47    "date-on-or-after",
48    "date-before",
49    "date-on-or-before",
50    "in-date-range",
51    "ip-address-in-range",
52    "ip-address-not-in-range",
53];
54
55const UNKNOWN_OPERATOR_FIX: &str =
56    "Use one of exactly, prefix, suffix, anything-but, numeric, exists, cidr, wildcard or equals-ignore-case";
57const INVALID_JSON_FIX: &str = "Provide a valid JSON object";
58const OBJECT_PATTERN_FIX: &str = "Use a JSON object containing event fields";
59const EMPTY_OBJECT_FIX: &str = "Add an event field, or remove the empty object";
60const OR_BRANCHES_FIX: &str = "Give $or an array containing at least two objects";
61const OR_RESERVED_FIELD_FIX: &str = "Rename the field so it is not reserved by $or matching";
62const EMPTY_ARRAY_FIX: &str = "Give the key at least one value, or remove the key";
63const SCALAR_VALUE_FIX: &str = "Wrap the value in an array";
64const MATCH_VALUE_FIX: &str = "Use a scalar value or an object containing exactly one matcher operator";
65const PREFIX_SUFFIX_FIX: &str = "Use a string or an object containing only equals-ignore-case with a string value";
66const NUMERIC_FIX: &str = "Use [operator, number] or [>, number, <, number] with inclusive variants where allowed";
67const NUMERIC_VALUE_FIX: &str = "Use a finite number whose decimal value is preserved as a 64-bit double";
68const ANYTHING_BUT_FIX: &str =
69    "Give anything-but one string, one number, a homogeneous array, or one supported nested matcher";
70const EXISTS_FIX: &str = "Set exists to an unquoted true or false";
71const CIDR_FIX: &str = "Use a valid IPv4 or IPv6 CIDR string";
72const WILDCARD_FIX: &str = "Use a string with non-consecutive wildcards and escape only * or \\";
73const STRING_MATCHER_FIX: &str = "Give the matcher a string value";
74
75#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
76pub struct EventPatternViolation {
77    pub resource_id: String,
78    pub property_path: String,
79    pub message: String,
80    pub suggested_fix: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
84struct PatternFinding {
85    property_path: String,
86    message: String,
87    suggested_fix: String,
88}
89
90#[derive(Clone)]
91struct PatternLocation {
92    display_path: String,
93    authored_path: Option<String>,
94}
95
96impl PatternLocation {
97    fn authored_root() -> Self {
98        Self {
99            display_path: EVENT_PATTERN_ROOT_LABEL.to_string(),
100            authored_path: Some(EVENT_PATTERN_PROPERTY_PATH.to_string()),
101        }
102    }
103
104    fn parsed_string_root() -> Self {
105        Self { display_path: EVENT_PATTERN_ROOT_LABEL.to_string(), authored_path: None }
106    }
107
108    fn field(&self, field_name: &str) -> Self {
109        Self {
110            display_path: format!("{}.{field_name}", self.display_path),
111            authored_path: self.authored_path.as_ref().map(|path| format!("{path}.{field_name}")),
112        }
113    }
114
115    fn item(&self, index: usize) -> Self {
116        Self {
117            display_path: format!("{}[{index}]", self.display_path),
118            authored_path: self.authored_path.as_ref().map(|path| format!("{path}.{index}")),
119        }
120    }
121
122    fn diagnostic_path(&self) -> String {
123        self.authored_path.clone().unwrap_or_else(|| EVENT_PATTERN_PROPERTY_PATH.to_string())
124    }
125}
126
127/// Event patterns that EventBridge rejects when the rule or archive is created.
128///
129/// Values controlled by an unresolved parameter or another deploy-time expression
130/// are not judged. Concrete siblings remain subject to validation.
131#[must_use]
132pub fn event_pattern_violations(model: &SemanticModel) -> Vec<EventPatternViolation> {
133    let mut violations = BTreeSet::new();
134    let conditions = HashMap::new();
135    for resource_type in EVENT_PATTERN_RESOURCE_TYPES {
136        for resource_id in model.resources_of_type(resource_type) {
137            let Some(pattern) = model.resolve(resource_id, EVENT_PATTERN_PROPERTY_PATH) else {
138                continue;
139            };
140            let mut checker = EventPatternChecker::new(model, resource_id, &conditions);
141            if checker.location_is_parameter_controlled(&PatternLocation::authored_root()) {
142                continue;
143            }
144            match pattern {
145                ResolvedValue::Concrete { value } => match &value.0 {
146                    serde_json::Value::String(pattern_text) => match serde_json::from_str(pattern_text) {
147                        Ok(parsed) => checker
148                            .check_pattern_root(PatternValue::Json(&parsed), PatternLocation::parsed_string_root()),
149                        Err(_) => checker.add_finding(
150                            &PatternLocation::parsed_string_root(),
151                            "EventPattern string is not valid JSON".to_string(),
152                            INVALID_JSON_FIX,
153                        ),
154                    },
155                    _ => checker.check_pattern_root(PatternValue::Resolved(pattern), PatternLocation::authored_root()),
156                },
157                _ => checker.check_pattern_root(PatternValue::Resolved(pattern), PatternLocation::authored_root()),
158            }
159            violations.extend(checker.findings.into_iter().map(|finding| EventPatternViolation {
160                resource_id: resource_id.clone(),
161                property_path: finding.property_path,
162                message: finding.message,
163                suggested_fix: finding.suggested_fix,
164            }));
165        }
166    }
167    violations.into_iter().collect()
168}
169
170#[derive(Clone, Copy)]
171enum PatternValue<'a> {
172    Json(&'a serde_json::Value),
173    Resolved(&'a ResolvedValue),
174}
175
176enum PatternShape<'a> {
177    Object(Vec<(&'a str, PatternValue<'a>)>),
178    Array(Vec<PatternValue<'a>>),
179    Scalar(&'a serde_json::Value),
180    Unknown,
181}
182
183impl<'a> PatternValue<'a> {
184    fn shape(self) -> PatternShape<'a> {
185        match self {
186            PatternValue::Json(serde_json::Value::Object(entries)) => PatternShape::Object(
187                entries.iter().map(|(key, value)| (key.as_str(), PatternValue::Json(value))).collect(),
188            ),
189            PatternValue::Json(serde_json::Value::Array(items)) => {
190                PatternShape::Array(items.iter().map(PatternValue::Json).collect())
191            }
192            PatternValue::Json(scalar) => PatternShape::Scalar(scalar),
193            PatternValue::Resolved(ResolvedValue::Concrete { value }) => PatternValue::Json(&value.0).shape(),
194            PatternValue::Resolved(ResolvedValue::Map { entries }) => PatternShape::Object(
195                entries.iter().map(|entry| (entry.key.as_str(), PatternValue::Resolved(&entry.value))).collect(),
196            ),
197            PatternValue::Resolved(ResolvedValue::List { items }) => {
198                PatternShape::Array(items.iter().map(PatternValue::Resolved).collect())
199            }
200            PatternValue::Resolved(
201                ResolvedValue::Enum { .. }
202                | ResolvedValue::Conditional { .. }
203                | ResolvedValue::Reference { .. }
204                | ResolvedValue::Dynamic { .. }
205                | ResolvedValue::TypedDynamic { .. },
206            ) => PatternShape::Unknown,
207        }
208    }
209}
210
211struct EventPatternChecker<'a> {
212    model: &'a SemanticModel,
213    resource_id: &'a str,
214    conditions: &'a HashMap<String, bool>,
215    findings: Vec<PatternFinding>,
216}
217
218#[derive(Clone, Copy, PartialEq, Eq)]
219enum PatternObjectContext {
220    Ordinary,
221    OrBranch,
222}
223
224impl<'a> EventPatternChecker<'a> {
225    fn new(model: &'a SemanticModel, resource_id: &'a str, conditions: &'a HashMap<String, bool>) -> Self {
226        Self { model, resource_id, conditions, findings: Vec::new() }
227    }
228
229    fn check_pattern_root(&mut self, pattern: PatternValue<'_>, location: PatternLocation) {
230        match self.shape_at(pattern, &location) {
231            PatternShape::Object(entries) => {
232                self.check_pattern_object(&location, &entries, PatternObjectContext::Ordinary)
233            }
234            PatternShape::Unknown => {}
235            PatternShape::Array(_) | PatternShape::Scalar(_) => {
236                self.add_finding(&location, "EventPattern must be a JSON object".to_string(), OBJECT_PATTERN_FIX)
237            }
238        }
239    }
240
241    fn check_pattern_object(
242        &mut self,
243        location: &PatternLocation,
244        entries: &[(&str, PatternValue<'_>)],
245        context: PatternObjectContext,
246    ) {
247        if entries.is_empty() {
248            self.add_finding(
249                location,
250                format!("'{}' must not be an empty object", location.display_path),
251                EMPTY_OBJECT_FIX,
252            );
253            return;
254        }
255        for (key, value) in entries {
256            let field_location = location.field(key);
257            if context == PatternObjectContext::OrBranch && OR_BRANCH_RESERVED_FIELD_NAMES.contains(key) {
258                self.add_finding(
259                    &field_location,
260                    format!("'{}' is reserved and cannot be a field name inside $or", field_location.display_path),
261                    OR_RESERVED_FIELD_FIX,
262                );
263                continue;
264            }
265            if *key == OR_KEY {
266                self.check_or_branches(*value, &field_location);
267                continue;
268            }
269            match self.shape_at(*value, &field_location) {
270                PatternShape::Object(nested) => self.check_pattern_object(&field_location, &nested, context),
271                PatternShape::Array(items) => self.check_match_values(&field_location, &items),
272                PatternShape::Scalar(_) => self.add_finding(
273                    &field_location,
274                    format!("'{}' must be an array or an object", field_location.display_path),
275                    SCALAR_VALUE_FIX,
276                ),
277                PatternShape::Unknown => {}
278            }
279        }
280    }
281
282    fn check_or_branches(&mut self, branches: PatternValue<'_>, location: &PatternLocation) {
283        match self.shape_at(branches, location) {
284            PatternShape::Array(branches) => {
285                if branches.len() < MIN_OR_BRANCHES {
286                    self.add_finding(
287                        location,
288                        format!("'{}' must contain at least {MIN_OR_BRANCHES} branch objects", location.display_path),
289                        OR_BRANCHES_FIX,
290                    );
291                }
292                for (index, branch) in branches.iter().enumerate() {
293                    let branch_location = location.item(index);
294                    match self.shape_at(*branch, &branch_location) {
295                        PatternShape::Object(entries) => {
296                            self.check_pattern_object(&branch_location, &entries, PatternObjectContext::OrBranch)
297                        }
298                        PatternShape::Unknown => {}
299                        PatternShape::Array(_) | PatternShape::Scalar(_) => self.add_finding(
300                            &branch_location,
301                            format!("'{}' must be an object", branch_location.display_path),
302                            OR_BRANCHES_FIX,
303                        ),
304                    }
305                }
306            }
307            PatternShape::Unknown => {}
308            PatternShape::Object(_) | PatternShape::Scalar(_) => self.add_finding(
309                location,
310                format!("'{}' must be an array of branch objects", location.display_path),
311                OR_BRANCHES_FIX,
312            ),
313        }
314    }
315
316    fn check_match_values(&mut self, location: &PatternLocation, match_values: &[PatternValue<'_>]) {
317        if match_values.is_empty() {
318            self.add_finding(location, format!("'{}' is an empty array", location.display_path), EMPTY_ARRAY_FIX);
319            return;
320        }
321        for (index, match_value) in match_values.iter().enumerate() {
322            let match_location = location.item(index);
323            match self.shape_at(*match_value, &match_location) {
324                PatternShape::Object(operators) => self.check_match_expression(&match_location, &operators),
325                PatternShape::Scalar(_) | PatternShape::Unknown => {}
326                PatternShape::Array(_) => self.add_finding(
327                    &match_location,
328                    format!("'{}' must be a scalar value or a matcher object", match_location.display_path),
329                    MATCH_VALUE_FIX,
330                ),
331            }
332        }
333    }
334
335    fn check_match_expression(&mut self, location: &PatternLocation, operators: &[(&str, PatternValue<'_>)]) {
336        if operators.len() != 1 {
337            self.add_finding(
338                location,
339                format!("matcher in '{}' must contain exactly one operator", location.display_path),
340                MATCH_VALUE_FIX,
341            );
342            return;
343        }
344        let (operator, operand) = operators[0];
345        let operand_location = location.field(operator);
346        match operator {
347            PREFIX_OPERATOR | SUFFIX_OPERATOR => {
348                self.check_prefix_or_suffix(operator, operand, location, &operand_location)
349            }
350            EXISTS_OPERATOR => self.check_exists(operand, location, &operand_location),
351            NUMERIC_OPERATOR => self.check_numeric(operand, location, &operand_location),
352            ANYTHING_BUT_OPERATOR => self.check_anything_but(operand, location, &operand_location),
353            WILDCARD_OPERATOR => self.check_wildcard(operand, location, &operand_location),
354            CIDR_OPERATOR => self.check_cidr(operand, location, &operand_location),
355            EXACTLY_OPERATOR | EQUALS_IGNORE_CASE_OPERATOR => {
356                self.check_string_matcher(operator, operand, location, &operand_location)
357            }
358            unknown_operator => self.add_finding(
359                &operand_location,
360                format!("'{unknown_operator}' in '{}' is not an event pattern operator", location.display_path),
361                UNKNOWN_OPERATOR_FIX,
362            ),
363        }
364    }
365
366    fn check_prefix_or_suffix(
367        &mut self,
368        operator: &str,
369        operand: PatternValue<'_>,
370        match_location: &PatternLocation,
371        operand_location: &PatternLocation,
372    ) {
373        let is_valid = match self.shape_at(operand, operand_location) {
374            PatternShape::Scalar(serde_json::Value::String(_)) | PatternShape::Unknown => true,
375            PatternShape::Object(entries) if entries.len() == 1 && entries[0].0 == EQUALS_IGNORE_CASE_OPERATOR => {
376                let case_insensitive_location = operand_location.field(EQUALS_IGNORE_CASE_OPERATOR);
377                matches!(
378                    self.shape_at(entries[0].1, &case_insensitive_location),
379                    PatternShape::Scalar(serde_json::Value::String(_)) | PatternShape::Unknown
380                )
381            }
382            PatternShape::Object(_) | PatternShape::Array(_) | PatternShape::Scalar(_) => false,
383        };
384        if !is_valid {
385            self.add_finding(
386                operand_location,
387                format!(
388                    "{operator} in '{}' must be a string or an equals-ignore-case object containing one string",
389                    match_location.display_path
390                ),
391                PREFIX_SUFFIX_FIX,
392            );
393        }
394    }
395
396    fn check_exists(
397        &mut self,
398        operand: PatternValue<'_>,
399        match_location: &PatternLocation,
400        operand_location: &PatternLocation,
401    ) {
402        if !matches!(
403            self.shape_at(operand, operand_location),
404            PatternShape::Scalar(serde_json::Value::Bool(_)) | PatternShape::Unknown
405        ) {
406            self.add_finding(
407                operand_location,
408                format!("exists in '{}' must be true or false", match_location.display_path),
409                EXISTS_FIX,
410            );
411        }
412    }
413
414    fn check_numeric(
415        &mut self,
416        operand: PatternValue<'_>,
417        match_location: &PatternLocation,
418        operand_location: &PatternLocation,
419    ) {
420        match self.shape_at(operand, operand_location) {
421            PatternShape::Array(bounds) => self.check_numeric_bounds(&bounds, match_location, operand_location),
422            PatternShape::Unknown => {}
423            PatternShape::Object(_) | PatternShape::Scalar(_) => self.add_finding(
424                operand_location,
425                format!("numeric in '{}' must be an array", match_location.display_path),
426                NUMERIC_FIX,
427            ),
428        }
429    }
430
431    fn check_numeric_bounds(
432        &mut self,
433        bounds: &[PatternValue<'_>],
434        match_location: &PatternLocation,
435        operand_location: &PatternLocation,
436    ) {
437        if bounds.len() != 2 && bounds.len() != 4 {
438            self.add_finding(
439                operand_location,
440                format!("numeric in '{}' must contain two or four elements", match_location.display_path),
441                NUMERIC_FIX,
442            );
443            return;
444        }
445        let first_operator_location = operand_location.item(0);
446        let Some(first_operator) = self.known_string(bounds[0], &first_operator_location) else {
447            if !matches!(self.shape_at(bounds[0], &first_operator_location), PatternShape::Unknown) {
448                self.add_finding(
449                    &first_operator_location,
450                    format!("numeric in '{}' must start with a comparison operator", match_location.display_path),
451                    NUMERIC_FIX,
452                );
453            }
454            return;
455        };
456        if !NUMERIC_COMPARISONS.contains(&first_operator) {
457            self.add_finding(
458                &first_operator_location,
459                format!("'{first_operator}' in '{}' is not a numeric comparison operator", match_location.display_path),
460                NUMERIC_FIX,
461            );
462            return;
463        }
464        let first_number_location = operand_location.item(1);
465        let first_number = self.numeric_operand(bounds[1], &first_number_location);
466        if first_number == NumericOperand::Invalid {
467            self.add_finding(
468                &first_number_location,
469                format!(
470                    "numeric comparison value in '{}' must be a finite number whose decimal value is preserved as a 64-bit double",
471                    match_location.display_path
472                ),
473                NUMERIC_VALUE_FIX,
474            );
475            return;
476        }
477        if bounds.len() == 2 {
478            return;
479        }
480        if !LOWER_BOUND_COMPARISONS.contains(&first_operator) {
481            self.add_finding(
482                &first_operator_location,
483                format!("four-part numeric range in '{}' must start with > or >=", match_location.display_path),
484                NUMERIC_FIX,
485            );
486            return;
487        }
488        let second_operator_location = operand_location.item(2);
489        let Some(second_operator) = self.known_string(bounds[2], &second_operator_location) else {
490            if !matches!(self.shape_at(bounds[2], &second_operator_location), PatternShape::Unknown) {
491                self.add_finding(
492                    &second_operator_location,
493                    format!("numeric range in '{}' must contain an upper-bound operator", match_location.display_path),
494                    NUMERIC_FIX,
495                );
496            }
497            return;
498        };
499        if !UPPER_BOUND_COMPARISONS.contains(&second_operator) {
500            self.add_finding(
501                &second_operator_location,
502                format!("'{second_operator}' in '{}' is not a valid upper-bound operator", match_location.display_path),
503                NUMERIC_FIX,
504            );
505            return;
506        }
507        let second_number_location = operand_location.item(3);
508        let second_number = self.numeric_operand(bounds[3], &second_number_location);
509        if second_number == NumericOperand::Invalid {
510            self.add_finding(
511                &second_number_location,
512                format!(
513                    "numeric comparison value in '{}' must be a finite number whose decimal value is preserved as a 64-bit double",
514                    match_location.display_path
515                ),
516                NUMERIC_VALUE_FIX,
517            );
518            return;
519        }
520        if let (NumericOperand::Known(lower), NumericOperand::Known(upper)) = (first_number, second_number) {
521            let bounds_are_not_strictly_increasing = lower >= upper;
522            if bounds_are_not_strictly_increasing {
523                self.add_finding(
524                    operand_location,
525                    format!(
526                        "numeric range in '{}' must have a lower bound strictly less than its upper bound",
527                        match_location.display_path
528                    ),
529                    NUMERIC_FIX,
530                );
531            }
532        }
533    }
534
535    fn check_anything_but(
536        &mut self,
537        operand: PatternValue<'_>,
538        match_location: &PatternLocation,
539        operand_location: &PatternLocation,
540    ) {
541        match self.shape_at(operand, operand_location) {
542            PatternShape::Unknown | PatternShape::Scalar(serde_json::Value::String(_)) => {}
543            PatternShape::Scalar(serde_json::Value::Number(number)) if comparable_number(number).is_some() => {}
544            PatternShape::Array(values) => self.check_anything_but_values(&values, match_location, operand_location),
545            PatternShape::Object(entries) => self.check_nested_anything_but(&entries, match_location, operand_location),
546            PatternShape::Scalar(_) => self.add_finding(
547                operand_location,
548                format!("anything-but in '{}' has an unsupported value", match_location.display_path),
549                ANYTHING_BUT_FIX,
550            ),
551        }
552    }
553
554    fn check_anything_but_values(
555        &mut self,
556        values: &[PatternValue<'_>],
557        match_location: &PatternLocation,
558        operand_location: &PatternLocation,
559    ) {
560        if values.is_empty() {
561            self.add_finding(
562                operand_location,
563                format!("anything-but in '{}' must not use an empty array", match_location.display_path),
564                ANYTHING_BUT_FIX,
565            );
566            return;
567        }
568        let mut value_kind = None;
569        for (index, value) in values.iter().enumerate() {
570            let value_location = operand_location.item(index);
571            let current_kind = match self.shape_at(*value, &value_location) {
572                PatternShape::Unknown => continue,
573                PatternShape::Scalar(serde_json::Value::String(_)) => AnythingButValueKind::String,
574                PatternShape::Scalar(serde_json::Value::Number(number)) if comparable_number(number).is_some() => {
575                    AnythingButValueKind::Number
576                }
577                PatternShape::Object(_) | PatternShape::Array(_) | PatternShape::Scalar(_) => {
578                    self.add_finding(
579                        &value_location,
580                        format!("anything-but in '{}' accepts only strings or numbers", match_location.display_path),
581                        ANYTHING_BUT_FIX,
582                    );
583                    return;
584                }
585            };
586            if value_kind.is_some_and(|existing| existing != current_kind) {
587                self.add_finding(
588                    operand_location,
589                    format!("anything-but in '{}' cannot mix strings and numbers", match_location.display_path),
590                    ANYTHING_BUT_FIX,
591                );
592                return;
593            }
594            value_kind = Some(current_kind);
595        }
596    }
597
598    fn check_nested_anything_but(
599        &mut self,
600        entries: &[(&str, PatternValue<'_>)],
601        match_location: &PatternLocation,
602        operand_location: &PatternLocation,
603    ) {
604        if entries.len() != 1 {
605            self.add_finding(
606                operand_location,
607                format!("anything-but in '{}' must contain exactly one nested operator", match_location.display_path),
608                ANYTHING_BUT_FIX,
609            );
610            return;
611        }
612        let (nested_operator, nested_operand) = entries[0];
613        let nested_location = operand_location.field(nested_operator);
614        if !ANYTHING_BUT_NESTED_OPERATORS.contains(&nested_operator) {
615            self.add_finding(
616                &nested_location,
617                format!("anything-but in '{}' cannot use '{nested_operator}'", match_location.display_path),
618                ANYTHING_BUT_FIX,
619            );
620            return;
621        }
622        self.check_nested_string_values(nested_operator, nested_operand, match_location, &nested_location);
623    }
624
625    fn check_nested_string_values(
626        &mut self,
627        nested_operator: &str,
628        operand: PatternValue<'_>,
629        match_location: &PatternLocation,
630        operand_location: &PatternLocation,
631    ) {
632        match self.shape_at(operand, operand_location) {
633            PatternShape::Unknown => {}
634            PatternShape::Scalar(serde_json::Value::String(value)) => {
635                if matches!(nested_operator, PREFIX_OPERATOR | SUFFIX_OPERATOR) && value.is_empty() {
636                    self.add_finding(
637                        operand_location,
638                        format!(
639                            "anything-but {nested_operator} in '{}' must not be empty",
640                            match_location.display_path
641                        ),
642                        ANYTHING_BUT_FIX,
643                    );
644                } else if nested_operator == WILDCARD_OPERATOR {
645                    self.check_wildcard_text(value, match_location, operand_location);
646                }
647            }
648            PatternShape::Array(values) => {
649                for (index, value) in values.iter().enumerate() {
650                    let value_location = operand_location.item(index);
651                    match self.shape_at(*value, &value_location) {
652                        PatternShape::Unknown => {}
653                        PatternShape::Scalar(serde_json::Value::String(text)) => {
654                            if matches!(nested_operator, PREFIX_OPERATOR | SUFFIX_OPERATOR) && text.is_empty() {
655                                self.add_finding(
656                                    &value_location,
657                                    format!(
658                                        "anything-but {nested_operator} in '{}' must not contain an empty string",
659                                        match_location.display_path
660                                    ),
661                                    ANYTHING_BUT_FIX,
662                                );
663                                return;
664                            }
665                            if nested_operator == WILDCARD_OPERATOR
666                                && self.check_wildcard_text(text, match_location, &value_location)
667                            {
668                                return;
669                            }
670                        }
671                        PatternShape::Object(_) | PatternShape::Array(_) | PatternShape::Scalar(_) => {
672                            self.add_finding(
673                                &value_location,
674                                format!(
675                                    "anything-but {nested_operator} in '{}' accepts only strings",
676                                    match_location.display_path
677                                ),
678                                ANYTHING_BUT_FIX,
679                            );
680                            return;
681                        }
682                    }
683                }
684            }
685            PatternShape::Object(_) | PatternShape::Scalar(_) => self.add_finding(
686                operand_location,
687                format!("anything-but {nested_operator} in '{}' accepts only strings", match_location.display_path),
688                ANYTHING_BUT_FIX,
689            ),
690        }
691    }
692
693    fn check_wildcard(
694        &mut self,
695        operand: PatternValue<'_>,
696        match_location: &PatternLocation,
697        operand_location: &PatternLocation,
698    ) {
699        match self.shape_at(operand, operand_location) {
700            PatternShape::Unknown => {}
701            PatternShape::Scalar(serde_json::Value::String(wildcard)) => {
702                self.check_wildcard_text(wildcard, match_location, operand_location);
703            }
704            PatternShape::Object(_) | PatternShape::Array(_) | PatternShape::Scalar(_) => self.add_finding(
705                operand_location,
706                format!("wildcard in '{}' must be a string", match_location.display_path),
707                WILDCARD_FIX,
708            ),
709        }
710    }
711
712    fn check_wildcard_text(
713        &mut self,
714        wildcard: &str,
715        match_location: &PatternLocation,
716        operand_location: &PatternLocation,
717    ) -> bool {
718        if let Some(problem) = wildcard_problem(wildcard) {
719            self.add_finding(
720                operand_location,
721                format!("wildcard in '{}' {problem}", match_location.display_path),
722                WILDCARD_FIX,
723            );
724            return true;
725        }
726        false
727    }
728
729    fn check_cidr(
730        &mut self,
731        operand: PatternValue<'_>,
732        match_location: &PatternLocation,
733        operand_location: &PatternLocation,
734    ) {
735        let is_valid = match self.shape_at(operand, operand_location) {
736            PatternShape::Unknown => true,
737            PatternShape::Scalar(serde_json::Value::String(cidr)) => is_valid_eventbridge_cidr(cidr),
738            PatternShape::Object(_) | PatternShape::Array(_) | PatternShape::Scalar(_) => false,
739        };
740        if !is_valid {
741            self.add_finding(
742                operand_location,
743                format!("cidr in '{}' must be a valid IPv4 or IPv6 network", match_location.display_path),
744                CIDR_FIX,
745            );
746        }
747    }
748
749    fn check_string_matcher(
750        &mut self,
751        operator: &str,
752        operand: PatternValue<'_>,
753        match_location: &PatternLocation,
754        operand_location: &PatternLocation,
755    ) {
756        if !matches!(
757            self.shape_at(operand, operand_location),
758            PatternShape::Unknown | PatternShape::Scalar(serde_json::Value::String(_))
759        ) {
760            self.add_finding(
761                operand_location,
762                format!("{operator} in '{}' must be a string", match_location.display_path),
763                STRING_MATCHER_FIX,
764            );
765        }
766    }
767
768    fn shape_at<'value>(&self, value: PatternValue<'value>, location: &PatternLocation) -> PatternShape<'value> {
769        if self.location_is_parameter_controlled(location) { PatternShape::Unknown } else { value.shape() }
770    }
771
772    fn location_is_parameter_controlled(&self, location: &PatternLocation) -> bool {
773        location.authored_path.as_deref().is_some_and(|property_path| {
774            self.model.scenario_value_uses_unresolved_parameter(self.resource_id, property_path, self.conditions)
775        })
776    }
777
778    fn known_string<'value>(&self, value: PatternValue<'value>, location: &PatternLocation) -> Option<&'value str> {
779        match self.shape_at(value, location) {
780            PatternShape::Scalar(serde_json::Value::String(text)) => Some(text),
781            PatternShape::Object(_) | PatternShape::Array(_) | PatternShape::Scalar(_) | PatternShape::Unknown => None,
782        }
783    }
784
785    fn numeric_operand(&self, value: PatternValue<'_>, location: &PatternLocation) -> NumericOperand {
786        match self.shape_at(value, location) {
787            PatternShape::Unknown => NumericOperand::Unknown,
788            PatternShape::Scalar(serde_json::Value::Number(number)) => {
789                comparable_number(number).map_or(NumericOperand::Invalid, NumericOperand::Known)
790            }
791            PatternShape::Object(_) | PatternShape::Array(_) | PatternShape::Scalar(_) => NumericOperand::Invalid,
792        }
793    }
794
795    fn add_finding(&mut self, location: &PatternLocation, message: String, suggested_fix: &str) {
796        self.findings.push(PatternFinding {
797            property_path: location.diagnostic_path(),
798            message,
799            suggested_fix: suggested_fix.to_string(),
800        });
801    }
802}
803
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805enum AnythingButValueKind {
806    String,
807    Number,
808}
809
810#[derive(Debug, Clone, Copy, PartialEq)]
811enum NumericOperand {
812    Known(f64),
813    Unknown,
814    Invalid,
815}
816
817fn is_valid_eventbridge_cidr(cidr: &str) -> bool {
818    let mut components = cidr.trim_end_matches('/').split('/');
819    let (Some(address), Some(mask), None) = (components.next(), components.next(), components.next()) else {
820        return false;
821    };
822    let Ok(mask_bits) = mask.parse::<i16>() else {
823        return false;
824    };
825    if address.contains(':') {
826        if address.contains('.') {
827            return false;
828        }
829        let Ok(ipv6) = address.parse::<Ipv6Addr>() else {
830            return false;
831        };
832        if ipv6.to_ipv4_mapped().is_some() { (0..32).contains(&mask_bits) } else { (0..128).contains(&mask_bits) }
833    } else {
834        is_valid_ipv4_literal(address) && (0..32).contains(&mask_bits)
835    }
836}
837
838fn is_valid_ipv4_literal(address: &str) -> bool {
839    let octets: Vec<&str> = address.split('.').collect();
840    octets.len() == 4
841        && octets.iter().all(|octet| {
842            !octet.is_empty() && octet.bytes().all(|byte| byte.is_ascii_digit()) && octet.parse::<u8>().is_ok()
843        })
844}
845
846#[derive(Debug, PartialEq, Eq)]
847struct NormalizedDecimal {
848    is_negative: bool,
849    digits: String,
850    exponent: i64,
851}
852
853fn comparable_number(number: &serde_json::Number) -> Option<f64> {
854    let value = number.as_f64().filter(|value| value.is_finite())?;
855    let authored = normalize_decimal(&number.to_string())?;
856    let represented = normalize_decimal(&value.to_string())?;
857    (authored == represented).then_some(value)
858}
859
860fn normalize_decimal(text: &str) -> Option<NormalizedDecimal> {
861    let (is_negative, unsigned) = text.strip_prefix('-').map_or((false, text), |value| (true, value));
862    let (mantissa, explicit_exponent) = match unsigned.find(['e', 'E']) {
863        Some(index) => {
864            let exponent_text = unsigned.get(index + 1..)?;
865            if exponent_text.contains(['e', 'E']) {
866                return None;
867            }
868            (unsigned.get(..index)?, exponent_text.parse::<i64>().ok()?)
869        }
870        None => (unsigned, 0),
871    };
872    let mut components = mantissa.split('.');
873    let whole = components.next()?;
874    let fraction = components.next().unwrap_or_default();
875    if components.next().is_some()
876        || whole.is_empty()
877        || !whole.bytes().all(|byte| byte.is_ascii_digit())
878        || !fraction.bytes().all(|byte| byte.is_ascii_digit())
879    {
880        return None;
881    }
882    let mut digits = format!("{whole}{fraction}").trim_start_matches('0').to_string();
883    if digits.is_empty() {
884        return Some(NormalizedDecimal { is_negative: false, digits: "0".to_string(), exponent: 0 });
885    }
886    let mut exponent = explicit_exponent.checked_sub(i64::try_from(fraction.len()).ok()?)?;
887    while digits.ends_with('0') {
888        digits.pop();
889        exponent = exponent.checked_add(1)?;
890    }
891    Some(NormalizedDecimal { is_negative, digits, exponent })
892}
893
894fn wildcard_problem(wildcard: &str) -> Option<&'static str> {
895    let mut characters = wildcard.chars();
896    let mut previous_was_wildcard = false;
897    while let Some(character) = characters.next() {
898        match character {
899            '\\' => match characters.next() {
900                Some('*' | '\\') => previous_was_wildcard = false,
901                Some(_) => return Some("uses an unsupported escape sequence"),
902                None => return Some("ends with an incomplete escape sequence"),
903            },
904            '*' if previous_was_wildcard => return Some("has consecutive wildcard characters"),
905            '*' => previous_was_wildcard = true,
906            _ => previous_was_wildcard = false,
907        }
908    }
909    None
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915
916    fn violations(event_pattern_yaml: &str) -> Vec<EventPatternViolation> {
917        let template = format!(
918            "Resources:\n  Rule:\n    Type: AWS::Events::Rule\n    Properties:\n      EventPattern: {event_pattern_yaml}\n"
919        );
920        let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
921        event_pattern_violations(&model)
922    }
923
924    fn messages(event_pattern_yaml: &str) -> Vec<String> {
925        violations(event_pattern_yaml).into_iter().map(|violation| violation.message).collect()
926    }
927
928    #[test]
929    fn matcher_objects_are_not_read_as_field_selectors() {
930        assert!(messages(r#"{"detail": {"x": [{"prefix": "a"}, {"anything-but": {"prefix": "b"}}]}}"#).is_empty());
931    }
932
933    #[test]
934    fn nested_field_selectors_are_checked_at_any_depth_with_precise_paths() {
935        let findings = violations(r#"{"detail": {"a": {"b": {"c": {"d": "plain"}}}}}"#);
936        assert_eq!(findings.len(), 1);
937        assert_eq!(findings[0].property_path, "Properties.EventPattern.detail.a.b.c.d");
938        assert_eq!(findings[0].message, "'EventPattern.detail.a.b.c.d' must be an array or an object");
939    }
940
941    #[test]
942    fn or_accepts_siblings_and_checks_every_branch() {
943        assert!(
944            messages(r#"{"source": ["a"], "$or": [{"detail-type": ["one"]}, {"detail-type": ["two"]}]}"#).is_empty()
945        );
946        assert_eq!(
947            messages(r#"{"$or": [{"source": ["a"]}, {"detail-type": []}]}"#),
948            vec!["'EventPattern.$or[1].detail-type' is an empty array"]
949        );
950    }
951
952    #[test]
953    fn or_requires_an_array_with_at_least_two_object_branches() {
954        for pattern in [
955            r#"{"$or": {"source": ["a"]}}"#,
956            r#"{"$or": [{"source": ["a"]}]}"#,
957            r#"{"$or": ["not-an-object", {"source": ["a"]}]}"#,
958        ] {
959            assert_eq!(messages(pattern).len(), 1, "{pattern}");
960        }
961    }
962
963    #[test]
964    fn or_branches_reject_reserved_field_names_at_any_depth() {
965        for pattern in [
966            r#"{"$or": [{"prefix": ["a"]}, {"source": ["b"]}]}"#,
967            r#"{"$or": [{"detail": {"numeric": ["1"]}}, {"source": ["b"]}]}"#,
968        ] {
969            assert_eq!(messages(pattern).len(), 1, "{pattern}");
970        }
971        assert!(messages(r#"{"prefix": ["a"], "source": ["b"]}"#).is_empty());
972    }
973
974    #[test]
975    fn json_string_pattern_is_parsed_before_checking() {
976        let findings = violations(r#"'{"source": [{"startsWith": "aws."}]}'"#);
977        assert_eq!(findings.len(), 1);
978        assert_eq!(findings[0].property_path, EVENT_PATTERN_PROPERTY_PATH);
979        assert_eq!(findings[0].message, "'startsWith' in 'EventPattern.source[0]' is not an event pattern operator");
980    }
981
982    #[test]
983    fn malformed_json_and_non_object_roots_are_reported() {
984        assert_eq!(messages("'not json'"), vec!["EventPattern string is not valid JSON"]);
985        assert_eq!(messages("'[]'"), vec!["EventPattern must be a JSON object"]);
986        assert_eq!(messages("'null'"), vec!["EventPattern must be a JSON object"]);
987    }
988
989    #[test]
990    fn pattern_from_overridable_parameter_is_not_judged() {
991        let template = r#"
992Parameters:
993  Pattern:
994    Type: String
995    Default: '{"source": "aws.ec2"}'
996Resources:
997  Rule:
998    Type: AWS::Events::Rule
999    Properties:
1000      EventPattern: !Ref Pattern
1001"#;
1002        let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
1003        let findings = event_pattern_violations(&model);
1004        assert!(findings.is_empty(), "a caller can override the parameter before deployment: {findings:?}");
1005    }
1006
1007    #[test]
1008    fn parameter_controlled_value_does_not_hide_invalid_concrete_sibling() {
1009        let template = r#"
1010Parameters:
1011  Sources:
1012    Type: CommaDelimitedList
1013    Default: example.app
1014Resources:
1015  Rule:
1016    Type: AWS::Events::Rule
1017    Properties:
1018      EventPattern:
1019        source: !Ref Sources
1020        detail:
1021          state: []
1022"#;
1023        let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
1024        let findings = event_pattern_violations(&model);
1025        assert_eq!(findings.len(), 1, "the concrete sibling remains invalid: {findings:?}");
1026        assert_eq!(findings[0].property_path, "Properties.EventPattern.detail.state");
1027    }
1028
1029    #[test]
1030    fn notification_arns_list_does_not_hide_invalid_concrete_sibling() {
1031        let template = r#"
1032Resources:
1033  Rule:
1034    Type: AWS::Events::Rule
1035    Properties:
1036      EventPattern:
1037        resources: !Ref AWS::NotificationARNs
1038        detail:
1039          state: []
1040"#;
1041        let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
1042        let findings = event_pattern_violations(&model);
1043        assert_eq!(findings.len(), 1, "the concrete sibling remains invalid: {findings:?}");
1044        assert_eq!(findings[0].property_path, "Properties.EventPattern.detail.state");
1045    }
1046
1047    #[test]
1048    fn scalar_pseudo_parameter_shapes_are_checked() {
1049        let template = r#"
1050Resources:
1051  Rule:
1052    Type: AWS::Events::Rule
1053    Properties:
1054      EventPattern:
1055        source: !Ref AWS::Region
1056        detail:
1057          account: [{numeric: [">", !Ref AWS::AccountId]}]
1058"#;
1059        let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
1060        let findings = event_pattern_violations(&model);
1061        assert_eq!(findings.len(), 2, "both scalar pseudo-parameter shapes are invalid: {findings:?}");
1062        assert!(
1063            findings.iter().any(|finding| finding.property_path == "Properties.EventPattern.source"),
1064            "source must remain subject to field-selector shape validation: {findings:?}"
1065        );
1066        assert!(
1067            findings
1068                .iter()
1069                .any(|finding| finding.property_path == "Properties.EventPattern.detail.account.0.numeric.1"),
1070            "AccountId must remain subject to numeric operand validation: {findings:?}"
1071        );
1072    }
1073
1074    #[test]
1075    fn notification_arns_list_shape_is_checked_in_matcher_contexts() {
1076        let template = r#"
1077Resources:
1078  Rule:
1079    Type: AWS::Events::Rule
1080    Properties:
1081      EventPattern:
1082        resources: [!Ref AWS::NotificationARNs]
1083        detail:
1084          topic: [{prefix: !Ref AWS::NotificationARNs}]
1085"#;
1086        let model = SemanticModel::from_bytes(template.as_bytes()).expect("template parses");
1087        let findings = event_pattern_violations(&model);
1088        assert_eq!(findings.len(), 2, "list values are invalid in both matcher contexts: {findings:?}");
1089        assert!(
1090            findings.iter().any(|finding| finding.property_path == "Properties.EventPattern.resources.0"),
1091            "a nested match array must be rejected: {findings:?}"
1092        );
1093        assert!(
1094            findings.iter().any(|finding| finding.property_path == "Properties.EventPattern.detail.topic.0.prefix"),
1095            "prefix must reject a list operand: {findings:?}"
1096        );
1097    }
1098
1099    #[test]
1100    fn prefix_and_suffix_case_insensitive_forms_require_one_string() {
1101        assert!(messages(r#"{"x": [{"prefix": {"equals-ignore-case": "abc"}}, {"suffix": "xyz"}]}"#).is_empty());
1102        for pattern in [
1103            r#"{"x": [{"prefix": {"bogus": "abc"}}]}"#,
1104            r#"{"x": [{"prefix": {"equals-ignore-case": 5}}]}"#,
1105            r#"{"x": [{"suffix": {"equals-ignore-case": "abc", "extra": "x"}}]}"#,
1106        ] {
1107            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1108        }
1109    }
1110
1111    #[test]
1112    fn numeric_matchers_require_valid_comparison_structure() {
1113        for pattern in [
1114            r#"{"x": [{"numeric": ">"}]}"#,
1115            r#"{"x": [{"numeric": [">"]}]}"#,
1116            r#"{"x": [{"numeric": [">", true]}]}"#,
1117            r#"{"x": [{"numeric": ["<", 1, "<", 2]}]}"#,
1118            r#"{"x": [{"numeric": [">", 1, ">=", 2]}]}"#,
1119        ] {
1120            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1121        }
1122        for pattern in [
1123            r#"{"x": [{"numeric": ["=", 3.8]}]}"#,
1124            r#"'{"x": [{"numeric": ["=", 1E-320]}]}'"#,
1125            r#"{"x": [{"numeric": [">=", 0, "<=", 1]}]}"#,
1126        ] {
1127            assert!(messages(pattern).is_empty(), "{pattern}");
1128        }
1129    }
1130
1131    #[test]
1132    fn numeric_comparison_values_must_preserve_their_decimal_value() {
1133        for pattern in [
1134            r#"{"x": [{"numeric": ["=", 9007199254740993]}]}"#,
1135            r#"{"x": [{"numeric": ["=", 9007199254740995]}]}"#,
1136            r#"'{"x": [{"numeric": ["=", 9007199254740993.0]}]}'"#,
1137            r#"'{"x": [{"numeric": ["=", 0.30000000000000001]}]}'"#,
1138        ] {
1139            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1140        }
1141        for pattern in [
1142            r#"{"x": [{"numeric": ["=", 9007199254740992.0]}]}"#,
1143            r#"{"x": [{"numeric": ["=", 9007199254740994]}]}"#,
1144            r#"{"x": [{"numeric": ["=", -9007199254740994]}]}"#,
1145            r#"{"x": [{"numeric": ["=", 1.0e+16]}]}"#,
1146        ] {
1147            assert!(messages(pattern).is_empty(), "{pattern}");
1148        }
1149    }
1150
1151    #[test]
1152    fn numeric_ranges_require_strictly_increasing_bounds() {
1153        let expected = vec![
1154            "numeric range in 'EventPattern.x[0]' must have a lower bound strictly less than its upper bound"
1155                .to_string(),
1156        ];
1157        for pattern in [
1158            r#"{"x": [{"numeric": [">", 2, "<", 1]}]}"#,
1159            r#"{"x": [{"numeric": [">=", 1, "<=", 1]}]}"#,
1160            r#"{"x": [{"numeric": [">=", -0.0, "<=", 0]}]}"#,
1161        ] {
1162            assert_eq!(messages(pattern), expected, "{pattern}");
1163        }
1164    }
1165
1166    #[test]
1167    fn anything_but_requires_supported_homogeneous_values() {
1168        for pattern in [
1169            r#"{"x": [{"anything-but": true}]}"#,
1170            r#"{"x": [{"anything-but": []}]}"#,
1171            r#"{"x": [{"anything-but": ["a", 1]}]}"#,
1172            r#"{"x": [{"anything-but": {"prefix": ""}}]}"#,
1173            r#"{"x": [{"anything-but": {"wildcard": 5}}]}"#,
1174            r#"{"x": [{"anything-but": {"prefix": "a", "suffix": "b"}}]}"#,
1175        ] {
1176            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1177        }
1178        for pattern in [
1179            r#"{"x": [{"anything-but": ["a", "b"]}]}"#,
1180            r#"{"x": [{"anything-but": [1, 2]}]}"#,
1181            r#"{"x": [{"anything-but": 9007199254740994}]}"#,
1182            r#"{"x": [{"anything-but": 1e16}]}"#,
1183            r#"{"x": [{"anything-but": {"prefix": ["a", "b"]}}]}"#,
1184            r#"{"x": [{"anything-but": {"equals-ignore-case": ["a", ""]}}]}"#,
1185            r#"{"x": [{"anything-but": {"prefix": []}}]}"#,
1186            r#"{"x": [{"anything-but": {"wildcard": []}}]}"#,
1187        ] {
1188            assert!(messages(pattern).is_empty(), "{pattern}");
1189        }
1190    }
1191
1192    #[test]
1193    fn wildcard_validation_distinguishes_escaped_literals_from_wildcards() {
1194        for pattern in [
1195            r#"{"x": [{"wildcard": "a*b"}]}"#,
1196            r#"{"x": [{"wildcard": "a\\**b"}]}"#,
1197            r#"{"x": [{"wildcard": "a\\\\*b"}]}"#,
1198        ] {
1199            assert!(messages(pattern).is_empty(), "{pattern}");
1200        }
1201        for pattern in [
1202            r#"{"x": [{"wildcard": "a**b"}]}"#,
1203            r#"{"x": [{"wildcard": "a\\qb"}]}"#,
1204            r#"{"x": [{"wildcard": "a\\"}]}"#,
1205            r#"{"x": [{"wildcard": 5}]}"#,
1206        ] {
1207            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1208        }
1209    }
1210
1211    #[test]
1212    fn nested_wildcard_list_reports_once_per_matcher() {
1213        for pattern in [
1214            r#"{"x": [{"anything-but": {"wildcard": ["a**", "b**"]}}]}"#,
1215            r#"'{"x": [{"anything-but": {"wildcard": ["a**", "b**"]}}]}'"#,
1216        ] {
1217            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1218        }
1219    }
1220
1221    #[test]
1222    fn cidr_and_string_matchers_follow_event_pattern_grammar() {
1223        for pattern in [
1224            r#"{"ip": [{"cidr": "10.0.0.0/24"}]}"#,
1225            r#"{"ip": [{"cidr": "10.0.0.0/24//"}]}"#,
1226            r#"{"ip": [{"cidr": "10.0.0.0/-0"}]}"#,
1227            r#"{"ip": [{"cidr": "10.0.0.0/31"}]}"#,
1228            r#"{"ip": [{"cidr": "2001:db8::/127"}]}"#,
1229            r#"{"ip": [{"cidr": "::ffff:a00:0/24"}]}"#,
1230            r#"{"name": [{"equals-ignore-case": "ABC"}, {"exactly": "abc"}]}"#,
1231        ] {
1232            assert!(messages(pattern).is_empty(), "{pattern}");
1233        }
1234        for pattern in [
1235            r#"{"x": [{"cidr": "not-a-network"}]}"#,
1236            r#"{"x": [{"cidr": "10.0.0.1"}]}"#,
1237            r#"{"x": [{"cidr": "10.0.0.0/32"}]}"#,
1238            r#"{"x": [{"cidr": "2001:db8::/128"}]}"#,
1239            r#"{"x": [{"cidr": "10.0.0.0/255.255.255.0"}]}"#,
1240            r#"{"x": [{"cidr": "::ffff:10.0.0.0/104"}]}"#,
1241            r#"{"x": [{"cidr": "::ffff:a00:0/104"}]}"#,
1242            r#"{"x": [{"cidr": 5}]}"#,
1243            r#"{"x": [{"equals-ignore-case": ["abc"]}]}"#,
1244            r#"{"x": [{"exactly": 5}]}"#,
1245        ] {
1246            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1247        }
1248    }
1249
1250    #[test]
1251    fn matcher_objects_require_exactly_one_operator() {
1252        for pattern in [r#"{"x": [{}]}"#, r#"{"x": [{"prefix": "a", "suffix": "b"}]}"#] {
1253            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1254        }
1255    }
1256
1257    #[test]
1258    fn field_selectors_reject_empty_objects_nested_arrays_and_bare_null() {
1259        for pattern in [r#"{}"#, r#"{"detail": {}}"#, r#"{"x": [["a"]]}"#, r#"{"x": null}"#] {
1260            assert_eq!(messages(pattern).len(), 1, "{pattern}");
1261        }
1262        assert!(messages(r#"{"x": [null, true, false, 1, 1e400, "a"]}"#).is_empty());
1263    }
1264
1265    #[test]
1266    fn identical_invalid_matchers_keep_distinct_paths() {
1267        let findings = violations(r#"{"x": [{"startsWith": "a"}, {"startsWith": "b"}]}"#);
1268        assert_eq!(findings.len(), 2);
1269        assert_eq!(findings[0].property_path, "Properties.EventPattern.x.0.startsWith");
1270        assert_eq!(findings[1].property_path, "Properties.EventPattern.x.1.startsWith");
1271    }
1272}