Skip to main content

rules/
helpers.rs

1use crate::category::Category;
2
3/// Returns `true` if the rule ID indicates a fatal-severity rule (prefix `F`).
4///
5/// This is a heuristic over the built-in ID convention only. It must never decide
6/// the severity, phase, or category of a custom, Rego, or Guard rule: those rules
7/// choose their own ID freely (see [`is_valid_custom_rule_id`]) and carry their own
8/// declared severity.
9pub fn is_fatal_rule(rule_id: &str) -> bool {
10    rule_id.starts_with('F')
11}
12
13/// The characters permitted in a custom (CEL/Rego/Guard) rule ID beyond ASCII
14/// letters and digits: the identifier separators `_`, `.`, and `-`.
15pub const CUSTOM_RULE_ID_SEPARATORS: [char; 3] = ['_', '.', '-'];
16
17/// Returns `true` if `rule_id` is a well-formed custom-rule identifier: a non-empty
18/// run of ASCII letters, digits, and the separators `_`, `.`, or `-`.
19///
20/// Custom (CEL/Rego/Guard) rule IDs are intentionally NOT required to follow the
21/// built-in `[FEWID]\d{4}` convention - an author may name a rule anything in that
22/// character set. The restriction only rejects whitespace and other punctuation that
23/// would corrupt diagnostic formatting, rule-ID filtering, and de-duplication.
24pub fn is_valid_custom_rule_id(rule_id: &str) -> bool {
25    !rule_id.is_empty() && rule_id.chars().all(|c| c.is_ascii_alphanumeric() || CUSTOM_RULE_ID_SEPARATORS.contains(&c))
26}
27
28/// Extracts the numeric part of a rule ID (the digits after the severity-prefix
29/// letter), used to order diagnostics by rule number within a severity. A rule ID
30/// that does not follow the `[FEWID]\d+` convention (e.g. a custom rule) yields
31/// `u32::MAX` so it sorts after the well-formed built-in rules.
32pub fn rule_number(rule_id: &str) -> u32 {
33    rule_id.get(1..).and_then(|digits| digits.parse::<u32>().ok()).unwrap_or(u32::MAX)
34}
35
36/// Map a rule ID to its diagnostic category based on the ID prefix convention.
37pub fn category_for_rule_id(rule_id: &str) -> Category {
38    if let Some(prefix3) = rule_id.get(..3) {
39        match prefix3 {
40            "E25" | "W25" => return Category::Resource,
41            "E86" | "W86" | "F86" => return Category::Structure,
42            "I25" => return Category::BestPractice,
43            "I35" => return Category::Resource,
44            "W30" => return Category::Schema,
45            "W36" => return Category::Resource,
46            _ => {}
47        }
48    }
49    if let Some(prefix2) = rule_id.get(..2) {
50        match prefix2 {
51            "F0" => return Category::Structure,
52            "F1" => return Category::Intrinsic,
53            "F2" => return Category::Structure,
54            "F3" => return Category::Schema,
55            "F5" => return Category::Structure,
56            "F6" => return Category::Structure,
57            "F7" => return Category::Structure,
58            "F8" => return Category::Structure,
59            "E0" => return Category::Structure,
60            "E1" => return Category::Intrinsic,
61            "E2" => return Category::Parameter,
62            "E3" => return Category::Schema,
63            "E5" => return Category::Structure,
64            "E6" => return Category::Structure,
65            "E8" => return Category::Structure,
66            "W1" => return Category::Intrinsic,
67            "W2" => return Category::Security,
68            "W3" => return Category::Schema,
69            "W7" => return Category::Structure,
70            "W8" => return Category::Structure,
71            "I2" => return Category::Structure,
72            "I3" => return Category::BestPractice,
73            _ => {}
74        }
75    }
76    Category::Resource
77}
78
79/// Map a CloudFormation schema format string to the rule ID that validates it.
80/// Returns `None` for formats without a dedicated rule.
81pub fn format_rule_for_format(format: &str) -> Option<&'static str> {
82    match format {
83        "AWS::EC2::SecurityGroup.Id" => Some("E1150"),
84        "AWS::EC2::VPC.Id" => Some("E1151"),
85        "AWS::EC2::Image.Id" => Some("E1152"),
86        "AWS::EC2::SecurityGroup.Name" => Some("E1153"),
87        "AWS::EC2::Subnet.Id" => Some("E1154"),
88        "AWS::Logs::LogGroup.Name" => Some("E1155"),
89        "AWS::IAM::Role.Arn" => Some("E1156"),
90        _ => None,
91    }
92}
93
94/// Derive the top-level CloudFormation template section a rule reports on.
95/// Used to fall back to the section's span when a diagnostic has no more
96/// precise location.
97pub fn section_for_rule_id(rule_id: &str) -> Option<&'static str> {
98    match rule_id {
99        "F0040" | "F6005" | "F6101" | "F6004" | "F6011" | "I6011" | "I6010" | "E6003" => Some("Outputs"),
100        "W8001" | "E8001" => Some("Conditions"),
101        "F0008" | "F0050" | "W7001" | "F0017" | "E7001" | "F7002" | "I7002" | "I7010" => Some("Mappings"),
102        "F0009" => Some("Conditions"),
103        "F0003" | "F0015" | "F0016" | "F2012" | "W2506" | "W2509" | "W2001" | "E2001" | "F2002" | "F2003" | "F2011"
104        | "I2011" | "F2015" | "W2501" | "I2010" => Some("Parameters"),
105        "F0001" | "F0007" | "F0011" | "E0001" | "F0005" | "E1028" => Some("Resources"),
106        "F0002" => Some("AWSTemplateFormatVersion"),
107        "F1004" => Some("Description"),
108        "E1005" => Some("Transform"),
109        "F0004" => Some("Outputs"),
110        "F8600" | "F8601" | "W8602" | "F8603" | "F8604" | "F8605" | "F8606" | "F8607" | "W8608" | "F8609" | "F8610"
111        | "F8611" => Some("Rules"),
112        _ => None,
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::category::Category;
120    use crate::registry::RULE_REGISTRY;
121
122    #[test]
123    fn is_fatal_rule_returns_true_for_f_prefix() {
124        assert!(is_fatal_rule("F3002"));
125        assert!(is_fatal_rule("F0001"));
126    }
127
128    #[test]
129    fn is_fatal_rule_returns_false_for_non_f_prefixes() {
130        assert!(!is_fatal_rule("E3002"));
131        assert!(!is_fatal_rule("W1001"));
132        assert!(!is_fatal_rule("I3011"));
133    }
134
135    #[test]
136    fn is_valid_custom_rule_id_accepts_alphanumeric_and_separators() {
137        // Built-in-style, arbitrary, and separator-bearing IDs are all permitted.
138        assert!(is_valid_custom_rule_id("CUSTOM001"));
139        assert!(is_valid_custom_rule_id("myRule1"));
140        assert!(is_valid_custom_rule_id("check_bucket_encryption"));
141        assert!(is_valid_custom_rule_id("s3.encryption.required"));
142        assert!(is_valid_custom_rule_id("my-rule-1"));
143        assert!(is_valid_custom_rule_id("Fluffy123"));
144        assert!(is_valid_custom_rule_id("_"));
145    }
146
147    #[test]
148    fn is_valid_custom_rule_id_rejects_empty_whitespace_and_punctuation() {
149        assert!(!is_valid_custom_rule_id(""));
150        assert!(!is_valid_custom_rule_id("my rule"));
151        assert!(!is_valid_custom_rule_id("rule/id"));
152        assert!(!is_valid_custom_rule_id("rule:id"));
153        assert!(!is_valid_custom_rule_id("rule#1"));
154        assert!(!is_valid_custom_rule_id("emoji😀"));
155    }
156
157    #[test]
158    fn rule_number_extracts_numeric_suffix_ignoring_prefix() {
159        assert_eq!(rule_number("F0001"), 1);
160        assert_eq!(rule_number("E3012"), 3012);
161        assert_eq!(rule_number("W9012"), 9012);
162        // The severity prefix is irrelevant to the number, so same-numbered rules of
163        // different severities share a number and are ordered by severity first.
164        assert_eq!(rule_number("F3012"), rule_number("E3012"));
165    }
166
167    #[test]
168    fn rule_number_returns_max_for_ids_without_a_numeric_suffix() {
169        assert_eq!(rule_number("CUSTOM"), u32::MAX);
170        assert_eq!(rule_number(""), u32::MAX);
171    }
172
173    #[test]
174    fn category_for_rule_id_maps_two_char_prefixes_correctly() {
175        assert_eq!(category_for_rule_id("F0001"), Category::Structure);
176        assert_eq!(category_for_rule_id("F1010"), Category::Intrinsic);
177        assert_eq!(category_for_rule_id("E1040"), Category::Intrinsic);
178        assert_eq!(category_for_rule_id("E2010"), Category::Parameter);
179        assert_eq!(category_for_rule_id("E3012"), Category::Schema);
180        // W21xx avoids the W25/W30/W36 3-char overrides
181        assert_eq!(category_for_rule_id("W2100"), Category::Security);
182        assert_eq!(category_for_rule_id("I3011"), Category::BestPractice);
183    }
184
185    #[test]
186    fn category_for_rule_id_applies_three_char_overrides() {
187        assert_eq!(category_for_rule_id("E2529"), Category::Resource);
188        assert_eq!(category_for_rule_id("E2504"), Category::Resource);
189        assert_eq!(category_for_rule_id("I2530"), Category::BestPractice);
190        assert_eq!(category_for_rule_id("F8600"), Category::Structure);
191    }
192
193    #[test]
194    fn category_for_rule_id_defaults_to_resource_for_unknown_prefix() {
195        assert_eq!(category_for_rule_id("Z9999"), Category::Resource);
196    }
197
198    #[test]
199    fn section_for_rule_id_maps_output_rules_to_outputs() {
200        assert_eq!(section_for_rule_id("F0040"), Some("Outputs"));
201        assert_eq!(section_for_rule_id("F0004"), Some("Outputs"));
202    }
203
204    #[test]
205    fn section_for_rule_id_maps_parameter_rules_to_parameters() {
206        assert_eq!(section_for_rule_id("F0003"), Some("Parameters"));
207        assert_eq!(section_for_rule_id("W2001"), Some("Parameters"));
208    }
209
210    #[test]
211    fn section_for_rule_id_maps_condition_rules_to_conditions() {
212        assert_eq!(section_for_rule_id("F0009"), Some("Conditions"));
213        assert_eq!(section_for_rule_id("W8001"), Some("Conditions"));
214    }
215
216    #[test]
217    fn section_for_rule_id_maps_rules_section_rules() {
218        assert_eq!(section_for_rule_id("F8600"), Some("Rules"));
219    }
220
221    #[test]
222    fn section_for_rule_id_returns_none_for_unmapped_rule() {
223        assert_eq!(section_for_rule_id("Z9999"), None);
224    }
225
226    #[test]
227    fn format_rule_for_format_maps_all_known_formats() {
228        assert_eq!(format_rule_for_format("AWS::EC2::Image.Id"), Some("E1152"));
229        assert_eq!(format_rule_for_format("AWS::EC2::SecurityGroup.Id"), Some("E1150"));
230        assert_eq!(format_rule_for_format("AWS::EC2::VPC.Id"), Some("E1151"));
231        assert_eq!(format_rule_for_format("AWS::EC2::Subnet.Id"), Some("E1154"));
232        assert_eq!(format_rule_for_format("AWS::Logs::LogGroup.Name"), Some("E1155"));
233        assert_eq!(format_rule_for_format("AWS::IAM::Role.Arn"), Some("E1156"));
234    }
235
236    #[test]
237    fn format_rule_for_format_returns_none_for_unknown_format() {
238        assert_eq!(format_rule_for_format("unknown-format"), None);
239    }
240
241    #[test]
242    fn category_heuristic_diverges_from_registry_for_some_rules() {
243        let mut divergences = 0;
244        for rule in RULE_REGISTRY {
245            if category_for_rule_id(rule.id) != rule.category {
246                divergences += 1;
247            }
248        }
249        // The helper uses prefix heuristics; the registry is authoritative.
250        // Many rules override the prefix convention. This test ensures
251        // divergences don't grow unexpectedly.
252        assert!(divergences > 0, "If all agree, the helper covers everything - great!");
253    }
254}