Skip to main content

slopguard_core/
baseline.rs

1//! Baseline support: capture the findings of an existing codebase once, then
2//! suppress them on later scans so only newly introduced findings are
3//! reported.
4//!
5//! Each baselined finding is identified by a hash of its rule id, its path
6//! relative to the baseline file, its matched text, and a couple of trimmed
7//! context lines. Line and column numbers are deliberately excluded so that
8//! inserting code above a finding does not invalidate the baseline.
9
10#[cfg(test)]
11mod tests;
12
13use std::collections::HashMap;
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19use serde_json::{from_str, to_string_pretty};
20use thiserror::Error;
21
22use crate::cache::sha256_hex;
23use crate::finding::Finding;
24
25/// Name of the baseline file looked up in the working directory and its
26/// parents.
27pub const BASELINE_FILE: &str = ".slopguard-baseline.json";
28
29/// Format version, so a file written by a newer slopguard is rejected cleanly.
30const BASELINE_VERSION: u32 = 1;
31
32/// Number of context lines taken before and after the finding's line to
33/// stabilize the hash without tying it to the line number.
34const CONTEXT_LINES: usize = 2;
35
36/// One baselined finding.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BaselineEntry {
39    /// Stable hash: rule id + relative path + matched text + context lines.
40    pub hash: String,
41    /// Human-readable metadata, present only to keep the git diff reviewable.
42    /// It takes no part in filtering.
43    pub rule_id: String,
44    pub file: String,
45}
46
47/// The on-disk baseline file.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Baseline {
50    pub version: u32,
51    pub findings: Vec<BaselineEntry>,
52}
53
54#[derive(Debug, Error)]
55pub enum BaselineError {
56    #[error("failed to read baseline '{path}': {source}")]
57    Io {
58        path: String,
59        #[source]
60        source: io::Error,
61    },
62    #[error("failed to parse baseline '{path}': {source}")]
63    Parse {
64        path: String,
65        #[source]
66        source: serde_json::Error,
67    },
68    #[error("unsupported baseline version {found} in '{path}' (expected {expected})")]
69    Version {
70        path: String,
71        found: u32,
72        expected: u32,
73    },
74}
75
76/// Normalize a finding's path so that `scan .`, `scan ./src` and
77/// `scan /abs/path` all hash to the same string.
78///
79/// Both sides are canonicalized when possible; otherwise the raw paths are
80/// used. The result always uses `/` separators so a baseline stays valid
81/// across platforms.
82pub fn relative_path(file: &Path, root: &Path) -> String {
83    let relative = match (file.canonicalize(), root.canonicalize()) {
84        (Ok(abs_file), Ok(abs_root)) => abs_file
85            .strip_prefix(&abs_root)
86            .map(Path::to_path_buf)
87            .unwrap_or(abs_file),
88        _ => file.strip_prefix(root).unwrap_or(file).to_path_buf(),
89    };
90    relative.to_string_lossy().replace('\\', "/")
91}
92
93/// The trimmed source lines surrounding `finding`, joined by newlines.
94///
95/// Returns an empty string when the line is out of bounds, so an unreadable or
96/// shortened file never panics and still produces a hash.
97fn context_snippet(lines: &[&str], finding: &Finding) -> String {
98    if finding.line == 0 || finding.line > lines.len() {
99        return String::new();
100    }
101    let index = finding.line - 1;
102    let start = index.saturating_sub(CONTEXT_LINES);
103    let end = (index + CONTEXT_LINES + 1).min(lines.len());
104    lines[start..end]
105        .iter()
106        .map(|line| line.trim())
107        .collect::<Vec<_>>()
108        .join("\n")
109}
110
111/// Stable identity hash of a finding.
112///
113/// `source` is the full content of the finding's file, used for context lines;
114/// pass `None` when it cannot be read. Line and column numbers never take part
115/// in the hash.
116pub fn finding_hash(finding: &Finding, root: &Path, source: Option<&str>) -> String {
117    let context = match source {
118        Some(content) => {
119            let lines: Vec<&str> = content.lines().collect();
120            context_snippet(&lines, finding)
121        }
122        None => String::new(),
123    };
124    let input = format!(
125        "{}\0{}\0{}\0{}",
126        finding.rule_id,
127        relative_path(&finding.file, root),
128        finding.matched_text,
129        context
130    );
131    sha256_hex(input.as_bytes())
132}
133
134/// Hash every finding, reading each source file at most once.
135///
136/// The returned hashes are in the same order as `findings`.
137pub fn hash_findings(findings: &[Finding], root: &Path) -> Vec<String> {
138    let mut contents: HashMap<PathBuf, Option<String>> = HashMap::new();
139    findings
140        .iter()
141        .map(|finding| {
142            let source = contents
143                .entry(finding.file.clone())
144                .or_insert_with(|| fs::read_to_string(&finding.file).ok());
145            finding_hash(finding, root, source.as_deref())
146        })
147        .collect()
148}
149
150/// Build a baseline from a scan result.
151///
152/// Identical findings are not deduplicated: N occurrences produce N entries so
153/// that filtering can use counting semantics. Entries are sorted by
154/// `(file, rule_id, hash)` to keep the file stable between runs.
155pub fn build(findings: &[Finding], root: &Path) -> Baseline {
156    let mut entries: Vec<BaselineEntry> = hash_findings(findings, root)
157        .into_iter()
158        .zip(findings)
159        .map(|(hash, finding)| BaselineEntry {
160            hash,
161            rule_id: finding.rule_id.to_string(),
162            file: relative_path(&finding.file, root),
163        })
164        .collect();
165    entries.sort_by(|a, b| (&a.file, &a.rule_id, &a.hash).cmp(&(&b.file, &b.rule_id, &b.hash)));
166    Baseline {
167        version: BASELINE_VERSION,
168        findings: entries,
169    }
170}
171
172/// Write a baseline as pretty-printed JSON with a trailing newline.
173///
174/// # Errors
175///
176/// Returns [`BaselineError::Io`] if the file cannot be serialized or written.
177pub fn write(baseline: &Baseline, path: &Path) -> Result<(), BaselineError> {
178    let json = to_string_pretty(baseline).map_err(|e| BaselineError::Io {
179        path: path.display().to_string(),
180        source: io::Error::other(e),
181    })?;
182    fs::write(path, format!("{json}\n")).map_err(|e| BaselineError::Io {
183        path: path.display().to_string(),
184        source: e,
185    })
186}
187
188/// Read and validate a baseline file.
189///
190/// # Errors
191///
192/// Returns [`BaselineError::Io`] if the file cannot be read,
193/// [`BaselineError::Parse`] if it is not valid JSON, and
194/// [`BaselineError::Version`] if it was written by an incompatible version.
195pub fn load(path: &Path) -> Result<Baseline, BaselineError> {
196    let content = fs::read_to_string(path).map_err(|e| BaselineError::Io {
197        path: path.display().to_string(),
198        source: e,
199    })?;
200    let baseline: Baseline = from_str(&content).map_err(|e| BaselineError::Parse {
201        path: path.display().to_string(),
202        source: e,
203    })?;
204    if baseline.version != BASELINE_VERSION {
205        return Err(BaselineError::Version {
206            path: path.display().to_string(),
207            found: baseline.version,
208            expected: BASELINE_VERSION,
209        });
210    }
211    Ok(baseline)
212}
213
214/// Walk up from `start` and return the first existing baseline file.
215pub fn find_baseline_file(start: &Path) -> Option<PathBuf> {
216    start.ancestors().find_map(|dir| {
217        let candidate = dir.join(BASELINE_FILE);
218        candidate.is_file().then_some(candidate)
219    })
220}
221
222/// Best guess at the project root: the closest ancestor holding a
223/// `slopguard.toml`, else the closest holding a `.git`, else `start` itself.
224pub fn project_root(start: &Path) -> PathBuf {
225    start
226        .ancestors()
227        .find(|dir| dir.join("slopguard.toml").is_file())
228        .or_else(|| start.ancestors().find(|dir| dir.join(".git").exists()))
229        .map(Path::to_path_buf)
230        .unwrap_or_else(|| start.to_path_buf())
231}
232
233/// Drop the findings already recorded in `baseline` and return the remaining
234/// ones along with how many were filtered.
235///
236/// Matching uses counting rather than set semantics: if the baseline holds 3
237/// identical occurrences and the scan finds 4, the fourth is reported. Baseline
238/// entries matching nothing (the code was fixed) are silently ignored.
239pub fn filter(findings: Vec<Finding>, baseline: &Baseline, root: &Path) -> (Vec<Finding>, usize) {
240    let mut remaining: HashMap<&str, usize> = HashMap::new();
241    for entry in &baseline.findings {
242        *remaining.entry(entry.hash.as_str()).or_insert(0) += 1;
243    }
244
245    let hashes = hash_findings(&findings, root);
246    let mut kept = Vec::new();
247    let mut filtered = 0;
248    for (finding, hash) in findings.into_iter().zip(hashes) {
249        match remaining.get_mut(hash.as_str()) {
250            Some(count) if *count > 0 => {
251                *count -= 1;
252                filtered += 1;
253            }
254            _ => kept.push(finding),
255        }
256    }
257    (kept, filtered)
258}