cc_audit/engine/
scanner.rs1use crate::error::{AuditError, Result};
9use crate::ignore::IgnoreFilter;
10use crate::rules::{DynamicRule, Finding, RuleEngine};
11use std::fs;
12use std::path::Path;
13use tracing::{debug, trace};
14
15pub trait Scanner {
21 fn scan_file(&self, path: &Path) -> Result<Vec<Finding>>;
23
24 fn scan_directory(&self, dir: &Path) -> Result<Vec<Finding>>;
26
27 fn scan_path(&self, path: &Path) -> Result<Vec<Finding>> {
32 trace!(path = %path.display(), "Scanning path");
33
34 if !path.exists() {
35 debug!(path = %path.display(), "Path not found");
36 return Err(AuditError::FileNotFound(path.display().to_string()));
37 }
38
39 if path.is_file() {
40 trace!(path = %path.display(), "Scanning as file");
41 return self.scan_file(path);
42 }
43
44 if !path.is_dir() {
45 debug!(path = %path.display(), "Path is not a directory");
46 return Err(AuditError::NotADirectory(path.display().to_string()));
47 }
48
49 trace!(path = %path.display(), "Scanning as directory");
50 self.scan_directory(path)
51 }
52}
53
54pub trait ContentScanner: Scanner {
60 fn config(&self) -> &ScannerConfig;
62
63 fn scan_content(&self, content: &str, file_path: &str) -> Result<Vec<Finding>> {
69 Ok(self.config().check_content(content, file_path))
70 }
71}
72
73pub type ProgressCallback = std::sync::Arc<dyn Fn() + Send + Sync>;
77
78pub struct ScannerConfig {
83 engine: RuleEngine,
84 ignore_filter: Option<IgnoreFilter>,
85 skip_comments: bool,
86 strict_secrets: bool,
87 recursive: bool,
88 progress_callback: Option<ProgressCallback>,
89}
90
91impl ScannerConfig {
92 pub fn new() -> Self {
94 Self {
95 engine: RuleEngine::new(),
96 ignore_filter: None,
97 skip_comments: false,
98 strict_secrets: false,
99 recursive: true,
100 progress_callback: None,
101 }
102 }
103
104 pub fn with_recursive(mut self, recursive: bool) -> Self {
107 self.recursive = recursive;
108 self
109 }
110
111 pub fn is_recursive(&self) -> bool {
113 self.recursive
114 }
115
116 pub fn max_depth(&self) -> Option<usize> {
120 if self.recursive { None } else { Some(3) }
121 }
122
123 pub fn with_skip_comments(mut self, skip: bool) -> Self {
125 self.skip_comments = skip;
126 self.engine = self.engine.with_skip_comments(skip);
127 self
128 }
129
130 pub fn with_strict_secrets(mut self, strict: bool) -> Self {
133 self.strict_secrets = strict;
134 self.engine = self.engine.with_strict_secrets(strict);
135 self
136 }
137
138 pub fn with_ignore_filter(mut self, filter: IgnoreFilter) -> Self {
140 self.ignore_filter = Some(filter);
141 self
142 }
143
144 pub fn with_dynamic_rules(mut self, rules: Vec<DynamicRule>) -> Self {
146 self.engine = self.engine.with_dynamic_rules(rules);
147 self
148 }
149
150 pub fn with_progress_callback(mut self, callback: ProgressCallback) -> Self {
152 self.progress_callback = Some(callback);
153 self
154 }
155
156 pub fn report_progress(&self) {
159 if let Some(ref callback) = self.progress_callback {
160 callback();
161 }
162 }
163
164 pub fn is_ignored(&self, path: &Path) -> bool {
166 self.ignore_filter
167 .as_ref()
168 .is_some_and(|f| f.is_ignored(path))
169 }
170
171 pub fn ignore_filter(&self) -> Option<&IgnoreFilter> {
173 self.ignore_filter.as_ref()
174 }
175
176 pub fn read_file(&self, path: &Path) -> Result<String> {
178 trace!(path = %path.display(), "Reading file");
179 fs::read_to_string(path).map_err(|e| {
180 debug!(path = %path.display(), error = %e, "Failed to read file");
181 AuditError::ReadError {
182 path: path.display().to_string(),
183 source: e,
184 }
185 })
186 }
187
188 pub fn check_content(&self, content: &str, file_path: &str) -> Vec<Finding> {
190 trace!(
191 file = file_path,
192 content_len = content.len(),
193 "Checking content"
194 );
195 let findings = self.engine.check_content(content, file_path);
196 if !findings.is_empty() {
197 debug!(file = file_path, count = findings.len(), "Found issues");
198 }
199 findings
200 }
201
202 pub fn check_frontmatter(&self, frontmatter: &str, file_path: &str) -> Vec<Finding> {
204 self.engine.check_frontmatter(frontmatter, file_path)
205 }
206
207 pub fn skip_comments(&self) -> bool {
209 self.skip_comments
210 }
211
212 pub fn strict_secrets(&self) -> bool {
214 self.strict_secrets
215 }
216
217 pub fn engine(&self) -> &RuleEngine {
219 &self.engine
220 }
221}
222
223impl Default for ScannerConfig {
224 fn default() -> Self {
225 Self::new()
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::sync::Arc;
233 use tempfile::TempDir;
234
235 #[test]
236 fn test_new_config() {
237 let config = ScannerConfig::new();
238 assert!(!config.skip_comments());
239 }
240
241 #[test]
242 fn test_progress_callback_is_called() {
243 use std::sync::Mutex;
244 let call_count = Arc::new(Mutex::new(0));
246 let call_count_clone = Arc::clone(&call_count);
247
248 let progress_fn = move || {
249 let mut count = call_count_clone.lock().unwrap();
250 *count += 1;
251 };
252
253 let config = ScannerConfig::new().with_progress_callback(Arc::new(progress_fn));
254
255 config.report_progress();
257 config.report_progress();
258
259 let final_count = *call_count.lock().unwrap();
260 assert_eq!(final_count, 2, "Progress callback should be called twice");
261 }
262
263 #[test]
264 fn test_with_skip_comments() {
265 let config = ScannerConfig::new().with_skip_comments(true);
266 assert!(config.skip_comments());
267 }
268
269 #[test]
270 fn test_default_config() {
271 let config = ScannerConfig::default();
272 assert!(!config.skip_comments());
273 }
274
275 #[test]
276 fn test_is_ignored_without_filter() {
277 let config = ScannerConfig::new();
278 assert!(!config.is_ignored(Path::new("test.rs")));
279 }
280
281 #[test]
282 fn test_read_file_success() {
283 let dir = TempDir::new().unwrap();
284 let file_path = dir.path().join("test.txt");
285 fs::write(&file_path, "test content").unwrap();
286
287 let config = ScannerConfig::new();
288 let content = config.read_file(&file_path).unwrap();
289 assert_eq!(content, "test content");
290 }
291
292 #[test]
293 fn test_read_file_not_found() {
294 let config = ScannerConfig::new();
295 let result = config.read_file(Path::new("/nonexistent/file.txt"));
296 assert!(result.is_err());
297 }
298
299 #[test]
300 fn test_check_content_detects_sudo() {
301 let config = ScannerConfig::new();
302 let findings = config.check_content("sudo rm -rf /", "test.sh");
303 assert!(findings.iter().any(|f| f.id == "PE-001"));
304 }
305
306 #[test]
307 fn test_check_content_skip_comments() {
308 let config = ScannerConfig::new().with_skip_comments(true);
309 let findings = config.check_content("# sudo rm -rf /", "test.sh");
310 assert!(findings.iter().all(|f| f.id != "PE-001"));
311 }
312
313 #[test]
314 fn test_check_frontmatter_wildcard() {
315 let config = ScannerConfig::new();
316 let findings = config.check_frontmatter("allowed-tools: *", "SKILL.md");
317 assert!(findings.iter().any(|f| f.id == "OP-001"));
318 }
319
320 #[test]
321 fn test_engine_accessor() {
322 let config = ScannerConfig::new();
323 let _engine = config.engine();
324 }
325}