Skip to main content

slopguard_core/
rule.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::fs::read_to_string;
4use std::io::Error as IoError;
5use std::path::{Component, Path, PathBuf};
6use std::str::{from_utf8, Utf8Error};
7
8use ast_grep_language::SupportLang;
9use ignore::WalkBuilder;
10use serde::{Deserialize, Serialize};
11use serde_yaml::Value;
12use slopguard_rules::BuiltinRules;
13use strum::{Display, EnumIter, EnumString};
14use thiserror::Error;
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct RuleId(pub String);
18
19impl fmt::Display for RuleId {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        f.write_str(&self.0)
22    }
23}
24
25impl RuleId {
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29}
30
31impl From<String> for RuleId {
32    fn from(s: String) -> Self {
33        Self(s)
34    }
35}
36
37impl From<&str> for RuleId {
38    fn from(s: &str) -> Self {
39        Self(s.to_string())
40    }
41}
42
43#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45#[strum(serialize_all = "lowercase")]
46pub enum Severity {
47    Error,
48    Warning,
49}
50
51#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53#[strum(serialize_all = "lowercase")]
54pub enum Language {
55    Rust,
56    TypeScript,
57}
58
59impl Language {
60    /// The ast-grep languages a rule written for this language is compiled
61    /// against. TypeScript rules also cover TSX files.
62    pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
63        match self {
64            Language::Rust => &[SupportLang::Rust],
65            Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
66        }
67    }
68}
69
70#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72#[strum(serialize_all = "lowercase")]
73pub enum Category {
74    Slop,
75    Security,
76    #[default]
77    Correctness,
78}
79
80impl Category {
81    /// Derive the category from the first directory of a rule path relative to
82    /// its ruleset root (e.g. `security/no-debug.yml`). Returns `None` when the
83    /// path has no directory or the directory is not a known category.
84    fn from_rule_path(path: &Path) -> Option<Self> {
85        match path.components().next()? {
86            Component::Normal(dir) => dir.to_str()?.parse().ok(),
87            _ => None,
88        }
89    }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct RuleTests {
94    #[serde(default)]
95    pub should_match: Vec<String>,
96    #[serde(default)]
97    pub should_not_match: Vec<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Rule {
102    pub id: RuleId,
103    pub language: Language,
104    pub severity: Severity,
105    pub message: String,
106    #[serde(default)]
107    pub note: Option<String>,
108    #[serde(default)]
109    pub category: Option<Category>,
110    #[serde(default)]
111    pub fix: Option<String>,
112    pub rule: Value,
113    #[serde(default)]
114    pub files: Option<Vec<String>>,
115    #[serde(default)]
116    pub ignores: Option<Vec<String>>,
117    /// Drop findings located inside `#[cfg(test)]` blocks of scanned Rust files.
118    /// Complements `ignores`, which only filters whole files by path.
119    #[serde(default)]
120    pub skip_test_code: bool,
121    #[serde(default)]
122    pub tests: Option<RuleTests>,
123}
124
125#[derive(Debug, Error)]
126pub enum RuleError {
127    #[error("failed to parse rule YAML: {0}")]
128    Parse(#[from] serde_yaml::Error),
129
130    #[error("rule '{id}': rule field must not be empty")]
131    EmptyRule { id: RuleId },
132
133    #[error("duplicate rule id: '{id}'")]
134    DuplicateId { id: RuleId },
135
136    #[error("failed to read rule file '{path}': {source}")]
137    Io {
138        path: String,
139        #[source]
140        source: IoError,
141    },
142
143    #[error("rule file '{path}' is not valid UTF-8: {source}")]
144    Utf8 {
145        path: String,
146        #[source]
147        source: Utf8Error,
148    },
149}
150
151impl RuleError {
152    fn io(path: &Path, source: IoError) -> Self {
153        Self::Io {
154            path: path.display().to_string(),
155            source,
156        }
157    }
158}
159
160/// Parse a single YAML string into a Rule, validating required fields.
161pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
162    let rule: Rule = serde_yaml::from_str(yaml)?;
163    if rule.rule.is_null() {
164        return Err(RuleError::EmptyRule { id: rule.id });
165    }
166    Ok(rule)
167}
168
169/// Parse a rule and fill in its category from the ruleset-relative path when
170/// the YAML omits it.
171fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
172    let mut rule = parse_rule(yaml)?;
173    if rule.category.is_none() {
174        rule.category = Category::from_rule_path(relative_path);
175    }
176    Ok(rule)
177}
178
179fn is_rule_file(path: &Path) -> bool {
180    matches!(
181        path.extension().and_then(|e| e.to_str()),
182        Some("yml" | "yaml")
183    )
184}
185
186/// Load all builtin rules embedded via rust-embed.
187pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
188    BuiltinRules::iter()
189        .filter(|path| is_rule_file(Path::new(path.as_ref())))
190        .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
191        .map(|(path, file)| {
192            let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
193                path: path.to_string(),
194                source: e,
195            })?;
196            parse_rule_at(yaml, Path::new(path.as_ref()))
197        })
198        .collect()
199}
200
201/// Load rules from custom directories on the filesystem. Missing directories
202/// are skipped; subdirectories are searched recursively.
203pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
204    let mut rules = Vec::new();
205    for dir in dirs.iter().filter(|dir| dir.is_dir()) {
206        // Custom rule directories are plain file trees: gitignore and
207        // hidden-file filtering would silently drop rules.
208        for entry in WalkBuilder::new(dir).standard_filters(false).build() {
209            let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
210            let path = entry.path();
211            let is_file = entry.file_type().is_some_and(|t| t.is_file());
212            if !is_file || !is_rule_file(path) {
213                continue;
214            }
215            let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
216            let relative = path.strip_prefix(dir).unwrap_or(path);
217            rules.push(parse_rule_at(&yaml, relative)?);
218        }
219    }
220    Ok(rules)
221}
222
223/// Load all effective rules based on config: builtin (filtered by rulesets and
224/// disabled list) plus custom rules from configured directories.
225pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
226    let mut rules: Vec<Rule> = load_builtin_rules()?
227        .into_iter()
228        .filter(
229            |rule| match rule.category.as_ref().unwrap_or(&Category::Correctness) {
230                Category::Slop => config.rulesets.slop,
231                Category::Security => config.rulesets.security,
232                Category::Correctness => config.rulesets.correctness,
233            },
234        )
235        .filter(|rule| !config.rules.disable.iter().any(|d| d == rule.id.as_str()))
236        .collect();
237
238    let custom = load_custom_rules(&config.rules.custom_dirs)?;
239    rules.extend(custom);
240    validate_unique_ids(&rules)?;
241    Ok(rules)
242}
243
244/// Validate that all rule ids are unique.
245pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
246    let mut seen = HashSet::new();
247    for rule in rules {
248        if !seen.insert(&rule.id) {
249            return Err(RuleError::DuplicateId {
250                id: rule.id.clone(),
251            });
252        }
253    }
254    Ok(())
255}
256
257#[cfg(test)]
258mod tests {
259    use std::fs::{create_dir, write};
260
261    use tempfile::tempdir;
262
263    use super::*;
264
265    #[test]
266    fn parse_valid_rule_all_fields() {
267        let yaml = r#"
268id: test-rule
269language: rust
270severity: error
271message: "Test message"
272note: "Test note"
273category: security
274fix: "Use X instead"
275rule:
276  kind: call_expression
277  regex: '\.unwrap\(\)\s*$'
278files:
279  - "**/src/**/*.rs"
280ignores:
281  - "**/tests/**"
282skip_test_code: true
283tests:
284  should_match:
285    - "let x = foo().unwrap();"
286  should_not_match:
287    - "let x = foo()?;"
288"#;
289        let rule = parse_rule(yaml).unwrap();
290        assert!(rule.skip_test_code);
291        assert_eq!(rule.id, RuleId::from("test-rule"));
292        assert_eq!(rule.language, Language::Rust);
293        assert_eq!(rule.severity, Severity::Error);
294        assert_eq!(rule.message, "Test message");
295        assert_eq!(rule.note.as_deref(), Some("Test note"));
296        assert_eq!(rule.category, Some(Category::Security));
297        assert_eq!(rule.fix.as_deref(), Some("Use X instead"));
298        assert!(!rule.rule.is_null());
299        assert_eq!(rule.files.as_ref().unwrap().len(), 1);
300        assert_eq!(rule.ignores.as_ref().unwrap().len(), 1);
301        let tests = rule.tests.as_ref().unwrap();
302        assert_eq!(tests.should_match.len(), 1);
303        assert_eq!(tests.should_not_match.len(), 1);
304    }
305
306    #[test]
307    fn parse_minimal_rule() {
308        let yaml = r#"
309id: minimal-rule
310language: rust
311severity: warning
312message: "Minimal"
313rule:
314  pattern: $X.unwrap()
315"#;
316        let rule = parse_rule(yaml).unwrap();
317        assert_eq!(rule.id, RuleId::from("minimal-rule"));
318        assert_eq!(rule.severity, Severity::Warning);
319        assert!(rule.note.is_none());
320        assert!(rule.category.is_none());
321        assert!(rule.fix.is_none());
322        assert!(rule.files.is_none());
323        assert!(rule.ignores.is_none());
324        assert!(!rule.skip_test_code);
325        assert!(rule.tests.is_none());
326    }
327
328    #[test]
329    fn reject_missing_id() {
330        let yaml = r#"
331language: rust
332severity: error
333message: "No id"
334rule:
335  pattern: $X
336"#;
337        let err = parse_rule(yaml).unwrap_err();
338        let msg = err.to_string();
339        assert!(
340            msg.contains("missing field"),
341            "expected missing field error, got: {msg}"
342        );
343    }
344
345    #[test]
346    fn reject_unsupported_language() {
347        let yaml = r#"
348id: bad-lang
349language: python
350severity: error
351message: "Bad lang"
352rule:
353  pattern: $X
354"#;
355        let err = parse_rule(yaml).unwrap_err();
356        assert!(matches!(err, RuleError::Parse(_)));
357        let msg = err.to_string();
358        assert!(
359            msg.contains("unknown variant"),
360            "expected unknown variant error, got: {msg}"
361        );
362    }
363
364    #[test]
365    fn reject_empty_rule() {
366        let yaml = r#"
367id: empty-rule
368language: rust
369severity: error
370message: "Empty rule"
371rule:
372"#;
373        let err = parse_rule(yaml).unwrap_err();
374        let msg = err.to_string();
375        assert!(
376            msg.contains("must not be empty"),
377            "expected empty rule error, got: {msg}"
378        );
379    }
380
381    #[test]
382    fn reject_malformed_yaml() {
383        let yaml = "this is: [not: valid: yaml: {{{";
384        let err = parse_rule(yaml).unwrap_err();
385        assert!(matches!(err, RuleError::Parse(_)));
386    }
387
388    #[test]
389    fn load_all_builtin_rules() {
390        let rules = load_builtin_rules().unwrap();
391        assert_eq!(
392            rules.len(),
393            34,
394            "expected 34 builtin rules, got {}",
395            rules.len()
396        );
397
398        let slop_count = rules
399            .iter()
400            .filter(|r| r.category == Some(Category::Slop))
401            .count();
402        let security_count = rules
403            .iter()
404            .filter(|r| r.category == Some(Category::Security))
405            .count();
406        let correctness_count = rules
407            .iter()
408            .filter(|r| r.category == Some(Category::Correctness))
409            .count();
410        assert_eq!(slop_count, 9, "expected 9 slop rules");
411        assert_eq!(security_count, 8, "expected 8 security rules");
412        assert_eq!(correctness_count, 17, "expected 17 correctness rules");
413
414        assert!(rules
415            .iter()
416            .any(|r| r.id == RuleId::from("no-unwrap-in-prod")));
417        assert!(rules
418            .iter()
419            .any(|r| r.id == RuleId::from("no-debug-on-secrets")));
420        assert!(rules.iter().any(|r| r.id == RuleId::from("no-slop-words")));
421
422        validate_unique_ids(&rules).unwrap();
423    }
424
425    #[test]
426    fn load_custom_rules_from_dir() {
427        let dir = tempdir().unwrap();
428        let rule_yaml = r#"
429id: custom-rule
430language: rust
431severity: warning
432message: "Custom rule"
433rule:
434  pattern: $X.clone()
435"#;
436        write(dir.path().join("custom.yml"), rule_yaml).unwrap();
437
438        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
439        assert_eq!(rules.len(), 1);
440        assert_eq!(rules[0].id, RuleId::from("custom-rule"));
441    }
442
443    #[test]
444    fn reject_duplicate_ids() {
445        let rule1 = parse_rule(
446            r#"
447id: same-id
448language: rust
449severity: error
450message: "First"
451rule:
452  pattern: $X
453"#,
454        )
455        .unwrap();
456        let rule2 = parse_rule(
457            r#"
458id: same-id
459language: rust
460severity: warning
461message: "Second"
462rule:
463  pattern: $Y
464"#,
465        )
466        .unwrap();
467        let err = validate_unique_ids(&[rule1, rule2]).unwrap_err();
468        let msg = err.to_string();
469        assert!(
470            msg.contains("duplicate rule id"),
471            "expected duplicate id error, got: {msg}"
472        );
473        assert!(
474            msg.contains("same-id"),
475            "expected same-id in error, got: {msg}"
476        );
477    }
478
479    #[test]
480    fn category_from_rule_path() {
481        let category = |p: &str| Category::from_rule_path(Path::new(p));
482        assert_eq!(category("slop/no-slop-words.yml"), Some(Category::Slop));
483        assert_eq!(category("security/no-debug.yml"), Some(Category::Security));
484        assert_eq!(
485            category("correctness/no-unwrap.yml"),
486            Some(Category::Correctness)
487        );
488        assert_eq!(category("unknown/something.yml"), None);
489        assert_eq!(category("rule.yml"), None);
490    }
491
492    #[test]
493    fn all_builtin_rules_valid_messages() {
494        for path in BuiltinRules::iter() {
495            if !is_rule_file(Path::new(path.as_ref())) {
496                continue;
497            }
498            let file = BuiltinRules::get(&path).unwrap();
499            let yaml = from_utf8(&file.data).unwrap();
500            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
501            assert!(
502                !rule.message.contains('\u{2014}'),
503                "rule '{}' message contains em dash: {}",
504                rule.id,
505                rule.message
506            );
507            assert!(
508                !rule.message.contains('\u{2013}'),
509                "rule '{}' message contains en dash: {}",
510                rule.id,
511                rule.message
512            );
513            assert!(
514                rule.message.is_ascii() || rule.message.chars().all(|c| c.is_ascii() || c == '\''),
515                "rule '{}' message contains non-ASCII characters: {}",
516                rule.id,
517                rule.message
518            );
519        }
520    }
521
522    #[test]
523    fn all_builtin_rules_have_tests() {
524        for path in BuiltinRules::iter() {
525            if !is_rule_file(Path::new(path.as_ref())) {
526                continue;
527            }
528            let file = BuiltinRules::get(&path).unwrap();
529            let yaml = from_utf8(&file.data).unwrap();
530            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
531            let tests = rule.tests.as_ref().unwrap_or_else(|| {
532                panic!("rule '{}' (at {path}) is missing `tests` block", rule.id)
533            });
534            assert!(
535                tests.should_match.len() >= 2,
536                "rule '{}' (at {path}) needs >= 2 should_match, got {}",
537                rule.id,
538                tests.should_match.len()
539            );
540            assert!(
541                tests.should_not_match.len() >= 2,
542                "rule '{}' (at {path}) needs >= 2 should_not_match, got {}",
543                rule.id,
544                tests.should_not_match.len()
545            );
546        }
547    }
548
549    #[test]
550    fn all_builtin_rules_have_explicit_category() {
551        for path in BuiltinRules::iter() {
552            if !is_rule_file(Path::new(path.as_ref())) {
553                continue;
554            }
555            let file = BuiltinRules::get(&path).unwrap();
556            let yaml = from_utf8(&file.data).unwrap();
557            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
558            assert!(
559                rule.category.is_some(),
560                "rule '{}' (at {path}) is missing an explicit `category` field",
561                rule.id
562            );
563        }
564    }
565
566    #[test]
567    fn custom_rules_category_from_subdir() {
568        let dir = tempdir().unwrap();
569        let security_dir = dir.path().join("security");
570        create_dir(&security_dir).unwrap();
571        let rule_yaml = r#"
572id: custom-sec
573language: rust
574severity: error
575message: "Custom security"
576rule:
577  pattern: $X
578"#;
579        write(security_dir.join("rule.yml"), rule_yaml).unwrap();
580
581        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
582        assert_eq!(rules.len(), 1);
583        assert_eq!(rules[0].category, Some(Category::Security));
584    }
585}