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)]
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 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}