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#[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}