alint_rules/
file_content_forbidden.rs1use 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 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}