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