1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4use std::ops::Range;
5
6#[derive(Debug)]
11pub struct BannedPatternRule {
12 id: String,
13 severity: Severity,
14 message: String,
15 suggest: Option<String>,
16 glob: Option<String>,
17 pattern: String,
18 compiled_regex: Option<Regex>,
19 skip_strings: bool,
20}
21
22impl BannedPatternRule {
23 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
24 let pattern = config
25 .pattern
26 .as_ref()
27 .filter(|p| !p.is_empty())
28 .ok_or_else(|| RuleBuildError::MissingField(config.id.clone(), "pattern"))?
29 .clone();
30
31 let compiled_regex = if config.regex {
32 let re = Regex::new(&pattern)
33 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
34 Some(re)
35 } else {
36 None
37 };
38
39 Ok(Self {
40 id: config.id.clone(),
41 severity: config.severity,
42 message: config.message.clone(),
43 suggest: config.suggest.clone(),
44 glob: config.glob.clone(),
45 pattern,
46 compiled_regex,
47 skip_strings: config.skip_strings,
48 })
49 }
50}
51
52impl Rule for BannedPatternRule {
53 fn id(&self) -> &str {
54 &self.id
55 }
56
57 fn severity(&self) -> Severity {
58 self.severity
59 }
60
61 fn file_glob(&self) -> Option<&str> {
62 self.glob.as_deref()
63 }
64
65 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
66 let mut violations = Vec::new();
67
68let line_offsets: Vec<usize> = if self.skip_strings {
69 std::iter::once(0)
70 .chain(ctx.content.match_indices('\n').map(|(i, _)| i + 1))
71 .collect()
72 } else {
73 Vec::new()
74 };
75
76 for (line_idx, line) in ctx.content.lines().enumerate() {
77 if let Some(ref re) = self.compiled_regex {
78 for m in re.find_iter(line) {
80 violations.push(Violation {
81 rule_id: self.id.clone(),
82 severity: self.severity,
83 file: ctx.file_path.to_path_buf(),
84 line: Some(line_idx + 1),
85 column: Some(m.start() + 1),
86 message: self.message.clone(),
87 suggest: self.suggest.clone(),
88 source_line: Some(line.to_string()),
89 fix: None,
90 });
91 }
92 } else {
93 let pat = self.pattern.as_str();
95 let pat_len = pat.len();
96 let mut search_start = 0;
97 while let Some(pos) = line[search_start..].find(pat) {
98 let col = search_start + pos;
99 violations.push(Violation {
100 rule_id: self.id.clone(),
101 severity: self.severity,
102 file: ctx.file_path.to_path_buf(),
103 line: Some(line_idx + 1),
104 column: Some(col + 1),
105 message: self.message.clone(),
106 suggest: self.suggest.clone(),
107 source_line: Some(line.to_string()),
108 fix: None,
109 });
110 search_start = col + pat_len;
111 }
112 }
113 }
114
115if self.skip_strings {
116 if let Some(tree) = crate::rules::ast::parse_file(ctx.file_path, ctx.content) {
117 let string_ranges = collect_string_ranges(&tree, ctx.content);
118 violations.retain(|v| {
119 let byte_offset = match (v.line, v.column) {
120 (Some(line), Some(col)) => line_offsets[line - 1] + (col - 1),
121 _ => return true,
122 };
123 !string_ranges
124 .iter()
125 .any(|range: &Range<usize>| range.contains(&byte_offset))
126 });
127 }
128 }
129
130 violations
131 }
132}
133
134fn collect_string_ranges(tree: &tree_sitter::Tree, _source: &str) -> Vec<Range<usize>> {
136 let mut ranges = Vec::new();
137 let mut cursor = tree.walk();
138 collect_string_ranges_recursive(&mut cursor, &mut ranges);
139 ranges
140}
141
142fn collect_string_ranges_recursive(
143 cursor: &mut tree_sitter::TreeCursor,
144 ranges: &mut Vec<Range<usize>>,
145) {
146 loop {
147 let node = cursor.node();
148 let kind = node.kind();
149 if kind == "string" || kind == "template_string" {
150 ranges.push(node.start_byte()..node.end_byte());
151 } else if cursor.goto_first_child() {
152 collect_string_ranges_recursive(cursor, ranges);
153 cursor.goto_parent();
154 }
155 if !cursor.goto_next_sibling() {
156 break;
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use std::path::Path;
165
166 fn make_config(pattern: &str, regex: bool) -> RuleConfig {
167 RuleConfig {
168 id: "test-banned-pattern".into(),
169 severity: Severity::Warning,
170 message: "banned pattern found".into(),
171 suggest: Some("remove this pattern".into()),
172 pattern: Some(pattern.to_string()),
173 regex,
174 ..Default::default()
175 }
176 }
177
178 fn check(rule: &BannedPatternRule, content: &str) -> Vec<Violation> {
179 let ctx = ScanContext {
180 file_path: Path::new("test.tsx"),
181 content,
182 };
183 rule.check_file(&ctx)
184 }
185
186 #[test]
187 fn literal_match() {
188 let config = make_config("style={{", false);
189 let rule = BannedPatternRule::new(&config).unwrap();
190 let violations = check(&rule, r#"<div style={{ color: "red" }}>"#);
191 assert_eq!(violations.len(), 1);
192 assert_eq!(violations[0].line, Some(1));
193 assert_eq!(violations[0].column, Some(6));
194 }
195
196 #[test]
197 fn literal_multiple_matches_per_line() {
198 let config = make_config("TODO", false);
199 let rule = BannedPatternRule::new(&config).unwrap();
200 let violations = check(&rule, "// TODO fix this TODO");
201 assert_eq!(violations.len(), 2);
202 assert_eq!(violations[0].column, Some(4));
203 assert_eq!(violations[1].column, Some(18));
204 }
205
206 #[test]
207 fn literal_no_match() {
208 let config = make_config("style={{", false);
209 let rule = BannedPatternRule::new(&config).unwrap();
210 let violations = check(&rule, r#"<div className="bg-white">"#);
211 assert!(violations.is_empty());
212 }
213
214 #[test]
215 fn literal_multiline() {
216 let config = make_config("console.log(", false);
217 let rule = BannedPatternRule::new(&config).unwrap();
218 let content = "const x = 1;\nconsole.log(x);\nconst y = 2;";
219 let violations = check(&rule, content);
220 assert_eq!(violations.len(), 1);
221 assert_eq!(violations[0].line, Some(2));
222 assert_eq!(violations[0].column, Some(1));
223 }
224
225 #[test]
226 fn regex_match() {
227 let config = make_config(r"console\.(log|debug)\(", true);
228 let rule = BannedPatternRule::new(&config).unwrap();
229 let content = "console.log('hi');\nconsole.debug('x');\nconsole.error('e');";
230 let violations = check(&rule, content);
231 assert_eq!(violations.len(), 2);
232 assert_eq!(violations[0].line, Some(1));
233 assert_eq!(violations[1].line, Some(2));
234 }
235
236 #[test]
237 fn regex_no_match() {
238 let config = make_config(r"console\.log\(", true);
239 let rule = BannedPatternRule::new(&config).unwrap();
240 let violations = check(&rule, "console.error('e');");
241 assert!(violations.is_empty());
242 }
243
244 #[test]
245 fn invalid_regex_error() {
246 let config = make_config(r"(unclosed", true);
247 let err = BannedPatternRule::new(&config).unwrap_err();
248 assert!(matches!(err, RuleBuildError::InvalidRegex(_, _)));
249 }
250
251 #[test]
252 fn missing_pattern_error() {
253 let config = RuleConfig {
254 id: "test".into(),
255 severity: Severity::Warning,
256 message: "test".into(),
257 ..Default::default()
258 };
259 let err = BannedPatternRule::new(&config).unwrap_err();
260 assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
261 }
262
263 #[test]
264 fn empty_pattern_error() {
265 let config = make_config("", false);
266 let err = BannedPatternRule::new(&config).unwrap_err();
267 assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
268 }
269
270 #[test]
271 fn violation_metadata() {
272 let config = make_config("style={{", false);
273 let rule = BannedPatternRule::new(&config).unwrap();
274 let violations = check(&rule, r#"<div style={{ color: "red" }}>"#);
275 assert_eq!(violations[0].rule_id, "test-banned-pattern");
276 assert_eq!(violations[0].severity, Severity::Warning);
277 assert_eq!(violations[0].message, "banned pattern found");
278 assert_eq!(violations[0].suggest.as_deref(), Some("remove this pattern"));
279 assert!(violations[0].source_line.is_some());
280 }
281
282 mod skip_strings {
283 use super::*;
284
285 fn make_skip_config(pattern: &str, regex: bool, skip_strings: bool) -> RuleConfig {
286 RuleConfig {
287 id: "test-skip-strings".into(),
288 severity: Severity::Warning,
289 message: "banned pattern found".into(),
290 pattern: Some(pattern.to_string()),
291 regex,
292 skip_strings,
293 ..Default::default()
294 }
295 }
296
297 #[test]
298 fn skip_strings_inside_template_literal() {
299 let config = make_skip_config("process.env", false, true);
300 let rule = BannedPatternRule::new(&config).unwrap();
301 let content = "const docs = `Use process.env.SECRET for config`;";
302 let ctx = ScanContext {
303 file_path: Path::new("test.tsx"),
304 content,
305 };
306 let violations = rule.check_file(&ctx);
307 assert!(violations.is_empty());
308 }
309
310 #[test]
311 fn skip_strings_outside_template_literal() {
312 let config = make_skip_config("process.env", false, true);
313 let rule = BannedPatternRule::new(&config).unwrap();
314 let content = "const val = process.env.SECRET;";
315 let ctx = ScanContext {
316 file_path: Path::new("test.tsx"),
317 content,
318 };
319 let violations = rule.check_file(&ctx);
320 assert_eq!(violations.len(), 1);
321 }
322
323 #[test]
324 fn skip_strings_inside_regular_string() {
325 let config = make_skip_config("process.env", false, true);
326 let rule = BannedPatternRule::new(&config).unwrap();
327 let content = r#"const msg = "Use process.env.SECRET";"#;
328 let ctx = ScanContext {
329 file_path: Path::new("test.tsx"),
330 content,
331 };
332 let violations = rule.check_file(&ctx);
333 assert!(violations.is_empty());
334 }
335
336 #[test]
337 fn skip_strings_false_still_flags() {
338 let config = make_skip_config("process.env", false, false);
339 let rule = BannedPatternRule::new(&config).unwrap();
340 let content = "const docs = `Use process.env.SECRET for config`;";
341 let ctx = ScanContext {
342 file_path: Path::new("test.tsx"),
343 content,
344 };
345 let violations = rule.check_file(&ctx);
346 assert_eq!(violations.len(), 1);
347 }
348 }
349}