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