1use log::warn;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::sync::OnceLock;
6use template_model::EntityType;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
11#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
12#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
13#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
14#[serde(rename_all = "camelCase")]
15pub struct IdRange {
16 pub prefix: String,
17 pub start: u32,
18 pub end: u32,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
24#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
25#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
26#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
27#[serde(rename_all = "camelCase")]
28pub struct ResourceIdFilter {
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
31 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
32 pub rule_id: Option<String>,
33 pub resource_id: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
42#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
43#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
44#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
45#[serde(rename_all = "camelCase")]
46pub struct LogicalIdFilter {
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
49 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
50 pub rule_id: Option<String>,
51 pub logical_id: String,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
54 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
55 pub entity_type: Option<EntityType>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
62#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
63#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
64#[serde(rename_all = "camelCase")]
65pub struct ResourceTypeFilter {
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
68 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
69 pub rule_id: Option<String>,
70 pub resource_type: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
82#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
83#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
84#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
85#[serde(rename_all = "camelCase")]
86pub struct ServiceFilter {
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
89 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
90 pub rule_id: Option<String>,
91 pub service: String,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
97#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
98#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
99#[serde(rename_all = "camelCase")]
100pub struct RuleFilterConfig {
101 #[serde(default)]
102 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
103 pub ids: Vec<String>,
104 #[serde(default)]
105 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
106 pub categories: Vec<String>,
107 #[serde(default)]
108 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
109 pub id_ranges: Vec<IdRange>,
110 #[serde(default)]
112 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
113 pub id_patterns: Vec<String>,
114 #[serde(default)]
115 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
116 pub resource_ids: Vec<ResourceIdFilter>,
117 #[serde(default)]
118 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
119 pub logical_ids: Vec<LogicalIdFilter>,
120 #[serde(default)]
121 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
122 pub resource_types: Vec<ResourceTypeFilter>,
123 #[serde(default)]
124 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
125 pub services: Vec<ServiceFilter>,
126}
127
128impl RuleFilterConfig {
129 pub fn is_empty(&self) -> bool {
130 self.ids.is_empty()
131 && self.categories.is_empty()
132 && self.id_ranges.is_empty()
133 && self.id_patterns.is_empty()
134 && self.resource_ids.is_empty()
135 && self.logical_ids.is_empty()
136 && self.resource_types.is_empty()
137 && self.services.is_empty()
138 }
139
140 fn matches(
141 &self,
142 rule_id: &str,
143 category: Option<&str>,
144 resource_id: Option<&str>,
145 resource_type: Option<&str>,
146 logical_id: Option<&str>,
147 entity_type: Option<EntityType>,
148 compiled: &CompiledPatterns,
149 ) -> bool {
150 if self.ids.iter().any(|id| id == rule_id) {
151 return true;
152 }
153 if let Some(cat) = category
154 && self.categories.iter().any(|c| c == cat)
155 {
156 return true;
157 }
158 if self.id_ranges.iter().any(|r| range_matches(rule_id, r)) {
159 return true;
160 }
161 if compiled.matches(rule_id) {
162 return true;
163 }
164 if let Some(rid) = resource_id
165 && self.resource_ids.iter().any(|f| f.resource_id == rid && rule_scope_matches(&f.rule_id, rule_id))
166 {
167 return true;
168 }
169 if let Some(lid) = logical_id
170 && self.logical_ids.iter().any(|f| {
171 f.logical_id == lid
172 && rule_scope_matches(&f.rule_id, rule_id)
173 && f.entity_type.is_none_or(|scoped| entity_type == Some(scoped))
174 })
175 {
176 return true;
177 }
178 if let Some(rtype) = resource_type {
179 if self.resource_types.iter().any(|f| f.resource_type == rtype && rule_scope_matches(&f.rule_id, rule_id)) {
180 return true;
181 }
182 if let Some(service) = service_prefix(rtype)
183 && self.services.iter().any(|f| f.service == service && rule_scope_matches(&f.rule_id, rule_id))
184 {
185 return true;
186 }
187 }
188 false
189 }
190}
191
192fn rule_scope_matches(scope: &Option<String>, rule_id: &str) -> bool {
195 scope.as_deref().is_none_or(|id| id == rule_id)
196}
197
198fn service_prefix(resource_type: &str) -> Option<&str> {
203 let first = resource_type.find("::")?;
206 let service_name = &resource_type[first + 2..];
207 let second_relative = service_name.find("::")?;
208 if second_relative == 0 {
209 return None; }
211 Some(&resource_type[..first + 2 + second_relative])
212}
213
214#[derive(Debug, Default, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct FilterConfig {
221 #[serde(default)]
222 pub include: RuleFilterConfig,
223 #[serde(default)]
224 pub exclude: RuleFilterConfig,
225 #[serde(skip)]
226 compiled: OnceLock<CompiledFilters>,
227}
228
229impl Clone for FilterConfig {
230 fn clone(&self) -> Self {
231 FilterConfig { include: self.include.clone(), exclude: self.exclude.clone(), compiled: OnceLock::new() }
232 }
233}
234
235impl FilterConfig {
236 pub fn new(include: RuleFilterConfig, exclude: RuleFilterConfig) -> Self {
237 FilterConfig { include, exclude, compiled: OnceLock::new() }
238 }
239
240 pub fn is_empty(&self) -> bool {
241 self.include.is_empty() && self.exclude.is_empty()
242 }
243
244 fn compiled(&self) -> &CompiledFilters {
245 self.compiled.get_or_init(|| CompiledFilters {
246 include: CompiledPatterns::new(&self.include.id_patterns),
247 exclude: CompiledPatterns::new(&self.exclude.id_patterns),
248 })
249 }
250
251 #[must_use]
252 pub fn matches_rule(
253 &self,
254 rule_id: &str,
255 category: Option<&str>,
256 resource_id: Option<&str>,
257 resource_type: Option<&str>,
258 logical_id: Option<&str>,
259 entity_type: Option<EntityType>,
260 ) -> bool {
261 let compiled = self.compiled();
262 if !self.include.is_empty()
263 && !self.include.matches(
264 rule_id,
265 category,
266 resource_id,
267 resource_type,
268 logical_id,
269 entity_type,
270 &compiled.include,
271 )
272 {
273 return false;
274 }
275 if self.exclude.matches(
276 rule_id,
277 category,
278 resource_id,
279 resource_type,
280 logical_id,
281 entity_type,
282 &compiled.exclude,
283 ) {
284 return false;
285 }
286 true
287 }
288
289 pub fn excluded_categories(&self) -> HashSet<&str> {
290 let mut cats: HashSet<&str> = self.exclude.categories.iter().map(|s| s.as_str()).collect();
291 for c in &self.include.categories {
292 cats.remove(c.as_str());
293 }
294 cats
295 }
296
297 #[must_use]
302 pub fn invalid_patterns(&self) -> Vec<String> {
303 let compiled = self.compiled();
304 let mut invalid = compiled.include.invalid.clone();
305 invalid.extend(compiled.exclude.invalid.iter().cloned());
306 invalid
307 }
308}
309
310#[derive(Debug)]
311struct CompiledPatterns {
312 regexes: Vec<Regex>,
313 invalid: Vec<String>,
314}
315
316impl CompiledPatterns {
317 fn new(patterns: &[String]) -> Self {
318 let mut regexes = Vec::new();
319 let mut invalid = Vec::new();
320 for pattern in patterns {
321 match Regex::new(pattern) {
322 Ok(regex) => regexes.push(regex),
323 Err(error) => {
324 warn!("Invalid rule-id filter pattern '{}': {}", pattern, error);
325 invalid.push(pattern.clone());
326 }
327 }
328 }
329 CompiledPatterns { regexes, invalid }
330 }
331
332 fn matches(&self, rule_id: &str) -> bool {
333 self.regexes.iter().any(|r| r.is_match(rule_id))
334 }
335}
336
337#[derive(Debug)]
338struct CompiledFilters {
339 include: CompiledPatterns,
340 exclude: CompiledPatterns,
341}
342
343fn range_matches(rule_id: &str, range: &IdRange) -> bool {
344 let Some(suffix) = rule_id.strip_prefix(&range.prefix) else {
345 return false;
346 };
347 let Ok(num) = suffix.parse::<u32>() else {
348 return false;
349 };
350 num >= range.start && num <= range.end
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn empty_filter_matches_all_rules() {
359 let f = FilterConfig::default();
360 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
361 assert!(f.matches_rule("W9037", Some("security"), Some("Bucket"), Some("AWS::S3::Bucket"), None, None));
362 }
363
364 #[test]
365 fn include_by_id_accepts_matching_and_rejects_others() {
366 let f = FilterConfig {
367 include: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
368 ..Default::default()
369 };
370 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
371 assert!(!f.matches_rule("E3013", Some("schema"), None, None, None, None));
372 }
373
374 #[test]
375 fn exclude_by_id_rejects_matching_and_accepts_others() {
376 let f = FilterConfig {
377 exclude: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
378 ..Default::default()
379 };
380 assert!(!f.matches_rule("E3012", Some("schema"), None, None, None, None));
381 assert!(f.matches_rule("E3013", Some("schema"), None, None, None, None));
382 }
383
384 #[test]
385 fn include_by_category_accepts_matching_category_only() {
386 let f = FilterConfig {
387 include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
388 ..Default::default()
389 };
390 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
391 assert!(!f.matches_rule("W9501", Some("security"), None, None, None, None));
392 }
393
394 #[test]
395 fn exclude_by_category_rejects_matching_category() {
396 let f = FilterConfig {
397 exclude: RuleFilterConfig { categories: vec!["best-practice".into()], ..Default::default() },
398 ..Default::default()
399 };
400 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
401 assert!(!f.matches_rule("I9040", Some("best-practice"), None, None, None, None));
402 }
403
404 #[test]
405 fn include_by_id_range_accepts_within_bounds_only() {
406 let f = FilterConfig {
407 include: RuleFilterConfig {
408 id_ranges: vec![IdRange { prefix: "E".into(), start: 3000, end: 3099 }],
409 ..Default::default()
410 },
411 ..Default::default()
412 };
413 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
414 assert!(f.matches_rule("E3099", Some("schema"), None, None, None, None));
415 assert!(!f.matches_rule("E3100", Some("schema"), None, None, None, None));
416 }
417
418 #[test]
419 fn include_by_regex_matches_pattern() {
420 let f = FilterConfig {
421 include: RuleFilterConfig { id_patterns: vec!["^E3\\d{3}$".into()], ..Default::default() },
422 ..Default::default()
423 };
424 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
425 assert!(!f.matches_rule("W9037", Some("security"), None, None, None, None));
426 }
427
428 #[test]
429 fn exclude_by_resource_id_suppresses_specific_resource() {
430 let f = FilterConfig {
431 exclude: RuleFilterConfig {
432 resource_ids: vec![ResourceIdFilter { rule_id: Some("E3012".into()), resource_id: "MyBucket".into() }],
433 ..Default::default()
434 },
435 ..Default::default()
436 };
437 assert!(!f.matches_rule("E3012", Some("schema"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
438 assert!(f.matches_rule("E3012", Some("schema"), Some("OtherBucket"), Some("AWS::S3::Bucket"), None, None));
439 }
440
441 #[test]
442 fn exclude_by_resource_id_without_rule_id_suppresses_every_rule_on_resource() {
443 let f = FilterConfig {
444 exclude: RuleFilterConfig {
445 resource_ids: vec![ResourceIdFilter { rule_id: None, resource_id: "MyBucket".into() }],
446 ..Default::default()
447 },
448 ..Default::default()
449 };
450 assert!(!f.matches_rule("E3012", Some("schema"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
452 assert!(!f.matches_rule("W3697", Some("best-practice"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
453 assert!(f.matches_rule("E3012", Some("schema"), Some("OtherBucket"), Some("AWS::S3::Bucket"), None, None));
455 }
456
457 #[test]
458 fn exclude_by_logical_id_suppresses_specific_rule_on_entity() {
459 let f = FilterConfig {
460 exclude: RuleFilterConfig {
461 logical_ids: vec![LogicalIdFilter {
462 rule_id: Some("W2001".into()),
463 logical_id: "MyParam".into(),
464 entity_type: None,
465 }],
466 ..Default::default()
467 },
468 ..Default::default()
469 };
470 assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
473 assert!(f.matches_rule("F2002", Some("parameter"), None, None, Some("MyParam"), None));
474 assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
475 }
476
477 #[test]
478 fn exclude_by_logical_id_without_rule_id_suppresses_every_rule_on_entity() {
479 let f = FilterConfig {
480 exclude: RuleFilterConfig {
481 logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyParam".into(), entity_type: None }],
482 ..Default::default()
483 },
484 ..Default::default()
485 };
486 assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
487 assert!(!f.matches_rule("F2002", Some("parameter"), None, None, Some("MyParam"), None));
488 assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
489 assert!(f.matches_rule("F0001", Some("structure"), None, None, None, None));
491 }
492
493 #[test]
494 fn include_by_logical_id_accepts_matching_entity_only() {
495 let f = FilterConfig {
496 include: RuleFilterConfig {
497 logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyParam".into(), entity_type: None }],
498 ..Default::default()
499 },
500 ..Default::default()
501 };
502 assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
503 assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
504 assert!(!f.matches_rule("F0001", Some("structure"), None, None, None, None));
505 }
506
507 #[test]
508 fn logical_id_filter_with_entity_type_matches_only_that_type() {
509 let f = FilterConfig {
512 exclude: RuleFilterConfig {
513 logical_ids: vec![LogicalIdFilter {
514 rule_id: None,
515 logical_id: "MyThing".into(),
516 entity_type: Some(EntityType::Parameter),
517 }],
518 ..Default::default()
519 },
520 ..Default::default()
521 };
522 assert!(!f.matches_rule(
523 "W2001",
524 Some("best-practice"),
525 None,
526 None,
527 Some("MyThing"),
528 Some(EntityType::Parameter)
529 ));
530 assert!(f.matches_rule(
531 "E3012",
532 Some("schema"),
533 Some("MyThing"),
534 Some("AWS::S3::Bucket"),
535 Some("MyThing"),
536 Some(EntityType::Resource),
537 ));
538 }
539
540 #[test]
541 fn logical_id_filter_without_entity_type_matches_every_type() {
542 let f = FilterConfig {
543 exclude: RuleFilterConfig {
544 logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyThing".into(), entity_type: None }],
545 ..Default::default()
546 },
547 ..Default::default()
548 };
549 assert!(!f.matches_rule("W2001", None, None, None, Some("MyThing"), Some(EntityType::Parameter)));
550 assert!(!f.matches_rule("E3012", None, Some("MyThing"), None, Some("MyThing"), Some(EntityType::Resource)));
551 }
552
553 #[test]
554 fn logical_id_filter_combines_entity_type_and_rule_scope() {
555 let f = FilterConfig {
556 exclude: RuleFilterConfig {
557 logical_ids: vec![LogicalIdFilter {
558 rule_id: Some("W2001".into()),
559 logical_id: "MyParam".into(),
560 entity_type: Some(EntityType::Parameter),
561 }],
562 ..Default::default()
563 },
564 ..Default::default()
565 };
566 assert!(!f.matches_rule("W2001", None, None, None, Some("MyParam"), Some(EntityType::Parameter)));
567 assert!(f.matches_rule("F2002", None, None, None, Some("MyParam"), Some(EntityType::Parameter)));
569 }
570
571 #[test]
572 fn logical_id_filter_matches_resources_alongside_resource_id_filter() {
573 let f = FilterConfig {
576 exclude: RuleFilterConfig {
577 logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyBucket".into(), entity_type: None }],
578 ..Default::default()
579 },
580 ..Default::default()
581 };
582 assert!(!f.matches_rule(
583 "E3012",
584 Some("schema"),
585 Some("MyBucket"),
586 Some("AWS::S3::Bucket"),
587 Some("MyBucket"),
588 None
589 ));
590 }
591
592 #[test]
593 fn exclude_by_resource_type_suppresses_specific_type() {
594 let f = FilterConfig {
595 exclude: RuleFilterConfig {
596 resource_types: vec![ResourceTypeFilter {
597 rule_id: Some("E3012".into()),
598 resource_type: "AWS::S3::Bucket".into(),
599 }],
600 ..Default::default()
601 },
602 ..Default::default()
603 };
604 assert!(!f.matches_rule("E3012", Some("schema"), Some("B"), Some("AWS::S3::Bucket"), None, None));
605 assert!(f.matches_rule("E3012", Some("schema"), Some("I"), Some("AWS::EC2::Instance"), None, None));
606 }
607
608 #[test]
609 fn exclude_by_resource_type_without_rule_id_suppresses_every_rule_on_type() {
610 let f = FilterConfig {
611 exclude: RuleFilterConfig {
612 resource_types: vec![ResourceTypeFilter { rule_id: None, resource_type: "AWS::S3::Bucket".into() }],
613 ..Default::default()
614 },
615 ..Default::default()
616 };
617 assert!(!f.matches_rule("E3012", Some("schema"), Some("B"), Some("AWS::S3::Bucket"), None, None));
618 assert!(!f.matches_rule("W9037", Some("security"), Some("B"), Some("AWS::S3::Bucket"), None, None));
619 assert!(f.matches_rule("E3012", Some("schema"), Some("I"), Some("AWS::EC2::Instance"), None, None));
620 }
621
622 #[test]
623 fn exclude_by_service_suppresses_one_rule_across_the_whole_service() {
624 let f = FilterConfig {
628 exclude: RuleFilterConfig {
629 services: vec![ServiceFilter { rule_id: Some("W3697".into()), service: "AWS::AutoScaling".into() }],
630 ..Default::default()
631 },
632 ..Default::default()
633 };
634 assert!(!f.matches_rule(
635 "W3697",
636 Some("best-practice"),
637 Some("Lc"),
638 Some("AWS::AutoScaling::LaunchConfiguration"),
639 None,
640 None
641 ));
642 assert!(!f.matches_rule(
643 "W3697",
644 Some("best-practice"),
645 Some("Asg"),
646 Some("AWS::AutoScaling::AutoScalingGroup"),
647 None,
648 None
649 ));
650 assert!(f.matches_rule(
652 "E3012",
653 Some("schema"),
654 Some("Lc"),
655 Some("AWS::AutoScaling::LaunchConfiguration"),
656 None,
657 None
658 ));
659 assert!(f.matches_rule("W3697", Some("best-practice"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
661 }
662
663 #[test]
664 fn exclude_by_service_without_rule_id_suppresses_every_rule_on_service() {
665 let f = FilterConfig {
666 exclude: RuleFilterConfig {
667 services: vec![ServiceFilter { rule_id: None, service: "AWS::AutoScaling".into() }],
668 ..Default::default()
669 },
670 ..Default::default()
671 };
672 assert!(!f.matches_rule(
673 "W3697",
674 Some("best-practice"),
675 Some("Lc"),
676 Some("AWS::AutoScaling::LaunchConfiguration"),
677 None,
678 None
679 ));
680 assert!(!f.matches_rule(
681 "E3012",
682 Some("schema"),
683 Some("Lc"),
684 Some("AWS::AutoScaling::LaunchConfiguration"),
685 None,
686 None
687 ));
688 assert!(f.matches_rule("E3012", Some("schema"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
689 }
690
691 #[test]
692 fn include_by_service_accepts_matching_service_only() {
693 let f = FilterConfig {
694 include: RuleFilterConfig {
695 services: vec![ServiceFilter { rule_id: None, service: "AWS::AutoScaling".into() }],
696 ..Default::default()
697 },
698 ..Default::default()
699 };
700 assert!(f.matches_rule(
701 "W3697",
702 Some("best-practice"),
703 Some("Lc"),
704 Some("AWS::AutoScaling::LaunchConfiguration"),
705 None,
706 None
707 ));
708 assert!(!f.matches_rule("W3697", Some("best-practice"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
709 }
710
711 #[test]
712 fn service_filter_requires_the_full_provider_and_service_prefix() {
713 let f = FilterConfig {
717 exclude: RuleFilterConfig {
718 services: vec![ServiceFilter { rule_id: None, service: "AutoScaling".into() }],
719 ..Default::default()
720 },
721 ..Default::default()
722 };
723 assert!(f.matches_rule(
724 "W3697",
725 Some("best-practice"),
726 Some("Lc"),
727 Some("AWS::AutoScaling::LaunchConfiguration"),
728 None,
729 None
730 ));
731 }
732
733 #[test]
734 fn service_filter_matches_non_aws_provider_prefix() {
735 let f = FilterConfig {
737 exclude: RuleFilterConfig {
738 services: vec![ServiceFilter { rule_id: None, service: "Alexa::ASK".into() }],
739 ..Default::default()
740 },
741 ..Default::default()
742 };
743 assert!(!f.matches_rule("E3012", Some("schema"), Some("S"), Some("Alexa::ASK::Skill"), None, None));
744 assert!(f.matches_rule(
745 "E3012",
746 Some("schema"),
747 Some("Lc"),
748 Some("AWS::AutoScaling::LaunchConfiguration"),
749 None,
750 None
751 ));
752 }
753
754 #[test]
755 fn service_filter_matches_by_string_equality_on_the_prefix() {
756 let f = FilterConfig {
759 exclude: RuleFilterConfig {
760 services: vec![ServiceFilter { rule_id: None, service: "AWS::MadeUpService".into() }],
761 ..Default::default()
762 },
763 ..Default::default()
764 };
765 assert!(!f.matches_rule("E3012", Some("schema"), Some("R"), Some("AWS::MadeUpService::Widget"), None, None));
766 assert!(f.matches_rule("E3012", Some("schema"), Some("R"), Some("Custom::MadeUpService::Thing"), None, None));
767 }
768
769 #[test]
770 fn service_filter_does_not_match_when_resource_type_has_no_service_prefix() {
771 let f = FilterConfig {
772 exclude: RuleFilterConfig {
773 services: vec![ServiceFilter { rule_id: None, service: "Bucket".into() }],
774 ..Default::default()
775 },
776 ..Default::default()
777 };
778 assert!(f.matches_rule("E3012", Some("schema"), Some("R"), Some("Bucket"), None, None));
780 }
781
782 #[test]
783 fn include_and_exclude_combined_applies_both() {
784 let f = FilterConfig {
785 include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
786 exclude: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
787 ..Default::default()
788 };
789 assert!(!f.matches_rule("E3012", Some("schema"), None, None, None, None));
790 assert!(f.matches_rule("E3013", Some("schema"), None, None, None, None));
791 assert!(!f.matches_rule("W9501", Some("security"), None, None, None, None));
792 }
793
794 #[test]
795 fn excluded_categories_omits_categories_also_in_include() {
796 let f = FilterConfig {
797 exclude: RuleFilterConfig {
798 categories: vec!["best-practice".into(), "schema".into()],
799 ..Default::default()
800 },
801 include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
802 ..Default::default()
803 };
804 let cats = f.excluded_categories();
805 assert!(cats.contains("best-practice"), "expected 'best-practice' in excluded categories");
806 assert!(!cats.contains("schema"), "'schema' should not be in excluded categories");
807 }
808
809 #[test]
810 fn invalid_regex_pattern_is_skipped_and_valid_patterns_still_match() {
811 let f = FilterConfig {
812 include: RuleFilterConfig { id_patterns: vec!["[invalid".into(), "^E3\\d+$".into()], ..Default::default() },
813 ..Default::default()
814 };
815 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
816 }
817
818 #[test]
819 fn invalid_patterns_are_reported_for_include_and_exclude() {
820 let f = FilterConfig {
821 include: RuleFilterConfig { id_patterns: vec!["[bad-include".into(), "^E3".into()], ..Default::default() },
822 exclude: RuleFilterConfig { id_patterns: vec!["(bad-exclude".into()], ..Default::default() },
823 ..Default::default()
824 };
825 let invalid = f.invalid_patterns();
826 assert!(invalid.contains(&"[bad-include".to_string()));
827 assert!(invalid.contains(&"(bad-exclude".to_string()));
828 assert!(!invalid.contains(&"^E3".to_string()), "valid patterns must not be reported");
829 }
830
831 #[test]
832 fn valid_patterns_report_no_invalid() {
833 let f = FilterConfig {
834 include: RuleFilterConfig { id_patterns: vec!["^E3\\d+$".into()], ..Default::default() },
835 ..Default::default()
836 };
837 assert!(f.invalid_patterns().is_empty());
838 }
839
840 #[test]
841 fn service_prefix_extracts_provider_and_service() {
842 assert_eq!(service_prefix("AWS::AutoScaling::LaunchConfiguration"), Some("AWS::AutoScaling"));
843 assert_eq!(service_prefix("AWS::S3::Bucket"), Some("AWS::S3"));
844 assert_eq!(service_prefix("Alexa::ASK::Skill"), Some("Alexa::ASK"));
845 assert_eq!(service_prefix("AWS::S3::Bucket::Tag"), Some("AWS::S3"));
847 }
848
849 #[test]
850 fn service_prefix_none_without_a_second_segment_or_empty_service_name() {
851 assert_eq!(service_prefix("Bucket"), None, "no `::` at all");
852 assert_eq!(service_prefix("Custom::MyResource"), None, "only a provider segment, no data-type segment");
853 assert_eq!(service_prefix("AWS::"), None, "provider then empty, no second `::`");
854 assert_eq!(service_prefix("AWS::::Widget"), None, "empty service-name segment");
855 assert_eq!(service_prefix(""), None);
856 }
857
858 #[test]
859 fn cloned_filter_recompiles_patterns_and_matches() {
860 let f = FilterConfig {
861 include: RuleFilterConfig { id_patterns: vec!["^E3\\d+$".into()], ..Default::default() },
862 ..Default::default()
863 };
864 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
865 let f2 = f.clone();
866 assert!(f2.matches_rule("E3012", Some("schema"), None, None, None, None));
867 }
868}