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