Skip to main content

sentio_core/
scanner.rs

1use crate::finding::Finding;
2use crate::rules::{convert_severity, RuleContext, RuleRegistry, SuppressionSet};
3use crate::syntax::{parse_rust_files, ParseFailure, ParsedFile, SyntaxReport};
4use serde::Serialize;
5use std::path::{Path, PathBuf};
6use walkdir::WalkDir;
7
8#[derive(Debug, Clone, Default)]
9pub struct ScanOptions {
10    pub include_tests: bool,
11    pub rule_filter: Option<String>,
12}
13
14#[derive(Debug, Clone, Default, Serialize)]
15pub struct ScanResult {
16    pub findings: Vec<Finding>,
17    pub files_scanned: usize,
18    pub files_parsed: usize,
19    pub parse_failures: Vec<ParseFailure>,
20}
21
22#[derive(Default)]
23pub struct Scanner {
24    rules: RuleRegistry,
25}
26
27impl Scanner {
28    pub fn new() -> Self {
29        Self {
30            rules: RuleRegistry::baseline(),
31        }
32    }
33
34    pub fn scan_path(&self, path: &str, options: &ScanOptions) -> ScanResult {
35        let file_paths: Vec<PathBuf> = discover_rust_files(path, options).collect();
36        let files_scanned = file_paths.len();
37        let syntax_report = parse_rust_files(file_paths);
38        self.scan_report(files_scanned, syntax_report, options)
39    }
40
41    pub fn scan_report(
42        &self,
43        files_scanned: usize,
44        report: SyntaxReport,
45        options: &ScanOptions,
46    ) -> ScanResult {
47        let files_parsed = report.files.len();
48        let findings = self.run_rules(&report.files, options);
49        let parse_failures = report.parse_failures;
50
51        ScanResult {
52            findings,
53            files_scanned,
54            files_parsed,
55            parse_failures,
56        }
57    }
58
59    fn run_rules(&self, files: &[ParsedFile], options: &ScanOptions) -> Vec<Finding> {
60        let ctx = RuleContext { files };
61        let suppressions: Vec<(String, SuppressionSet)> = files
62            .iter()
63            .map(|file| (file.path.display().to_string(), SuppressionSet::from_source(&file.source)))
64            .collect();
65
66        let mut findings = Vec::new();
67        for file in files {
68            for rule in self.rules.matching_rules(options.rule_filter.as_deref()) {
69                for matched in rule.match_file(file, &ctx) {
70                    let finding = Finding {
71                        rule_id: matched.rule_id.to_string(),
72                        severity: convert_severity(matched.severity),
73                        message: matched.message,
74                        location: matched.location,
75                        help: matched.help,
76                        suppressed: false,
77                    };
78
79                    if is_suppressed(&finding, &suppressions) {
80                        continue;
81                    }
82
83                    findings.push(finding);
84                }
85            }
86        }
87
88        findings
89    }
90}
91
92fn is_suppressed(finding: &Finding, suppressions: &[(String, SuppressionSet)]) -> bool {
93    suppressions
94        .iter()
95        .find(|(path, _)| path == &finding.location.path)
96        .is_some_and(|(_, set)| set.is_suppressed(finding))
97}
98
99fn discover_rust_files<'a>(
100    path: &'a str,
101    options: &'a ScanOptions,
102) -> impl Iterator<Item = PathBuf> + 'a {
103    WalkDir::new(path)
104        .into_iter()
105        .filter_map(Result::ok)
106        .filter(|entry| entry.file_type().is_file())
107        .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("rs"))
108        .filter(|entry| !is_excluded_path(entry.path()))
109        .filter(move |entry| options.include_tests || !is_test_path(entry.path()))
110        .map(|entry| entry.into_path())
111}
112
113fn is_excluded_path(path: &Path) -> bool {
114    path.components().any(|component| {
115        let part = component.as_os_str().to_string_lossy();
116        matches!(part.as_ref(), "target" | ".git")
117    })
118}
119
120fn is_test_path(path: &Path) -> bool {
121    path.components().any(|component| {
122        let part = component.as_os_str().to_string_lossy();
123        matches!(part.as_ref(), "tests" | "test" | "fixtures")
124    })
125}