Skip to main content

rules/
filter.rs

1use log::warn;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::sync::OnceLock;
6use template_model::EntityType;
7
8/// Numeric range filter for rule IDs sharing a common letter prefix, matching an
9/// inclusive span of the trailing numbers.
10#[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/// Suppress a rule for a specific logical resource ID. An absent `rule_id`
22/// scopes the filter to every rule on that resource.
23#[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/// Suppress a rule for a specific named template entity - a resource,
37/// parameter, output, mapping, condition, or template rule - identified by its
38/// logical ID. An absent `rule_id` scopes the filter to every rule on that
39/// entity; an absent `entity_type` scopes it to entities of every type with
40/// that logical ID.
41#[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/// Suppress a rule for a specific resource type. An absent `rule_id` scopes the
59/// filter to every rule on that type.
60#[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/// Suppress a rule for every resource belonging to a service - the
74/// `service-provider::service-name` prefix of the resource type (its first two
75/// `::`-delimited segments, for example `AWS::AutoScaling` in
76/// `AWS::AutoScaling::LaunchConfiguration`, or `Alexa::ASK` in
77/// `Alexa::ASK::Skill`). An absent `rule_id` scopes the filter to every rule on
78/// that service.
79///
80/// The service string is compared verbatim against that prefix.
81#[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/// A set of rule-matching criteria; a rule matches when it satisfies any one of them.
95#[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    /// Regular expressions matched against the rule ID.
111    #[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
192/// A resource-scoped filter's optional `rule_id` matches a diagnostic when it is
193/// absent (scoped to every rule) or equals the diagnostic's rule ID.
194fn rule_scope_matches(scope: &Option<String>, rule_id: &str) -> bool {
195    scope.as_deref().is_none_or(|id| id == rule_id)
196}
197
198/// The `service-provider::service-name` prefix of a resource type - its first two
199/// `::`-delimited segments (for example `AWS::AutoScaling` in
200/// `AWS::AutoScaling::LaunchConfiguration`), or `None` when the type has no second
201/// segment or an empty service-name segment.
202fn service_prefix(resource_type: &str) -> Option<&str> {
203    // Resource types follow `service-provider::service-name::data-type-name`; the
204    // service is everything up to (but excluding) the second `::`.
205    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; // empty service-name segment (e.g. `AWS::::Widget`)
210    }
211    Some(&resource_type[..first + 2 + second_relative])
212}
213
214/// Include/exclude filter configuration for diagnostics.
215///
216/// If include filters are non-empty, a diagnostic must match at least one.
217/// Any diagnostic matching an exclude filter is removed regardless.
218#[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    /// Every `id_patterns` entry (from either the include or exclude filter) that is not a valid
298    /// regular expression. Callers should surface these rather than let them be silently discarded:
299    /// a dropped include pattern in particular would otherwise filter out every diagnostic, because
300    /// the include set is treated as non-empty yet matches nothing.
301    #[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        // Every rule on MyBucket is suppressed, regardless of rule id.
451        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        // A different resource is untouched.
454        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        // The scoped rule on the parameter is suppressed; other rules on the
471        // same entity and the same rule on other entities still fire.
472        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        // A diagnostic with no logical id is never matched by a logical-id filter.
490        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        // Same logical ID could name both a parameter and (in another template)
510        // a resource; the type scope keeps the filter from spilling over.
511        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        // Different rule on the same entity still fires.
568        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        // A resource finding carries the same value in both slots, so either
574        // filter dimension suppresses it - the resource-id filter is unchanged.
575        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        // Issue #37: silence W3697 for every AutoScaling resource without touching
625        // the same rule on other services. The service is the fully qualified
626        // `service-provider::service-name` prefix, e.g. `AWS::AutoScaling`.
627        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        // A different rule on the same service still fires.
651        assert!(f.matches_rule(
652            "E3012",
653            Some("schema"),
654            Some("Lc"),
655            Some("AWS::AutoScaling::LaunchConfiguration"),
656            None,
657            None
658        ));
659        // The same rule on a different service still fires.
660        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        // The bare service segment does not match - the filter is the fully
714        // qualified `service-provider::service-name` prefix, so `AutoScaling`
715        // alone must not silence `AWS::AutoScaling::LaunchConfiguration`.
716        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        // The prefix includes the provider, so a non-AWS provider is matched too.
736        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        // Matching is pure string equality on the service prefix, so any prefix -
757        // including a custom namespace - is honored as written.
758        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        // A single-segment type has no service prefix, so the service filter never matches it.
779        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        // A subproperty type (four segments) still resolves to the same service prefix.
846        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}