Skip to main content

slopguard_core/
cache.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use sha2::{Digest, Sha256};
6use thiserror::Error;
7
8use crate::finding::Finding;
9use crate::rule::Rule;
10
11const RULES_HASH_FILE: &str = "rules.hash";
12const CACHE_GITIGNORE: &str = "*\n";
13
14/// Hex-encoded SHA256 of `data`. Shared by every cache key in the workspace.
15pub fn sha256_hex(data: &[u8]) -> String {
16    let mut hasher = Sha256::new();
17    hasher.update(data);
18    format!("{:x}", hasher.finalize())
19}
20
21/// Create `dir` (and parents) if missing and drop a `.gitignore` that ignores
22/// the entire cache directory. Idempotent.
23///
24/// # Errors
25///
26/// Returns an [`io::Error`] if the directory or `.gitignore` cannot be written.
27pub fn ensure_gitignored_dir(dir: &Path) -> Result<(), io::Error> {
28    if !dir.exists() {
29        fs::create_dir_all(dir)?;
30        fs::write(dir.join(".gitignore"), CACHE_GITIGNORE)?;
31    }
32    Ok(())
33}
34
35#[derive(Debug, Error)]
36pub enum CacheError {
37    #[error("cache I/O error: {0}")]
38    Io(#[from] io::Error),
39}
40
41/// Compute a stable hash of the active rules by sorting their ids and hashing
42/// the concatenated id list together with each rule's YAML-serialized body.
43pub fn rules_hash(rules: &[Rule]) -> String {
44    let mut ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
45    ids.sort();
46
47    let mut hasher = Sha256::new();
48    for id in &ids {
49        hasher.update(id.as_bytes());
50        hasher.update(b"\0");
51    }
52    format!("{:x}", hasher.finalize())
53}
54
55/// Compute the SHA256 hash of a file's content.
56pub fn file_content_hash(content: &[u8]) -> String {
57    sha256_hex(content)
58}
59
60/// A file-based cache store for scan findings.
61pub struct CacheStore {
62    dir: PathBuf,
63}
64
65impl CacheStore {
66    pub fn new(project_root: &Path) -> Self {
67        Self {
68            dir: project_root.join(".slopguard-cache"),
69        }
70    }
71
72    /// Create a cache store that writes directly to the given directory
73    /// instead of appending `.slopguard-cache`.
74    pub fn with_dir(dir: PathBuf) -> Self {
75        Self { dir }
76    }
77
78    pub fn dir(&self) -> &Path {
79        &self.dir
80    }
81
82    fn ensure_dir(&self) -> Result<(), CacheError> {
83        ensure_gitignored_dir(&self.dir)?;
84        Ok(())
85    }
86
87    fn stored_rules_hash(&self) -> Option<String> {
88        fs::read_to_string(self.dir.join(RULES_HASH_FILE)).ok()
89    }
90
91    fn write_rules_hash(&self, hash: &str) -> Result<(), CacheError> {
92        self.ensure_dir()?;
93        fs::write(self.dir.join(RULES_HASH_FILE), hash)?;
94        Ok(())
95    }
96
97    /// Check if the rules have changed since the last scan. If they have,
98    /// invalidate the entire cache.
99    pub fn check_rules_changed(&self, current_hash: &str) -> Result<bool, CacheError> {
100        match self.stored_rules_hash() {
101            Some(stored) if stored == current_hash => Ok(false),
102            _ => {
103                self.invalidate_all()?;
104                self.write_rules_hash(current_hash)?;
105                Ok(true)
106            }
107        }
108    }
109
110    fn invalidate_all(&self) -> Result<(), CacheError> {
111        if self.dir.exists() {
112            for entry in fs::read_dir(&self.dir)? {
113                let entry = entry?;
114                let name = entry.file_name();
115                let name_str = name.to_string_lossy();
116                if name_str != ".gitignore" && name_str != RULES_HASH_FILE {
117                    fs::remove_file(entry.path())?;
118                }
119            }
120        }
121        Ok(())
122    }
123
124    fn entry_path(&self, file_hash: &str) -> PathBuf {
125        self.dir.join(format!("{file_hash}.bin"))
126    }
127
128    /// Look up cached findings for a file by its content hash.
129    pub fn get(&self, file_hash: &str) -> Option<Vec<Finding>> {
130        let path = self.entry_path(file_hash);
131        let data = fs::read(&path).ok()?;
132        rmp_serde::from_slice(&data).ok()
133    }
134
135    /// Store findings for a file identified by its content hash.
136    pub fn put(&self, file_hash: &str, findings: &[Finding]) -> Result<(), CacheError> {
137        self.ensure_dir()?;
138        let data = rmp_serde::to_vec_named(findings).map_err(io::Error::other)?;
139        fs::write(self.entry_path(file_hash), data)?;
140        Ok(())
141    }
142
143    /// Remove cache entries whose hash no longer appears in the set of
144    /// current file hashes.
145    pub fn cleanup(&self, current_hashes: &[String]) -> Result<usize, CacheError> {
146        if !self.dir.exists() {
147            return Ok(0);
148        }
149        let mut removed = 0;
150        for entry in fs::read_dir(&self.dir)? {
151            let entry = entry?;
152            let name = entry.file_name();
153            let name_str = name.to_string_lossy();
154            if name_str == ".gitignore" || name_str == RULES_HASH_FILE {
155                continue;
156            }
157            let hash = name_str.trim_end_matches(".bin");
158            if !current_hashes.iter().any(|h| h == hash) {
159                fs::remove_file(entry.path())?;
160                removed += 1;
161            }
162        }
163        Ok(removed)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use std::path::PathBuf;
170    use std::slice;
171
172    use tempfile::tempdir;
173
174    use super::*;
175    use crate::rule::{parse_rule, RuleId, Severity};
176
177    fn test_rule() -> Rule {
178        parse_rule(
179            r#"
180id: test-unwrap
181language: rust
182severity: error
183category: correctness
184message: ".unwrap() forbidden"
185rule:
186  kind: call_expression
187  regex: '\.unwrap\(\)\s*$'
188"#,
189        )
190        .unwrap()
191    }
192
193    fn sample_finding() -> Finding {
194        Finding {
195            rule_id: RuleId::from("test-unwrap"),
196            severity: Severity::Error,
197            category: crate::rule::Category::Correctness,
198            message: ".unwrap() forbidden".to_string(),
199            note: None,
200            fix: None,
201            file: PathBuf::from("src/main.rs"),
202            line: 2,
203            column: 5,
204            end_line: 2,
205            end_column: 20,
206            matched_text: "foo().unwrap()".to_string(),
207            confidence: None,
208        }
209    }
210
211    #[test]
212    fn cache_miss() {
213        let dir = tempdir().unwrap();
214        let store = CacheStore::new(dir.path());
215        let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
216
217        assert!(store.get(&hash).is_none());
218    }
219
220    #[test]
221    fn cache_hit() {
222        let dir = tempdir().unwrap();
223        let store = CacheStore::new(dir.path());
224        let content = b"fn main() { foo().unwrap(); }";
225        let hash = file_content_hash(content);
226        let findings = vec![sample_finding()];
227
228        store.put(&hash, &findings).unwrap();
229        let cached = store.get(&hash).unwrap();
230
231        assert_eq!(cached.len(), 1);
232        assert_eq!(cached[0].rule_id, RuleId::from("test-unwrap"));
233        assert_eq!(cached[0].line, 2);
234        assert_eq!(cached[0].matched_text, "foo().unwrap()");
235    }
236
237    #[test]
238    fn invalidation_file_change() {
239        let dir = tempdir().unwrap();
240        let store = CacheStore::new(dir.path());
241
242        let content_v1 = b"fn main() { foo().unwrap(); }";
243        let content_v2 = b"fn main() { foo()?; }";
244        let hash_v1 = file_content_hash(content_v1);
245        let hash_v2 = file_content_hash(content_v2);
246
247        store.put(&hash_v1, &[sample_finding()]).unwrap();
248
249        assert!(store.get(&hash_v1).is_some());
250        assert!(store.get(&hash_v2).is_none());
251    }
252
253    #[test]
254    fn invalidation_rule_change() {
255        let dir = tempdir().unwrap();
256        let store = CacheStore::new(dir.path());
257
258        let rules_v1 = [test_rule()];
259        let hash_v1 = rules_hash(&rules_v1);
260
261        let content_hash = file_content_hash(b"fn main() {}");
262        store.put(&content_hash, &[]).unwrap();
263        store.write_rules_hash(&hash_v1).unwrap();
264
265        assert!(store.get(&content_hash).is_some());
266
267        let mut rule_v2 = test_rule();
268        rule_v2.id = RuleId::from("different-rule");
269        let rules_v2 = [rule_v2];
270        let hash_v2 = rules_hash(&rules_v2);
271        assert_ne!(hash_v1, hash_v2);
272
273        store.check_rules_changed(&hash_v2).unwrap();
274
275        assert!(
276            store.get(&content_hash).is_none(),
277            "cache entries should be invalidated when rules change"
278        );
279    }
280
281    #[test]
282    fn cleanup_orphan_entries() {
283        let dir = tempdir().unwrap();
284        let store = CacheStore::new(dir.path());
285
286        let hash_a = file_content_hash(b"file a");
287        let hash_b = file_content_hash(b"file b");
288        store.put(&hash_a, &[]).unwrap();
289        store.put(&hash_b, &[sample_finding()]).unwrap();
290
291        let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
292
293        assert_eq!(removed, 1);
294        assert!(store.get(&hash_a).is_some());
295        assert!(store.get(&hash_b).is_none());
296    }
297
298    #[test]
299    fn corrupted_cache_entry() {
300        let dir = tempdir().unwrap();
301        let store = CacheStore::new(dir.path());
302
303        store.ensure_dir().unwrap();
304        let hash = file_content_hash(b"some content");
305        fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
306
307        assert!(
308            store.get(&hash).is_none(),
309            "corrupted entry should return None, not panic"
310        );
311    }
312}