Skip to main content

alint_rules/
file_content_forbidden.rs

1//! `file_content_forbidden` — files in scope must NOT match a regex.
2
3use std::path::Path;
4
5use alint_core::{Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation};
6use regex::Regex;
7use serde::Deserialize;
8
9#[derive(Debug, Deserialize)]
10#[serde(deny_unknown_fields)]
11struct Options {
12    pattern: String,
13}
14
15#[derive(Debug)]
16pub struct FileContentForbiddenRule {
17    id: String,
18    level: Level,
19    policy_url: Option<String>,
20    message: Option<String>,
21    scope: Scope,
22    pattern_src: String,
23    pattern: Regex,
24}
25
26impl Rule for FileContentForbiddenRule {
27    alint_core::rule_common_impl!();
28
29    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
30        let mut violations = Vec::new();
31        for entry in ctx.index.files() {
32            if !self.scope.matches(&entry.path, ctx.index) {
33                continue;
34            }
35            let full = ctx.root.join(&entry.path);
36            let bytes = match std::fs::read(&full) {
37                Ok(b) => b,
38                Err(e) => {
39                    violations.push(
40                        Violation::new(format!("could not read file: {e}"))
41                            .with_path(entry.path.clone()),
42                    );
43                    continue;
44                }
45            };
46            violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
47        }
48        Ok(violations)
49    }
50
51    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
52        Some(self)
53    }
54}
55
56impl PerFileRule for FileContentForbiddenRule {
57    fn path_scope(&self) -> &Scope {
58        &self.scope
59    }
60
61    fn evaluate_file(
62        &self,
63        _ctx: &Context<'_>,
64        path: &Path,
65        bytes: &[u8],
66    ) -> Result<Vec<Violation>> {
67        // Non-UTF-8 files are silently skipped; they can't contain a
68        // text regex match. Use `file_is_text` to flag binaries.
69        let Ok(text) = std::str::from_utf8(bytes) else {
70            return Ok(Vec::new());
71        };
72        let Some(m) = self.pattern.find(text) else {
73            return Ok(Vec::new());
74        };
75        let line = text[..m.start()].matches('\n').count() + 1;
76        let msg = self
77            .message
78            .clone()
79            .unwrap_or_else(|| format!("forbidden pattern /{}/ found", self.pattern_src));
80        Ok(vec![
81            Violation::new(msg)
82                .with_path(std::sync::Arc::<Path>::from(path))
83                .with_location(line, 1),
84        ])
85    }
86}
87
88pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
89    let Some(_paths) = &spec.paths else {
90        return Err(Error::rule_config(
91            &spec.id,
92            "file_content_forbidden requires a `paths` field",
93        ));
94    };
95    let opts: Options = spec
96        .deserialize_options()
97        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
98    let pattern = Regex::new(&opts.pattern)
99        .map_err(|e| Error::rule_config(&spec.id, format!("invalid pattern: {e}")))?;
100    Ok(Box::new(FileContentForbiddenRule {
101        id: spec.id.clone(),
102        level: spec.level,
103        policy_url: spec.policy_url.clone(),
104        message: spec.message.clone(),
105        scope: Scope::from_spec(spec)?,
106        pattern_src: opts.pattern,
107        pattern,
108    }))
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
115
116    #[test]
117    fn build_rejects_missing_paths_field() {
118        let spec = spec_yaml(
119            "id: t\n\
120             kind: file_content_forbidden\n\
121             pattern: \"X\"\n\
122             level: error\n",
123        );
124        assert!(build(&spec).is_err());
125    }
126
127    #[test]
128    fn build_rejects_invalid_regex() {
129        let spec = spec_yaml(
130            "id: t\n\
131             kind: file_content_forbidden\n\
132             paths: \"**/*\"\n\
133             pattern: \"[bad\"\n\
134             level: error\n",
135        );
136        assert!(build(&spec).is_err());
137    }
138
139    #[test]
140    fn evaluate_fires_on_forbidden_match_with_line_number() {
141        let spec = spec_yaml(
142            "id: t\n\
143             kind: file_content_forbidden\n\
144             paths: \"src/**/*.rs\"\n\
145             pattern: \"\\\\bTODO\\\\b\"\n\
146             level: error\n",
147        );
148        let rule = build(&spec).unwrap();
149        let (tmp, idx) = tempdir_with_files(&[(
150            "src/main.rs",
151            b"fn main() {\n    let x = 1;\n    // TODO\n}\n",
152        )]);
153        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
154        assert_eq!(v.len(), 1);
155        assert_eq!(v[0].line, Some(3), "violation should point at line 3");
156    }
157
158    #[test]
159    fn evaluate_passes_when_pattern_absent() {
160        let spec = spec_yaml(
161            "id: t\n\
162             kind: file_content_forbidden\n\
163             paths: \"src/**/*.rs\"\n\
164             pattern: \"\\\\bTODO\\\\b\"\n\
165             level: error\n",
166        );
167        let rule = build(&spec).unwrap();
168        let (tmp, idx) =
169            tempdir_with_files(&[("src/main.rs", b"fn main() {\n    let x = 1;\n}\n")]);
170        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
171        assert!(v.is_empty(), "clean file should pass: {v:?}");
172    }
173
174    #[test]
175    fn evaluate_silent_on_non_utf8() {
176        let spec = spec_yaml(
177            "id: t\n\
178             kind: file_content_forbidden\n\
179             paths: \"**/*\"\n\
180             pattern: \"X\"\n\
181             level: error\n",
182        );
183        let rule = build(&spec).unwrap();
184        let (tmp, idx) = tempdir_with_files(&[("img.bin", &[0xff, 0xfe])]);
185        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
186        assert!(v.is_empty());
187    }
188}