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