Skip to main content

rules/
filter.rs

1use crate::Severity;
2use log::warn;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use std::sync::OnceLock;
7use template_model::EntityType;
8
9/// Numeric range filter for rule IDs sharing a common letter prefix, matching an
10/// inclusive span of the trailing numbers.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
13#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
14#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
15#[serde(rename_all = "camelCase")]
16pub struct IdRange {
17    pub prefix: String,
18    pub start: u32,
19    pub end: u32,
20}
21
22/// Suppress a rule for a specific logical resource ID. An absent `rule_id`
23/// scopes the filter to every rule on that resource.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
26#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
27#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
28#[serde(rename_all = "camelCase")]
29pub struct ResourceIdFilter {
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
32    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
33    pub rule_id: Option<String>,
34    pub resource_id: String,
35}
36
37/// Suppress a rule for a specific named template entity - a resource,
38/// parameter, output, mapping, condition, or template rule - identified by its
39/// logical ID. An absent `rule_id` scopes the filter to every rule on that
40/// entity; an absent `entity_type` scopes it to entities of every type with
41/// that logical ID.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
44#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
45#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
46#[serde(rename_all = "camelCase")]
47pub struct LogicalIdFilter {
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
50    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
51    pub rule_id: Option<String>,
52    pub logical_id: String,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
55    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
56    pub entity_type: Option<EntityType>,
57}
58
59/// Suppress a rule for a specific resource type. An absent `rule_id` scopes the
60/// filter to every rule on that type.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
63#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
64#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
65#[serde(rename_all = "camelCase")]
66pub struct ResourceTypeFilter {
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
69    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
70    pub rule_id: Option<String>,
71    pub resource_type: String,
72}
73
74/// Suppress a rule for every resource belonging to a service - the
75/// `service-provider::service-name` prefix of the resource type (its first two
76/// `::`-delimited segments, for example `AWS::AutoScaling` in
77/// `AWS::AutoScaling::LaunchConfiguration`, or `Alexa::ASK` in
78/// `Alexa::ASK::Skill`). An absent `rule_id` scopes the filter to every rule on
79/// that service.
80///
81/// The service string is compared verbatim against that prefix.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
84#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
85#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
86#[serde(rename_all = "camelCase")]
87pub struct ServiceFilter {
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
90    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
91    pub rule_id: Option<String>,
92    pub service: String,
93}
94
95/// A set of rule-matching criteria; a rule matches when it satisfies any one of them.
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
98#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
99#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
100#[serde(rename_all = "camelCase")]
101pub struct RuleFilterConfig {
102    #[serde(default)]
103    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
104    pub ids: Vec<String>,
105    #[serde(default)]
106    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
107    pub categories: Vec<String>,
108    #[serde(default)]
109    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
110    pub id_ranges: Vec<IdRange>,
111    /// Regular expressions matched against the rule ID.
112    #[serde(default)]
113    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
114    pub id_patterns: Vec<String>,
115    #[serde(default)]
116    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
117    pub resource_ids: Vec<ResourceIdFilter>,
118    #[serde(default)]
119    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
120    pub logical_ids: Vec<LogicalIdFilter>,
121    #[serde(default)]
122    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
123    pub resource_types: Vec<ResourceTypeFilter>,
124    #[serde(default)]
125    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
126    pub services: Vec<ServiceFilter>,
127}
128
129impl RuleFilterConfig {
130    pub fn is_empty(&self) -> bool {
131        self.ids.is_empty()
132            && self.categories.is_empty()
133            && self.id_ranges.is_empty()
134            && self.id_patterns.is_empty()
135            && self.resource_ids.is_empty()
136            && self.logical_ids.is_empty()
137            && self.resource_types.is_empty()
138            && self.services.is_empty()
139    }
140
141    /// Whether the rule matches on a dimension that applies uniformly to every
142    /// diagnostic it can emit - an exact ID, a category, an ID range, or an ID
143    /// regex - independent of the resource or entity a given diagnostic fires on.
144    fn matches_globally(&self, rule_id: &str, category: Option<&str>, compiled: &CompiledPatterns) -> bool {
145        self.ids.iter().any(|id| id == rule_id)
146            || category.is_some_and(|cat| self.categories.iter().any(|c| c == cat))
147            || self.id_ranges.iter().any(|range| range_matches(rule_id, range))
148            || compiled.matches(rule_id)
149    }
150
151    /// Whether this filter could match at least one diagnostic the rule can emit,
152    /// for some resource, entity, or type: either a global dimension matches, or a
153    /// scoped dimension carries a rule scope that covers this rule. Used for global
154    /// include gating - a rule that a non-empty include filter can never admit
155    /// produces nothing and can be pruned before evaluation.
156    fn could_match_rule(&self, rule_id: &str, category: Option<&str>, compiled: &CompiledPatterns) -> bool {
157        self.matches_globally(rule_id, category, compiled)
158            || self.resource_ids.iter().any(|f| rule_scope_matches(&f.rule_id, rule_id))
159            || self.logical_ids.iter().any(|f| rule_scope_matches(&f.rule_id, rule_id))
160            || self.resource_types.iter().any(|f| rule_scope_matches(&f.rule_id, rule_id))
161            || self.services.iter().any(|f| rule_scope_matches(&f.rule_id, rule_id))
162    }
163
164    fn matches(
165        &self,
166        rule_id: &str,
167        category: Option<&str>,
168        resource_id: Option<&str>,
169        resource_type: Option<&str>,
170        logical_id: Option<&str>,
171        entity_type: Option<EntityType>,
172        compiled: &CompiledPatterns,
173    ) -> bool {
174        if self.matches_globally(rule_id, category, compiled) {
175            return true;
176        }
177        if let Some(rid) = resource_id
178            && self.resource_ids.iter().any(|f| f.resource_id == rid && rule_scope_matches(&f.rule_id, rule_id))
179        {
180            return true;
181        }
182        if let Some(lid) = logical_id
183            && self.logical_ids.iter().any(|f| {
184                f.logical_id == lid
185                    && rule_scope_matches(&f.rule_id, rule_id)
186                    && f.entity_type.is_none_or(|scoped| entity_type == Some(scoped))
187            })
188        {
189            return true;
190        }
191        if let Some(rtype) = resource_type {
192            if self.resource_types.iter().any(|f| f.resource_type == rtype && rule_scope_matches(&f.rule_id, rule_id)) {
193                return true;
194            }
195            if let Some(service) = service_prefix(rtype)
196                && self.services.iter().any(|f| f.service == service && rule_scope_matches(&f.rule_id, rule_id))
197            {
198                return true;
199            }
200        }
201        false
202    }
203}
204
205/// A resource-scoped filter's optional `rule_id` matches a diagnostic when it is
206/// absent (scoped to every rule) or equals the diagnostic's rule ID.
207fn rule_scope_matches(scope: &Option<String>, rule_id: &str) -> bool {
208    scope.as_deref().is_none_or(|id| id == rule_id)
209}
210
211/// The `service-provider::service-name` prefix of a resource type - its first two
212/// `::`-delimited segments (for example `AWS::AutoScaling` in
213/// `AWS::AutoScaling::LaunchConfiguration`), or `None` when the type has no second
214/// segment or an empty service-name segment.
215fn service_prefix(resource_type: &str) -> Option<&str> {
216    // Resource types follow `service-provider::service-name::data-type-name`; the
217    // service is everything up to (but excluding) the second `::`.
218    let first = resource_type.find("::")?;
219    let service_name = &resource_type[first + 2..];
220    let second_relative = service_name.find("::")?;
221    if second_relative == 0 {
222        return None; // empty service-name segment (e.g. `AWS::::Widget`)
223    }
224    Some(&resource_type[..first + 2 + second_relative])
225}
226
227/// Include/exclude filter configuration for diagnostics.
228///
229/// If include filters are non-empty, a diagnostic must match at least one.
230/// Any diagnostic matching an exclude filter is removed regardless.
231#[derive(Debug, Default, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct FilterConfig {
234    #[serde(default)]
235    pub include: RuleFilterConfig,
236    #[serde(default)]
237    pub exclude: RuleFilterConfig,
238    #[serde(skip)]
239    compiled: OnceLock<CompiledFilters>,
240}
241
242impl Clone for FilterConfig {
243    fn clone(&self) -> Self {
244        FilterConfig { include: self.include.clone(), exclude: self.exclude.clone(), compiled: OnceLock::new() }
245    }
246}
247
248impl FilterConfig {
249    pub fn new(include: RuleFilterConfig, exclude: RuleFilterConfig) -> Self {
250        FilterConfig { include, exclude, compiled: OnceLock::new() }
251    }
252
253    pub fn is_empty(&self) -> bool {
254        self.include.is_empty() && self.exclude.is_empty()
255    }
256
257    fn compiled(&self) -> &CompiledFilters {
258        self.compiled.get_or_init(|| CompiledFilters {
259            include: CompiledPatterns::new(&self.include.id_patterns),
260            exclude: CompiledPatterns::new(&self.exclude.id_patterns),
261        })
262    }
263
264    #[must_use]
265    pub fn matches_rule(
266        &self,
267        rule_id: &str,
268        category: Option<&str>,
269        resource_id: Option<&str>,
270        resource_type: Option<&str>,
271        logical_id: Option<&str>,
272        entity_type: Option<EntityType>,
273    ) -> bool {
274        let compiled = self.compiled();
275        if !self.include.is_empty()
276            && !self.include.matches(
277                rule_id,
278                category,
279                resource_id,
280                resource_type,
281                logical_id,
282                entity_type,
283                &compiled.include,
284            )
285        {
286            return false;
287        }
288        if self.exclude.matches(
289            rule_id,
290            category,
291            resource_id,
292            resource_type,
293            logical_id,
294            entity_type,
295            &compiled.exclude,
296        ) {
297            return false;
298        }
299        true
300    }
301
302    pub fn excluded_categories(&self) -> HashSet<&str> {
303        let mut cats: HashSet<&str> = self.exclude.categories.iter().map(|s| s.as_str()).collect();
304        for c in &self.include.categories {
305            cats.remove(c.as_str());
306        }
307        cats
308    }
309
310    /// Every `id_patterns` entry (from either the include or exclude filter) that is not a valid
311    /// regular expression. Callers should surface these rather than let them be silently discarded:
312    /// a dropped include pattern in particular would otherwise filter out every diagnostic, because
313    /// the include set is treated as non-empty yet matches nothing.
314    #[must_use]
315    pub fn invalid_patterns(&self) -> Vec<String> {
316        let compiled = self.compiled();
317        let mut invalid = compiled.include.invalid.clone();
318        invalid.extend(compiled.exclude.invalid.iter().cloned());
319        invalid
320    }
321
322    /// Whether global filtering can prove, before any rule evaluation, that no
323    /// diagnostic this built-in rule could emit will survive finalization -
324    /// regardless of the resource, path, or entity it fires on. A rule proven
325    /// globally unable to survive can skip its work entirely without changing the
326    /// reported diagnostics, because finalization would have removed every one of
327    /// its findings anyway.
328    ///
329    /// Only dimensions that apply uniformly to every diagnostic of the rule are
330    /// consulted: the minimum-severity gate (accounting for the strict promotion
331    /// of Warn to Error) and the global include/exclude dimensions - exact ID,
332    /// category, ID range, and ID regex. The scoped resource-id, logical-id,
333    /// resource-type, and service dimensions can still admit the rule for some
334    /// resource, so they never justify global suppression.
335    #[must_use]
336    pub fn globally_suppresses_builtin_rule(
337        &self,
338        rule_id: &str,
339        category: Option<&str>,
340        severity: Severity,
341        strict: bool,
342        severity_level: Severity,
343    ) -> bool {
344        // Strict mode promotes a Warn to an Error before the severity gate runs,
345        // so gate on the promoted severity to stay consistent with finalization.
346        let effective_severity = if strict && severity == Severity::Warn { Severity::Error } else { severity };
347        if effective_severity < severity_level {
348            return true;
349        }
350        let compiled = self.compiled();
351        if self.exclude.matches_globally(rule_id, category, &compiled.exclude) {
352            return true;
353        }
354        !self.include.is_empty() && !self.include.could_match_rule(rule_id, category, &compiled.include)
355    }
356}
357
358#[derive(Debug)]
359struct CompiledPatterns {
360    regexes: Vec<Regex>,
361    invalid: Vec<String>,
362}
363
364impl CompiledPatterns {
365    fn new(patterns: &[String]) -> Self {
366        let mut regexes = Vec::new();
367        let mut invalid = Vec::new();
368        for pattern in patterns {
369            match Regex::new(pattern) {
370                Ok(regex) => regexes.push(regex),
371                Err(error) => {
372                    warn!("Invalid rule-id filter pattern '{}': {}", pattern, error);
373                    invalid.push(pattern.clone());
374                }
375            }
376        }
377        CompiledPatterns { regexes, invalid }
378    }
379
380    fn matches(&self, rule_id: &str) -> bool {
381        self.regexes.iter().any(|r| r.is_match(rule_id))
382    }
383}
384
385#[derive(Debug)]
386struct CompiledFilters {
387    include: CompiledPatterns,
388    exclude: CompiledPatterns,
389}
390
391fn range_matches(rule_id: &str, range: &IdRange) -> bool {
392    let Some(suffix) = rule_id.strip_prefix(&range.prefix) else {
393        return false;
394    };
395    let Ok(num) = suffix.parse::<u32>() else {
396        return false;
397    };
398    num >= range.start && num <= range.end
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn empty_filter_matches_all_rules() {
407        let f = FilterConfig::default();
408        assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
409        assert!(f.matches_rule("W9037", Some("security"), Some("Bucket"), Some("AWS::S3::Bucket"), None, None));
410    }
411
412    #[test]
413    fn include_by_id_accepts_matching_and_rejects_others() {
414        let f = FilterConfig {
415            include: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
416            ..Default::default()
417        };
418        assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
419        assert!(!f.matches_rule("E3013", Some("schema"), None, None, None, None));
420    }
421
422    #[test]
423    fn exclude_by_id_rejects_matching_and_accepts_others() {
424        let f = FilterConfig {
425            exclude: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
426            ..Default::default()
427        };
428        assert!(!f.matches_rule("E3012", Some("schema"), None, None, None, None));
429        assert!(f.matches_rule("E3013", Some("schema"), None, None, None, None));
430    }
431
432    #[test]
433    fn include_by_category_accepts_matching_category_only() {
434        let f = FilterConfig {
435            include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
436            ..Default::default()
437        };
438        assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
439        assert!(!f.matches_rule("W9501", Some("security"), None, None, None, None));
440    }
441
442    #[test]
443    fn exclude_by_category_rejects_matching_category() {
444        let f = FilterConfig {
445            exclude: RuleFilterConfig { categories: vec!["best-practice".into()], ..Default::default() },
446            ..Default::default()
447        };
448        assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
449        assert!(!f.matches_rule("I9040", Some("best-practice"), None, None, None, None));
450    }
451
452    #[test]
453    fn include_by_id_range_accepts_within_bounds_only() {
454        let f = FilterConfig {
455            include: RuleFilterConfig {
456                id_ranges: vec![IdRange { prefix: "E".into(), start: 3000, end: 3099 }],
457                ..Default::default()
458            },
459            ..Default::default()
460        };
461        assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
462        assert!(f.matches_rule("E3099", Some("schema"), None, None, None, None));
463        assert!(!f.matches_rule("E3100", Some("schema"), None, None, None, None));
464    }
465
466    #[test]
467    fn include_by_regex_matches_pattern() {
468        let f = FilterConfig {
469            include: RuleFilterConfig { id_patterns: vec!["^E3\\d{3}$".into()], ..Default::default() },
470            ..Default::default()
471        };
472        assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
473        assert!(!f.matches_rule("W9037", Some("security"), None, None, None, None));
474    }
475
476    #[test]
477    fn exclude_by_resource_id_suppresses_specific_resource() {
478        let f = FilterConfig {
479            exclude: RuleFilterConfig {
480                resource_ids: vec![ResourceIdFilter { rule_id: Some("E3012".into()), resource_id: "MyBucket".into() }],
481                ..Default::default()
482            },
483            ..Default::default()
484        };
485        assert!(!f.matches_rule("E3012", Some("schema"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
486        assert!(f.matches_rule("E3012", Some("schema"), Some("OtherBucket"), Some("AWS::S3::Bucket"), None, None));
487    }
488
489    #[test]
490    fn exclude_by_resource_id_without_rule_id_suppresses_every_rule_on_resource() {
491        let f = FilterConfig {
492            exclude: RuleFilterConfig {
493                resource_ids: vec![ResourceIdFilter { rule_id: None, resource_id: "MyBucket".into() }],
494                ..Default::default()
495            },
496            ..Default::default()
497        };
498        // Every rule on MyBucket is suppressed, regardless of rule id.
499        assert!(!f.matches_rule("E3012", Some("schema"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
500        assert!(!f.matches_rule("W3697", Some("best-practice"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
501        // A different resource is untouched.
502        assert!(f.matches_rule("E3012", Some("schema"), Some("OtherBucket"), Some("AWS::S3::Bucket"), None, None));
503    }
504
505    #[test]
506    fn exclude_by_logical_id_suppresses_specific_rule_on_entity() {
507        let f = FilterConfig {
508            exclude: RuleFilterConfig {
509                logical_ids: vec![LogicalIdFilter {
510                    rule_id: Some("W2001".into()),
511                    logical_id: "MyParam".into(),
512                    entity_type: None,
513                }],
514                ..Default::default()
515            },
516            ..Default::default()
517        };
518        // The scoped rule on the parameter is suppressed; other rules on the
519        // same entity and the same rule on other entities still fire.
520        assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
521        assert!(f.matches_rule("F2002", Some("parameter"), None, None, Some("MyParam"), None));
522        assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
523    }
524
525    #[test]
526    fn exclude_by_logical_id_without_rule_id_suppresses_every_rule_on_entity() {
527        let f = FilterConfig {
528            exclude: RuleFilterConfig {
529                logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyParam".into(), entity_type: None }],
530                ..Default::default()
531            },
532            ..Default::default()
533        };
534        assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
535        assert!(!f.matches_rule("F2002", Some("parameter"), None, None, Some("MyParam"), None));
536        assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
537        // A diagnostic with no logical id is never matched by a logical-id filter.
538        assert!(f.matches_rule("F0001", Some("structure"), None, None, None, None));
539    }
540
541    #[test]
542    fn include_by_logical_id_accepts_matching_entity_only() {
543        let f = FilterConfig {
544            include: RuleFilterConfig {
545                logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyParam".into(), entity_type: None }],
546                ..Default::default()
547            },
548            ..Default::default()
549        };
550        assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
551        assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
552        assert!(!f.matches_rule("F0001", Some("structure"), None, None, None, None));
553    }
554
555    #[test]
556    fn logical_id_filter_with_entity_type_matches_only_that_type() {
557        // Same logical ID could name both a parameter and (in another template)
558        // a resource; the type scope keeps the filter from spilling over.
559        let f = FilterConfig {
560            exclude: RuleFilterConfig {
561                logical_ids: vec![LogicalIdFilter {
562                    rule_id: None,
563                    logical_id: "MyThing".into(),
564                    entity_type: Some(EntityType::Parameter),
565                }],
566                ..Default::default()
567            },
568            ..Default::default()
569        };
570        assert!(!f.matches_rule(
571            "W2001",
572            Some("best-practice"),
573            None,
574            None,
575            Some("MyThing"),
576            Some(EntityType::Parameter)
577        ));
578        assert!(f.matches_rule(
579            "E3012",
580            Some("schema"),
581            Some("MyThing"),
582            Some("AWS::S3::Bucket"),
583            Some("MyThing"),
584            Some(EntityType::Resource),
585        ));
586    }
587
588    #[test]
589    fn logical_id_filter_without_entity_type_matches_every_type() {
590        let f = FilterConfig {
591            exclude: RuleFilterConfig {
592                logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyThing".into(), entity_type: None }],
593                ..Default::default()
594            },
595            ..Default::default()
596        };
597        assert!(!f.matches_rule("W2001", None, None, None, Some("MyThing"), Some(EntityType::Parameter)));
598        assert!(!f.matches_rule("E3012", None, Some("MyThing"), None, Some("MyThing"), Some(EntityType::Resource)));
599    }
600
601    #[test]
602    fn logical_id_filter_combines_entity_type_and_rule_scope() {
603        let f = FilterConfig {
604            exclude: RuleFilterConfig {
605                logical_ids: vec![LogicalIdFilter {
606                    rule_id: Some("W2001".into()),
607                    logical_id: "MyParam".into(),
608                    entity_type: Some(EntityType::Parameter),
609                }],
610                ..Default::default()
611            },
612            ..Default::default()
613        };
614        assert!(!f.matches_rule("W2001", None, None, None, Some("MyParam"), Some(EntityType::Parameter)));
615        // Different rule on the same entity still fires.
616        assert!(f.matches_rule("F2002", None, None, None, Some("MyParam"), Some(EntityType::Parameter)));
617    }
618
619    #[test]
620    fn logical_id_filter_matches_resources_alongside_resource_id_filter() {
621        // A resource finding carries the same value in both slots, so either
622        // filter dimension suppresses it - the resource-id filter is unchanged.
623        let f = FilterConfig {
624            exclude: RuleFilterConfig {
625                logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyBucket".into(), entity_type: None }],
626                ..Default::default()
627            },
628            ..Default::default()
629        };
630        assert!(!f.matches_rule(
631            "E3012",
632            Some("schema"),
633            Some("MyBucket"),
634            Some("AWS::S3::Bucket"),
635            Some("MyBucket"),
636            None
637        ));
638    }
639
640    #[test]
641    fn exclude_by_resource_type_suppresses_specific_type() {
642        let f = FilterConfig {
643            exclude: RuleFilterConfig {
644                resource_types: vec![ResourceTypeFilter {
645                    rule_id: Some("E3012".into()),
646                    resource_type: "AWS::S3::Bucket".into(),
647                }],
648                ..Default::default()
649            },
650            ..Default::default()
651        };
652        assert!(!f.matches_rule("E3012", Some("schema"), Some("B"), Some("AWS::S3::Bucket"), None, None));
653        assert!(f.matches_rule("E3012", Some("schema"), Some("I"), Some("AWS::EC2::Instance"), None, None));
654    }
655
656    #[test]
657    fn exclude_by_resource_type_without_rule_id_suppresses_every_rule_on_type() {
658        let f = FilterConfig {
659            exclude: RuleFilterConfig {
660                resource_types: vec![ResourceTypeFilter { rule_id: None, resource_type: "AWS::S3::Bucket".into() }],
661                ..Default::default()
662            },
663            ..Default::default()
664        };
665        assert!(!f.matches_rule("E3012", Some("schema"), Some("B"), Some("AWS::S3::Bucket"), None, None));
666        assert!(!f.matches_rule("W9037", Some("security"), Some("B"), Some("AWS::S3::Bucket"), None, None));
667        assert!(f.matches_rule("E3012", Some("schema"), Some("I"), Some("AWS::EC2::Instance"), None, None));
668    }
669
670    #[test]
671    fn exclude_by_service_suppresses_one_rule_across_the_whole_service() {
672        // Issue #37: silence W3697 for every AutoScaling resource without touching
673        // the same rule on other services. The service is the fully qualified
674        // `service-provider::service-name` prefix, e.g. `AWS::AutoScaling`.
675        let f = FilterConfig {
676            exclude: RuleFilterConfig {
677                services: vec![ServiceFilter { rule_id: Some("W3697".into()), service: "AWS::AutoScaling".into() }],
678                ..Default::default()
679            },
680            ..Default::default()
681        };
682        assert!(!f.matches_rule(
683            "W3697",
684            Some("best-practice"),
685            Some("Lc"),
686            Some("AWS::AutoScaling::LaunchConfiguration"),
687            None,
688            None
689        ));
690        assert!(!f.matches_rule(
691            "W3697",
692            Some("best-practice"),
693            Some("Asg"),
694            Some("AWS::AutoScaling::AutoScalingGroup"),
695            None,
696            None
697        ));
698        // A different rule on the same service still fires.
699        assert!(f.matches_rule(
700            "E3012",
701            Some("schema"),
702            Some("Lc"),
703            Some("AWS::AutoScaling::LaunchConfiguration"),
704            None,
705            None
706        ));
707        // The same rule on a different service still fires.
708        assert!(f.matches_rule("W3697", Some("best-practice"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
709    }
710
711    #[test]
712    fn exclude_by_service_without_rule_id_suppresses_every_rule_on_service() {
713        let f = FilterConfig {
714            exclude: RuleFilterConfig {
715                services: vec![ServiceFilter { rule_id: None, service: "AWS::AutoScaling".into() }],
716                ..Default::default()
717            },
718            ..Default::default()
719        };
720        assert!(!f.matches_rule(
721            "W3697",
722            Some("best-practice"),
723            Some("Lc"),
724            Some("AWS::AutoScaling::LaunchConfiguration"),
725            None,
726            None
727        ));
728        assert!(!f.matches_rule(
729            "E3012",
730            Some("schema"),
731            Some("Lc"),
732            Some("AWS::AutoScaling::LaunchConfiguration"),
733            None,
734            None
735        ));
736        assert!(f.matches_rule("E3012", Some("schema"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
737    }
738
739    #[test]
740    fn include_by_service_accepts_matching_service_only() {
741        let f = FilterConfig {
742            include: RuleFilterConfig {
743                services: vec![ServiceFilter { rule_id: None, service: "AWS::AutoScaling".into() }],
744                ..Default::default()
745            },
746            ..Default::default()
747        };
748        assert!(f.matches_rule(
749            "W3697",
750            Some("best-practice"),
751            Some("Lc"),
752            Some("AWS::AutoScaling::LaunchConfiguration"),
753            None,
754            None
755        ));
756        assert!(!f.matches_rule("W3697", Some("best-practice"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
757    }
758
759    #[test]
760    fn service_filter_requires_the_full_provider_and_service_prefix() {
761        // The bare service segment does not match - the filter is the fully
762        // qualified `service-provider::service-name` prefix, so `AutoScaling`
763        // alone must not silence `AWS::AutoScaling::LaunchConfiguration`.
764        let f = FilterConfig {
765            exclude: RuleFilterConfig {
766                services: vec![ServiceFilter { rule_id: None, service: "AutoScaling".into() }],
767                ..Default::default()
768            },
769            ..Default::default()
770        };
771        assert!(f.matches_rule(
772            "W3697",
773            Some("best-practice"),
774            Some("Lc"),
775            Some("AWS::AutoScaling::LaunchConfiguration"),
776            None,
777            None
778        ));
779    }
780
781    #[test]
782    fn service_filter_matches_non_aws_provider_prefix() {
783        // The prefix includes the provider, so a non-AWS provider is matched too.
784        let f = FilterConfig {
785            exclude: RuleFilterConfig {
786                services: vec![ServiceFilter { rule_id: None, service: "Alexa::ASK".into() }],
787                ..Default::default()
788            },
789            ..Default::default()
790        };
791        assert!(!f.matches_rule("E3012", Some("schema"), Some("S"), Some("Alexa::ASK::Skill"), None, None));
792        assert!(f.matches_rule(
793            "E3012",
794            Some("schema"),
795            Some("Lc"),
796            Some("AWS::AutoScaling::LaunchConfiguration"),
797            None,
798            None
799        ));
800    }
801
802    #[test]
803    fn service_filter_matches_by_string_equality_on_the_prefix() {
804        // Matching is pure string equality on the service prefix, so any prefix -
805        // including a custom namespace - is honored as written.
806        let f = FilterConfig {
807            exclude: RuleFilterConfig {
808                services: vec![ServiceFilter { rule_id: None, service: "AWS::MadeUpService".into() }],
809                ..Default::default()
810            },
811            ..Default::default()
812        };
813        assert!(!f.matches_rule("E3012", Some("schema"), Some("R"), Some("AWS::MadeUpService::Widget"), None, None));
814        assert!(f.matches_rule("E3012", Some("schema"), Some("R"), Some("Custom::MadeUpService::Thing"), None, None));
815    }
816
817    #[test]
818    fn service_filter_does_not_match_when_resource_type_has_no_service_prefix() {
819        let f = FilterConfig {
820            exclude: RuleFilterConfig {
821                services: vec![ServiceFilter { rule_id: None, service: "Bucket".into() }],
822                ..Default::default()
823            },
824            ..Default::default()
825        };
826        // A single-segment type has no service prefix, so the service filter never matches it.
827        assert!(f.matches_rule("E3012", Some("schema"), Some("R"), Some("Bucket"), None, None));
828    }
829
830    #[test]
831    fn include_and_exclude_combined_applies_both() {
832        let f = FilterConfig {
833            include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
834            exclude: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
835            ..Default::default()
836        };
837        assert!(!f.matches_rule("E3012", Some("schema"), None, None, None, None));
838        assert!(f.matches_rule("E3013", Some("schema"), None, None, None, None));
839        assert!(!f.matches_rule("W9501", Some("security"), None, None, None, None));
840    }
841
842    #[test]
843    fn excluded_categories_omits_categories_also_in_include() {
844        let f = FilterConfig {
845            exclude: RuleFilterConfig {
846                categories: vec!["best-practice".into(), "schema".into()],
847                ..Default::default()
848            },
849            include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
850            ..Default::default()
851        };
852        let cats = f.excluded_categories();
853        assert!(cats.contains("best-practice"), "expected 'best-practice' in excluded categories");
854        assert!(!cats.contains("schema"), "'schema' should not be in excluded categories");
855    }
856
857    #[test]
858    fn invalid_regex_pattern_is_skipped_and_valid_patterns_still_match() {
859        let f = FilterConfig {
860            include: RuleFilterConfig { id_patterns: vec!["[invalid".into(), "^E3\\d+$".into()], ..Default::default() },
861            ..Default::default()
862        };
863        assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
864    }
865
866    #[test]
867    fn invalid_patterns_are_reported_for_include_and_exclude() {
868        let f = FilterConfig {
869            include: RuleFilterConfig { id_patterns: vec!["[bad-include".into(), "^E3".into()], ..Default::default() },
870            exclude: RuleFilterConfig { id_patterns: vec!["(bad-exclude".into()], ..Default::default() },
871            ..Default::default()
872        };
873        let invalid = f.invalid_patterns();
874        assert!(invalid.contains(&"[bad-include".to_string()));
875        assert!(invalid.contains(&"(bad-exclude".to_string()));
876        assert!(!invalid.contains(&"^E3".to_string()), "valid patterns must not be reported");
877    }
878
879    #[test]
880    fn valid_patterns_report_no_invalid() {
881        let f = FilterConfig {
882            include: RuleFilterConfig { id_patterns: vec!["^E3\\d+$".into()], ..Default::default() },
883            ..Default::default()
884        };
885        assert!(f.invalid_patterns().is_empty());
886    }
887
888    #[test]
889    fn service_prefix_extracts_provider_and_service() {
890        assert_eq!(service_prefix("AWS::AutoScaling::LaunchConfiguration"), Some("AWS::AutoScaling"));
891        assert_eq!(service_prefix("AWS::S3::Bucket"), Some("AWS::S3"));
892        assert_eq!(service_prefix("Alexa::ASK::Skill"), Some("Alexa::ASK"));
893        // A subproperty type (four segments) still resolves to the same service prefix.
894        assert_eq!(service_prefix("AWS::S3::Bucket::Tag"), Some("AWS::S3"));
895    }
896
897    #[test]
898    fn service_prefix_none_without_a_second_segment_or_empty_service_name() {
899        assert_eq!(service_prefix("Bucket"), None, "no `::` at all");
900        assert_eq!(service_prefix("Custom::MyResource"), None, "only a provider segment, no data-type segment");
901        assert_eq!(service_prefix("AWS::"), None, "provider then empty, no second `::`");
902        assert_eq!(service_prefix("AWS::::Widget"), None, "empty service-name segment");
903        assert_eq!(service_prefix(""), None);
904    }
905
906    #[test]
907    fn cloned_filter_recompiles_patterns_and_matches() {
908        let f = FilterConfig {
909            include: RuleFilterConfig { id_patterns: vec!["^E3\\d+$".into()], ..Default::default() },
910            ..Default::default()
911        };
912        assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
913        let f2 = f.clone();
914        assert!(f2.matches_rule("E3012", Some("schema"), None, None, None, None));
915    }
916
917    #[test]
918    fn empty_filter_globally_suppresses_nothing_at_the_default_level() {
919        let f = FilterConfig::default();
920        assert!(!f.globally_suppresses_builtin_rule(
921            "W9010",
922            Some("best-practice"),
923            Severity::Warn,
924            false,
925            Severity::Info
926        ));
927        assert!(!f.globally_suppresses_builtin_rule("E3012", Some("schema"), Severity::Error, false, Severity::Info));
928    }
929
930    #[test]
931    fn severity_gate_suppresses_rules_below_the_minimum_level() {
932        let f = FilterConfig::default();
933        // Reporting only errors and above prunes Warn and Info rules globally.
934        assert!(f.globally_suppresses_builtin_rule(
935            "W9010",
936            Some("best-practice"),
937            Severity::Warn,
938            false,
939            Severity::Error
940        ));
941        assert!(f.globally_suppresses_builtin_rule(
942            "I3011",
943            Some("best-practice"),
944            Severity::Info,
945            false,
946            Severity::Error
947        ));
948        assert!(!f.globally_suppresses_builtin_rule("E3012", Some("schema"), Severity::Error, false, Severity::Error));
949    }
950
951    #[test]
952    fn strict_promotion_keeps_a_warn_rule_alive_at_the_error_level() {
953        let f = FilterConfig::default();
954        // Strict promotes Warn to Error, so a Warn rule survives an Error gate.
955        assert!(!f.globally_suppresses_builtin_rule(
956            "W9010",
957            Some("best-practice"),
958            Severity::Warn,
959            true,
960            Severity::Error
961        ));
962        // An Info rule is not promoted and is still pruned.
963        assert!(f.globally_suppresses_builtin_rule(
964            "I3011",
965            Some("best-practice"),
966            Severity::Info,
967            true,
968            Severity::Error
969        ));
970    }
971
972    #[test]
973    fn global_exclude_dimensions_suppress_a_rule() {
974        let by_id = FilterConfig {
975            exclude: RuleFilterConfig { ids: vec!["W9010".into()], ..Default::default() },
976            ..Default::default()
977        };
978        assert!(by_id.globally_suppresses_builtin_rule(
979            "W9010",
980            Some("best-practice"),
981            Severity::Warn,
982            false,
983            Severity::Info
984        ));
985
986        let by_category = FilterConfig {
987            exclude: RuleFilterConfig { categories: vec!["best-practice".into()], ..Default::default() },
988            ..Default::default()
989        };
990        assert!(by_category.globally_suppresses_builtin_rule(
991            "W9010",
992            Some("best-practice"),
993            Severity::Warn,
994            false,
995            Severity::Info
996        ));
997
998        let by_range = FilterConfig {
999            exclude: RuleFilterConfig {
1000                id_ranges: vec![IdRange { prefix: "W".into(), start: 9000, end: 9099 }],
1001                ..Default::default()
1002            },
1003            ..Default::default()
1004        };
1005        assert!(by_range.globally_suppresses_builtin_rule(
1006            "W9010",
1007            Some("best-practice"),
1008            Severity::Warn,
1009            false,
1010            Severity::Info
1011        ));
1012
1013        let by_pattern = FilterConfig {
1014            exclude: RuleFilterConfig { id_patterns: vec!["^W90".into()], ..Default::default() },
1015            ..Default::default()
1016        };
1017        assert!(by_pattern.globally_suppresses_builtin_rule(
1018            "W9010",
1019            Some("best-practice"),
1020            Severity::Warn,
1021            false,
1022            Severity::Info
1023        ));
1024    }
1025
1026    #[test]
1027    fn scoped_exclude_dimensions_never_globally_suppress() {
1028        // These filters only silence the rule on specific resources, so it can
1029        // still fire elsewhere and must not be pruned before evaluation.
1030        let by_type = FilterConfig {
1031            exclude: RuleFilterConfig {
1032                resource_types: vec![ResourceTypeFilter {
1033                    rule_id: Some("W9010".into()),
1034                    resource_type: "AWS::EC2::Instance".into(),
1035                }],
1036                ..Default::default()
1037            },
1038            ..Default::default()
1039        };
1040        assert!(!by_type.globally_suppresses_builtin_rule(
1041            "W9010",
1042            Some("best-practice"),
1043            Severity::Warn,
1044            false,
1045            Severity::Info
1046        ));
1047
1048        let by_service = FilterConfig {
1049            exclude: RuleFilterConfig {
1050                services: vec![ServiceFilter { rule_id: None, service: "AWS::EC2".into() }],
1051                ..Default::default()
1052            },
1053            ..Default::default()
1054        };
1055        assert!(!by_service.globally_suppresses_builtin_rule(
1056            "W9010",
1057            Some("best-practice"),
1058            Severity::Warn,
1059            false,
1060            Severity::Info
1061        ));
1062
1063        let by_resource_id = FilterConfig {
1064            exclude: RuleFilterConfig {
1065                resource_ids: vec![ResourceIdFilter { rule_id: None, resource_id: "MyInstance".into() }],
1066                ..Default::default()
1067            },
1068            ..Default::default()
1069        };
1070        assert!(!by_resource_id.globally_suppresses_builtin_rule(
1071            "W9010",
1072            Some("best-practice"),
1073            Severity::Warn,
1074            false,
1075            Severity::Info
1076        ));
1077    }
1078
1079    #[test]
1080    fn nonempty_include_globally_suppresses_rules_it_can_never_admit() {
1081        let f = FilterConfig {
1082            include: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
1083            ..Default::default()
1084        };
1085        // Only E3012 is included, so any other rule can never survive.
1086        assert!(f.globally_suppresses_builtin_rule(
1087            "W9010",
1088            Some("best-practice"),
1089            Severity::Warn,
1090            false,
1091            Severity::Info
1092        ));
1093        assert!(!f.globally_suppresses_builtin_rule("E3012", Some("schema"), Severity::Error, false, Severity::Info));
1094    }
1095
1096    #[test]
1097    fn nonempty_include_with_scoped_dimension_keeps_a_coverable_rule_alive() {
1098        // A scoped include entry with no rule scope could admit any rule on the
1099        // matching resource, so no rule is globally pruned.
1100        let f = FilterConfig {
1101            include: RuleFilterConfig {
1102                resource_types: vec![ResourceTypeFilter { rule_id: None, resource_type: "AWS::EC2::Instance".into() }],
1103                ..Default::default()
1104            },
1105            ..Default::default()
1106        };
1107        assert!(!f.globally_suppresses_builtin_rule(
1108            "W9010",
1109            Some("best-practice"),
1110            Severity::Warn,
1111            false,
1112            Severity::Info
1113        ));
1114
1115        // A scoped include entry bound to a different rule cannot admit this one.
1116        let scoped_to_other = FilterConfig {
1117            include: RuleFilterConfig {
1118                resource_types: vec![ResourceTypeFilter {
1119                    rule_id: Some("E3012".into()),
1120                    resource_type: "AWS::EC2::Instance".into(),
1121                }],
1122                ..Default::default()
1123            },
1124            ..Default::default()
1125        };
1126        assert!(scoped_to_other.globally_suppresses_builtin_rule(
1127            "W9010",
1128            Some("best-practice"),
1129            Severity::Warn,
1130            false,
1131            Severity::Info
1132        ));
1133        assert!(!scoped_to_other.globally_suppresses_builtin_rule(
1134            "E3012",
1135            Some("schema"),
1136            Severity::Error,
1137            false,
1138            Severity::Info
1139        ));
1140    }
1141}