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        "AWS::IAM::Role.Name" => Some("E1165"),
91        _ => None,
92    }
93}
94
95/// Derive the top-level CloudFormation template section a rule reports on.
96/// Used to fall back to the section's span when a diagnostic has no more
97/// precise location.
98pub fn section_for_rule_id(rule_id: &str) -> Option<&'static str> {
99    match rule_id {
100        "F0040" | "F6005" | "F6101" | "F6004" | "F6011" | "I6011" | "I6010" | "E6003" => Some("Outputs"),
101        "W8001" | "E8001" => Some("Conditions"),
102        "F0008" | "F0050" | "W7001" | "F0017" | "E7001" | "F7002" | "I7002" | "I7010" => Some("Mappings"),
103        "F0009" => Some("Conditions"),
104        "F0003" | "F0015" | "F0016" | "F2012" | "W2506" | "W2509" | "W2001" | "E2001" | "F2002" | "F2003" | "F2011"
105        | "I2011" | "F2015" | "W2501" | "I2010" => Some("Parameters"),
106        "F0001" | "F0007" | "F0011" | "E0001" | "F0005" | "E1028" => Some("Resources"),
107        "F0002" => Some("AWSTemplateFormatVersion"),
108        "F1004" => Some("Description"),
109        "E1005" => Some("Transform"),
110        "F0004" => Some("Outputs"),
111        "F8600" | "F8601" | "W8602" | "F8603" | "F8604" | "F8605" | "F8606" | "F8607" | "W8608" | "F8609" | "F8610"
112        | "F8611" => Some("Rules"),
113        _ => None,
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::category::Category;
121    use crate::registry::RULE_REGISTRY;
122
123    #[test]
124    fn is_fatal_rule_returns_true_for_f_prefix() {
125        assert!(is_fatal_rule("F3002"));
126        assert!(is_fatal_rule("F0001"));
127    }
128
129    #[test]
130    fn is_fatal_rule_returns_false_for_non_f_prefixes() {
131        assert!(!is_fatal_rule("E3002"));
132        assert!(!is_fatal_rule("W1001"));
133        assert!(!is_fatal_rule("I3011"));
134    }
135
136    #[test]
137    fn is_valid_custom_rule_id_accepts_alphanumeric_and_separators() {
138        // Built-in-style, arbitrary, and separator-bearing IDs are all permitted.
139        assert!(is_valid_custom_rule_id("CUSTOM001"));
140        assert!(is_valid_custom_rule_id("myRule1"));
141        assert!(is_valid_custom_rule_id("check_bucket_encryption"));
142        assert!(is_valid_custom_rule_id("s3.encryption.required"));
143        assert!(is_valid_custom_rule_id("my-rule-1"));
144        assert!(is_valid_custom_rule_id("Fluffy123"));
145        assert!(is_valid_custom_rule_id("_"));
146    }
147
148    #[test]
149    fn is_valid_custom_rule_id_rejects_empty_whitespace_and_punctuation() {
150        assert!(!is_valid_custom_rule_id(""));
151        assert!(!is_valid_custom_rule_id("my rule"));
152        assert!(!is_valid_custom_rule_id("rule/id"));
153        assert!(!is_valid_custom_rule_id("rule:id"));
154        assert!(!is_valid_custom_rule_id("rule#1"));
155        assert!(!is_valid_custom_rule_id("emoji😀"));
156    }
157
158    #[test]
159    fn rule_number_extracts_numeric_suffix_ignoring_prefix() {
160        assert_eq!(rule_number("F0001"), 1);
161        assert_eq!(rule_number("E3012"), 3012);
162        assert_eq!(rule_number("W9012"), 9012);
163        // The severity prefix is irrelevant to the number, so same-numbered rules of
164        // different severities share a number and are ordered by severity first.
165        assert_eq!(rule_number("F3012"), rule_number("E3012"));
166    }
167
168    #[test]
169    fn rule_number_returns_max_for_ids_without_a_numeric_suffix() {
170        assert_eq!(rule_number("CUSTOM"), u32::MAX);
171        assert_eq!(rule_number(""), u32::MAX);
172    }
173
174    #[test]
175    fn category_for_rule_id_maps_two_char_prefixes_correctly() {
176        assert_eq!(category_for_rule_id("F0001"), Category::Structure);
177        assert_eq!(category_for_rule_id("F1010"), Category::Intrinsic);
178        assert_eq!(category_for_rule_id("E1040"), Category::Intrinsic);
179        assert_eq!(category_for_rule_id("E2010"), Category::Parameter);
180        assert_eq!(category_for_rule_id("E3012"), Category::Schema);
181        // W21xx avoids the W25/W30/W36 3-char overrides
182        assert_eq!(category_for_rule_id("W2100"), Category::Security);
183        assert_eq!(category_for_rule_id("I3011"), Category::BestPractice);
184    }
185
186    #[test]
187    fn category_for_rule_id_applies_three_char_overrides() {
188        assert_eq!(category_for_rule_id("E2529"), Category::Resource);
189        assert_eq!(category_for_rule_id("E2504"), Category::Resource);
190        assert_eq!(category_for_rule_id("I2530"), Category::BestPractice);
191        assert_eq!(category_for_rule_id("F8600"), Category::Structure);
192    }
193
194    #[test]
195    fn category_for_rule_id_defaults_to_resource_for_unknown_prefix() {
196        assert_eq!(category_for_rule_id("Z9999"), Category::Resource);
197    }
198
199    #[test]
200    fn section_for_rule_id_maps_output_rules_to_outputs() {
201        assert_eq!(section_for_rule_id("F0040"), Some("Outputs"));
202        assert_eq!(section_for_rule_id("F0004"), Some("Outputs"));
203    }
204
205    #[test]
206    fn section_for_rule_id_maps_parameter_rules_to_parameters() {
207        assert_eq!(section_for_rule_id("F0003"), Some("Parameters"));
208        assert_eq!(section_for_rule_id("W2001"), Some("Parameters"));
209    }
210
211    #[test]
212    fn section_for_rule_id_maps_condition_rules_to_conditions() {
213        assert_eq!(section_for_rule_id("F0009"), Some("Conditions"));
214        assert_eq!(section_for_rule_id("W8001"), Some("Conditions"));
215    }
216
217    #[test]
218    fn section_for_rule_id_maps_rules_section_rules() {
219        assert_eq!(section_for_rule_id("F8600"), Some("Rules"));
220    }
221
222    #[test]
223    fn section_for_rule_id_returns_none_for_unmapped_rule() {
224        assert_eq!(section_for_rule_id("Z9999"), None);
225    }
226
227    #[test]
228    fn format_rule_for_format_maps_all_known_formats() {
229        assert_eq!(format_rule_for_format("AWS::EC2::Image.Id"), Some("E1152"));
230        assert_eq!(format_rule_for_format("AWS::EC2::SecurityGroup.Id"), Some("E1150"));
231        assert_eq!(format_rule_for_format("AWS::EC2::VPC.Id"), Some("E1151"));
232        assert_eq!(format_rule_for_format("AWS::EC2::Subnet.Id"), Some("E1154"));
233        assert_eq!(format_rule_for_format("AWS::Logs::LogGroup.Name"), Some("E1155"));
234        assert_eq!(format_rule_for_format("AWS::IAM::Role.Arn"), Some("E1156"));
235        assert_eq!(format_rule_for_format("AWS::IAM::Role.Name"), Some("E1165"));
236    }
237
238    #[test]
239    fn format_rule_for_format_returns_none_for_unknown_format() {
240        assert_eq!(format_rule_for_format("unknown-format"), None);
241    }
242
243    #[test]
244    fn category_heuristic_diverges_from_registry_for_some_rules() {
245        let mut divergences = 0;
246        for rule in RULE_REGISTRY {
247            if category_for_rule_id(rule.id) != rule.category {
248                divergences += 1;
249            }
250        }
251        // The helper uses prefix heuristics; the registry is authoritative.
252        // Many rules override the prefix convention. This test ensures
253        // divergences don't grow unexpectedly.
254        assert!(divergences > 0, "If all agree, the helper covers everything - great!");
255    }
256}