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, Hash, 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
100fn default_enabled() -> bool {
101    true
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Rule {
106    pub id: RuleId,
107    pub language: Language,
108    pub severity: Severity,
109    pub message: String,
110    #[serde(default)]
111    pub note: Option<String>,
112    #[serde(default)]
113    pub category: Option<Category>,
114    #[serde(default)]
115    pub fix: Option<String>,
116    pub rule: Value,
117    #[serde(default)]
118    pub files: Option<Vec<String>>,
119    #[serde(default)]
120    pub ignores: Option<Vec<String>>,
121    /// Drop findings located inside `#[cfg(test)]` blocks of scanned Rust files.
122    /// Complements `ignores`, which only filters whole files by path.
123    #[serde(default = "default_enabled")]
124    pub enabled: bool,
125    #[serde(default)]
126    pub skip_test_code: bool,
127    #[serde(default)]
128    pub tests: Option<RuleTests>,
129}
130
131#[derive(Debug, Error)]
132pub enum RuleError {
133    #[error("failed to parse rule YAML: {0}")]
134    Parse(#[from] serde_yaml::Error),
135
136    #[error("rule '{id}': rule field must not be empty")]
137    EmptyRule { id: RuleId },
138
139    #[error("duplicate rule id: '{id}'")]
140    DuplicateId { id: RuleId },
141
142    #[error("failed to read rule file '{path}': {source}")]
143    Io {
144        path: String,
145        #[source]
146        source: IoError,
147    },
148
149    #[error("rule file '{path}' is not valid UTF-8: {source}")]
150    Utf8 {
151        path: String,
152        #[source]
153        source: Utf8Error,
154    },
155}
156
157impl RuleError {
158    fn io(path: &Path, source: IoError) -> Self {
159        Self::Io {
160            path: path.display().to_string(),
161            source,
162        }
163    }
164}
165
166/// Parse a single YAML string into a Rule, validating required fields.
167pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
168    let rule: Rule = serde_yaml::from_str(yaml)?;
169    if rule.rule.is_null() {
170        return Err(RuleError::EmptyRule { id: rule.id });
171    }
172    Ok(rule)
173}
174
175/// Parse a rule and fill in its category from the ruleset-relative path when
176/// the YAML omits it.
177fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
178    let mut rule = parse_rule(yaml)?;
179    if rule.category.is_none() {
180        rule.category = Category::from_rule_path(relative_path);
181    }
182    Ok(rule)
183}
184
185fn is_rule_file(path: &Path) -> bool {
186    matches!(
187        path.extension().and_then(|e| e.to_str()),
188        Some("yml" | "yaml")
189    )
190}
191
192/// Load all builtin rules embedded via rust-embed.
193pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
194    BuiltinRules::iter()
195        .filter(|path| is_rule_file(Path::new(path.as_ref())))
196        .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
197        .map(|(path, file)| {
198            let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
199                path: path.to_string(),
200                source: e,
201            })?;
202            parse_rule_at(yaml, Path::new(path.as_ref()))
203        })
204        .collect()
205}
206
207/// Load rules from custom directories on the filesystem. Missing directories
208/// are skipped; subdirectories are searched recursively.
209pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
210    let mut rules = Vec::new();
211    for dir in dirs.iter().filter(|dir| dir.is_dir()) {
212        // Custom rule directories are plain file trees: gitignore and
213        // hidden-file filtering would silently drop rules.
214        for entry in WalkBuilder::new(dir).standard_filters(false).build() {
215            let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
216            let path = entry.path();
217            let is_file = entry.file_type().is_some_and(|t| t.is_file());
218            if !is_file || !is_rule_file(path) {
219                continue;
220            }
221            let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
222            let relative = path.strip_prefix(dir).unwrap_or(path);
223            rules.push(parse_rule_at(&yaml, relative)?);
224        }
225    }
226    Ok(rules)
227}
228
229/// Whether a builtin rule takes part in a scan under `config`.
230///
231/// A rule listed in `rules.enable` is always active. Otherwise it must be
232/// enabled by default, belong to an active ruleset, and not be listed in
233/// `rules.disable`.
234pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
235    let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
236    if listed(&config.rules.enable) {
237        return true;
238    }
239    let ruleset_on = match rule.category.as_ref().unwrap_or(&Category::Correctness) {
240        Category::Slop => config.rulesets.slop,
241        Category::Security => config.rulesets.security,
242        Category::Correctness => config.rulesets.correctness,
243    };
244    ruleset_on && rule.enabled && !listed(&config.rules.disable)
245}
246
247/// Load all effective rules based on config: builtin (filtered by rulesets and
248/// the enable/disable lists) plus custom rules from configured directories.
249pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
250    let mut rules: Vec<Rule> = load_builtin_rules()?
251        .into_iter()
252        .filter(|rule| is_rule_active(rule, config))
253        .collect();
254
255    let custom = load_custom_rules(&config.rules.custom_dirs)?;
256    rules.extend(custom);
257    validate_unique_ids(&rules)?;
258    Ok(rules)
259}
260
261/// Load all rules (builtin + custom) without filtering by activation status.
262/// Used by `explain` to look up any rule regardless of config.
263pub fn load_all_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
264    let mut rules = load_builtin_rules()?;
265    let custom = load_custom_rules(&config.rules.custom_dirs)?;
266    rules.extend(custom);
267    Ok(rules)
268}
269
270/// Validate that all rule (id, language) pairs are unique. The same rule id
271/// may appear for different languages (e.g. `no-todo-fixme` for both Rust and
272/// TypeScript), so a single `rules.disable` entry covers both variants.
273pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
274    let mut seen = HashSet::new();
275    for rule in rules {
276        if !seen.insert((&rule.id, &rule.language)) {
277            return Err(RuleError::DuplicateId {
278                id: rule.id.clone(),
279            });
280        }
281    }
282    Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287    use std::fs::{create_dir, write};
288
289    use tempfile::tempdir;
290
291    use super::*;
292
293    #[test]
294    fn parse_valid_rule_all_fields() {
295        let yaml = r#"
296id: test-rule
297language: rust
298severity: error
299message: "Test message"
300note: "Test note"
301category: security
302fix: "Use X instead"
303rule:
304  kind: call_expression
305  regex: '\.unwrap\(\)\s*$'
306files:
307  - "**/src/**/*.rs"
308ignores:
309  - "**/tests/**"
310skip_test_code: true
311tests:
312  should_match:
313    - "let x = foo().unwrap();"
314  should_not_match:
315    - "let x = foo()?;"
316"#;
317        let rule = parse_rule(yaml).unwrap();
318        assert!(rule.skip_test_code);
319        assert_eq!(rule.id, RuleId::from("test-rule"));
320        assert_eq!(rule.language, Language::Rust);
321        assert_eq!(rule.severity, Severity::Error);
322        assert_eq!(rule.message, "Test message");
323        assert_eq!(rule.note.as_deref(), Some("Test note"));
324        assert_eq!(rule.category, Some(Category::Security));
325        assert_eq!(rule.fix.as_deref(), Some("Use X instead"));
326        assert!(!rule.rule.is_null());
327        assert_eq!(rule.files.as_ref().unwrap().len(), 1);
328        assert_eq!(rule.ignores.as_ref().unwrap().len(), 1);
329        let tests = rule.tests.as_ref().unwrap();
330        assert_eq!(tests.should_match.len(), 1);
331        assert_eq!(tests.should_not_match.len(), 1);
332    }
333
334    #[test]
335    fn parse_minimal_rule() {
336        let yaml = r#"
337id: minimal-rule
338language: rust
339severity: warning
340message: "Minimal"
341rule:
342  pattern: $X.unwrap()
343"#;
344        let rule = parse_rule(yaml).unwrap();
345        assert_eq!(rule.id, RuleId::from("minimal-rule"));
346        assert_eq!(rule.severity, Severity::Warning);
347        assert!(rule.note.is_none());
348        assert!(rule.category.is_none());
349        assert!(rule.fix.is_none());
350        assert!(rule.files.is_none());
351        assert!(rule.ignores.is_none());
352        assert!(!rule.skip_test_code);
353        assert!(rule.tests.is_none());
354        assert!(rule.enabled);
355    }
356
357    #[test]
358    fn rule_activation_precedence() {
359        let opt_in = parse_rule(
360            r#"
361id: opt-in
362language: rust
363severity: warning
364category: slop
365enabled: false
366message: "Opt-in"
367rule:
368  pattern: $X.unwrap()
369"#,
370        )
371        .unwrap();
372        let mut config = crate::config::Config::default();
373
374        assert!(!is_rule_active(&opt_in, &config), "enabled: false is off");
375
376        config.rules.enable.push("opt-in".to_string());
377        assert!(is_rule_active(&opt_in, &config), "enable list turns it on");
378
379        config.rules.disable.push("opt-in".to_string());
380        assert!(is_rule_active(&opt_in, &config), "enable wins over disable");
381
382        config.rulesets.slop = false;
383        assert!(is_rule_active(&opt_in, &config), "enable wins over ruleset");
384
385        config.rules.enable.clear();
386        config.rules.disable.clear();
387        config.rulesets.slop = true;
388        let mut default_on = opt_in.clone();
389        default_on.enabled = true;
390        assert!(is_rule_active(&default_on, &config));
391
392        config.rules.disable.push("opt-in".to_string());
393        assert!(!is_rule_active(&default_on, &config), "disable list");
394
395        config.rules.disable.clear();
396        config.rulesets.slop = false;
397        assert!(!is_rule_active(&default_on, &config), "ruleset off");
398    }
399
400    #[test]
401    fn reject_missing_id() {
402        let yaml = r#"
403language: rust
404severity: error
405message: "No id"
406rule:
407  pattern: $X
408"#;
409        let err = parse_rule(yaml).unwrap_err();
410        let msg = err.to_string();
411        assert!(
412            msg.contains("missing field"),
413            "expected missing field error, got: {msg}"
414        );
415    }
416
417    #[test]
418    fn reject_unsupported_language() {
419        let yaml = r#"
420id: bad-lang
421language: python
422severity: error
423message: "Bad lang"
424rule:
425  pattern: $X
426"#;
427        let err = parse_rule(yaml).unwrap_err();
428        assert!(matches!(err, RuleError::Parse(_)));
429        let msg = err.to_string();
430        assert!(
431            msg.contains("unknown variant"),
432            "expected unknown variant error, got: {msg}"
433        );
434    }
435
436    #[test]
437    fn reject_empty_rule() {
438        let yaml = r#"
439id: empty-rule
440language: rust
441severity: error
442message: "Empty rule"
443rule:
444"#;
445        let err = parse_rule(yaml).unwrap_err();
446        let msg = err.to_string();
447        assert!(
448            msg.contains("must not be empty"),
449            "expected empty rule error, got: {msg}"
450        );
451    }
452
453    #[test]
454    fn reject_malformed_yaml() {
455        let yaml = "this is: [not: valid: yaml: {{{";
456        let err = parse_rule(yaml).unwrap_err();
457        assert!(matches!(err, RuleError::Parse(_)));
458    }
459
460    #[test]
461    fn load_all_builtin_rules() {
462        let rules = load_builtin_rules().unwrap();
463        assert_eq!(
464            rules.len(),
465            55,
466            "expected 55 builtin rules, got {}",
467            rules.len()
468        );
469
470        let slop_count = rules
471            .iter()
472            .filter(|r| r.category == Some(Category::Slop))
473            .count();
474        let security_count = rules
475            .iter()
476            .filter(|r| r.category == Some(Category::Security))
477            .count();
478        let correctness_count = rules
479            .iter()
480            .filter(|r| r.category == Some(Category::Correctness))
481            .count();
482        assert_eq!(slop_count, 17, "expected 17 slop rules");
483        assert_eq!(security_count, 11, "expected 11 security rules");
484        assert_eq!(correctness_count, 27, "expected 27 correctness rules");
485
486        assert!(rules
487            .iter()
488            .any(|r| r.id == RuleId::from("no-unwrap-in-prod")));
489        assert!(rules
490            .iter()
491            .any(|r| r.id == RuleId::from("no-debug-on-secrets")));
492        assert!(rules.iter().any(|r| r.id == RuleId::from("no-slop-words")));
493
494        validate_unique_ids(&rules).unwrap();
495    }
496
497    #[test]
498    fn load_custom_rules_from_dir() {
499        let dir = tempdir().unwrap();
500        let rule_yaml = r#"
501id: custom-rule
502language: rust
503severity: warning
504message: "Custom rule"
505rule:
506  pattern: $X.clone()
507"#;
508        write(dir.path().join("custom.yml"), rule_yaml).unwrap();
509
510        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
511        assert_eq!(rules.len(), 1);
512        assert_eq!(rules[0].id, RuleId::from("custom-rule"));
513    }
514
515    #[test]
516    fn reject_duplicate_ids_same_language() {
517        let rule1 = parse_rule(
518            r#"
519id: same-id
520language: rust
521severity: error
522message: "First"
523rule:
524  pattern: $X
525"#,
526        )
527        .unwrap();
528        let rule2 = parse_rule(
529            r#"
530id: same-id
531language: rust
532severity: warning
533message: "Second"
534rule:
535  pattern: $Y
536"#,
537        )
538        .unwrap();
539        let err = validate_unique_ids(&[rule1, rule2]).unwrap_err();
540        let msg = err.to_string();
541        assert!(
542            msg.contains("duplicate rule id"),
543            "expected duplicate id error, got: {msg}"
544        );
545        assert!(
546            msg.contains("same-id"),
547            "expected same-id in error, got: {msg}"
548        );
549    }
550
551    #[test]
552    fn allow_same_id_different_language() {
553        let rust_rule = parse_rule(
554            r#"
555id: shared-rule
556language: rust
557severity: error
558message: "Rust variant"
559rule:
560  pattern: $X
561"#,
562        )
563        .unwrap();
564        let ts_rule = parse_rule(
565            r#"
566id: shared-rule
567language: typescript
568severity: error
569message: "TypeScript variant"
570rule:
571  pattern: $X
572"#,
573        )
574        .unwrap();
575        validate_unique_ids(&[rust_rule, ts_rule]).unwrap();
576    }
577
578    #[test]
579    fn category_from_rule_path() {
580        let category = |p: &str| Category::from_rule_path(Path::new(p));
581        assert_eq!(category("slop/no-slop-words.yml"), Some(Category::Slop));
582        assert_eq!(category("security/no-debug.yml"), Some(Category::Security));
583        assert_eq!(
584            category("correctness/no-unwrap.yml"),
585            Some(Category::Correctness)
586        );
587        assert_eq!(category("unknown/something.yml"), None);
588        assert_eq!(category("rule.yml"), None);
589    }
590
591    #[test]
592    fn all_builtin_rules_valid_messages() {
593        for path in BuiltinRules::iter() {
594            if !is_rule_file(Path::new(path.as_ref())) {
595                continue;
596            }
597            let file = BuiltinRules::get(&path).unwrap();
598            let yaml = from_utf8(&file.data).unwrap();
599            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
600            assert!(
601                !rule.message.contains('\u{2014}'),
602                "rule '{}' message contains em dash: {}",
603                rule.id,
604                rule.message
605            );
606            assert!(
607                !rule.message.contains('\u{2013}'),
608                "rule '{}' message contains en dash: {}",
609                rule.id,
610                rule.message
611            );
612            assert!(
613                rule.message.is_ascii() || rule.message.chars().all(|c| c.is_ascii() || c == '\''),
614                "rule '{}' message contains non-ASCII characters: {}",
615                rule.id,
616                rule.message
617            );
618        }
619    }
620
621    #[test]
622    fn all_builtin_rules_have_tests() {
623        for path in BuiltinRules::iter() {
624            if !is_rule_file(Path::new(path.as_ref())) {
625                continue;
626            }
627            let file = BuiltinRules::get(&path).unwrap();
628            let yaml = from_utf8(&file.data).unwrap();
629            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
630            let tests = rule.tests.as_ref().unwrap_or_else(|| {
631                panic!("rule '{}' (at {path}) is missing `tests` block", rule.id)
632            });
633            assert!(
634                tests.should_match.len() >= 2,
635                "rule '{}' (at {path}) needs >= 2 should_match, got {}",
636                rule.id,
637                tests.should_match.len()
638            );
639            assert!(
640                tests.should_not_match.len() >= 2,
641                "rule '{}' (at {path}) needs >= 2 should_not_match, got {}",
642                rule.id,
643                tests.should_not_match.len()
644            );
645        }
646    }
647
648    #[test]
649    fn all_builtin_rules_have_explicit_category() {
650        for path in BuiltinRules::iter() {
651            if !is_rule_file(Path::new(path.as_ref())) {
652                continue;
653            }
654            let file = BuiltinRules::get(&path).unwrap();
655            let yaml = from_utf8(&file.data).unwrap();
656            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
657            assert!(
658                rule.category.is_some(),
659                "rule '{}' (at {path}) is missing an explicit `category` field",
660                rule.id
661            );
662        }
663    }
664
665    #[test]
666    fn custom_rules_category_from_subdir() {
667        let dir = tempdir().unwrap();
668        let security_dir = dir.path().join("security");
669        create_dir(&security_dir).unwrap();
670        let rule_yaml = r#"
671id: custom-sec
672language: rust
673severity: error
674message: "Custom security"
675rule:
676  pattern: $X
677"#;
678        write(security_dir.join("rule.yml"), rule_yaml).unwrap();
679
680        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
681        assert_eq!(rules.len(), 1);
682        assert_eq!(rules[0].category, Some(Category::Security));
683    }
684}