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