Skip to main content

cc_audit/scanner/
common.rs

1use crate::error::{AuditError, Result};
2use crate::ignore::IgnoreFilter;
3use crate::rules::{DynamicRule, Finding, RuleEngine};
4use std::fs;
5use std::path::Path;
6use tracing::{debug, trace};
7
8/// Common configuration shared by all scanners.
9///
10/// This struct provides a unified way to manage RuleEngine settings,
11/// ignore filters, and common file operations across different scanner implementations.
12pub struct ScannerConfig {
13    engine: RuleEngine,
14    ignore_filter: Option<IgnoreFilter>,
15    skip_comments: bool,
16}
17
18impl ScannerConfig {
19    /// Creates a new ScannerConfig with default settings.
20    pub fn new() -> Self {
21        Self {
22            engine: RuleEngine::new(),
23            ignore_filter: None,
24            skip_comments: false,
25        }
26    }
27
28    /// Enables or disables comment skipping during scanning.
29    pub fn with_skip_comments(mut self, skip: bool) -> Self {
30        self.skip_comments = skip;
31        self.engine = RuleEngine::new().with_skip_comments(skip);
32        self
33    }
34
35    /// Sets an ignore filter for file filtering.
36    pub fn with_ignore_filter(mut self, filter: IgnoreFilter) -> Self {
37        self.ignore_filter = Some(filter);
38        self
39    }
40
41    /// Adds dynamic rules loaded from custom YAML files.
42    pub fn with_dynamic_rules(mut self, rules: Vec<DynamicRule>) -> Self {
43        self.engine = self.engine.with_dynamic_rules(rules);
44        self
45    }
46
47    /// Returns whether the given path should be ignored.
48    pub fn is_ignored(&self, path: &Path) -> bool {
49        self.ignore_filter
50            .as_ref()
51            .is_some_and(|f| f.is_ignored(path))
52    }
53
54    /// Reads a file and returns its content as a string.
55    pub fn read_file(&self, path: &Path) -> Result<String> {
56        trace!(path = %path.display(), "Reading file");
57        fs::read_to_string(path).map_err(|e| {
58            debug!(path = %path.display(), error = %e, "Failed to read file");
59            AuditError::ReadError {
60                path: path.display().to_string(),
61                source: e,
62            }
63        })
64    }
65
66    /// Checks the content against all rules and returns findings.
67    pub fn check_content(&self, content: &str, file_path: &str) -> Vec<Finding> {
68        trace!(
69            file = file_path,
70            content_len = content.len(),
71            "Checking content"
72        );
73        let findings = self.engine.check_content(content, file_path);
74        if !findings.is_empty() {
75            debug!(file = file_path, count = findings.len(), "Found issues");
76        }
77        findings
78    }
79
80    /// Checks YAML frontmatter for specific rules (e.g., OP-001).
81    pub fn check_frontmatter(&self, frontmatter: &str, file_path: &str) -> Vec<Finding> {
82        self.engine.check_frontmatter(frontmatter, file_path)
83    }
84
85    /// Returns whether skip_comments is enabled.
86    pub fn skip_comments(&self) -> bool {
87        self.skip_comments
88    }
89
90    /// Returns a reference to the underlying RuleEngine.
91    pub fn engine(&self) -> &RuleEngine {
92        &self.engine
93    }
94}
95
96impl Default for ScannerConfig {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use tempfile::TempDir;
106
107    #[test]
108    fn test_new_config() {
109        let config = ScannerConfig::new();
110        assert!(!config.skip_comments());
111    }
112
113    #[test]
114    fn test_with_skip_comments() {
115        let config = ScannerConfig::new().with_skip_comments(true);
116        assert!(config.skip_comments());
117    }
118
119    #[test]
120    fn test_default_config() {
121        let config = ScannerConfig::default();
122        assert!(!config.skip_comments());
123    }
124
125    #[test]
126    fn test_is_ignored_without_filter() {
127        let config = ScannerConfig::new();
128        assert!(!config.is_ignored(Path::new("test.rs")));
129    }
130
131    #[test]
132    fn test_read_file_success() {
133        let dir = TempDir::new().unwrap();
134        let file_path = dir.path().join("test.txt");
135        fs::write(&file_path, "test content").unwrap();
136
137        let config = ScannerConfig::new();
138        let content = config.read_file(&file_path).unwrap();
139        assert_eq!(content, "test content");
140    }
141
142    #[test]
143    fn test_read_file_not_found() {
144        let config = ScannerConfig::new();
145        let result = config.read_file(Path::new("/nonexistent/file.txt"));
146        assert!(result.is_err());
147    }
148
149    #[test]
150    fn test_check_content_detects_sudo() {
151        let config = ScannerConfig::new();
152        let findings = config.check_content("sudo rm -rf /", "test.sh");
153        assert!(findings.iter().any(|f| f.id == "PE-001"));
154    }
155
156    #[test]
157    fn test_check_content_skip_comments() {
158        let config = ScannerConfig::new().with_skip_comments(true);
159        let findings = config.check_content("# sudo rm -rf /", "test.sh");
160        assert!(findings.iter().all(|f| f.id != "PE-001"));
161    }
162
163    #[test]
164    fn test_check_frontmatter_wildcard() {
165        let config = ScannerConfig::new();
166        let findings = config.check_frontmatter("allowed-tools: *", "SKILL.md");
167        assert!(findings.iter().any(|f| f.id == "OP-001"));
168    }
169
170    #[test]
171    fn test_engine_accessor() {
172        let config = ScannerConfig::new();
173        let _engine = config.engine();
174    }
175}