Skip to main content

batuta/bug_hunter/
cache.rs

1//! Finding cache with mtime-based invalidation for bug-hunter.
2//!
3//! Caches findings to disk using FNV-1a hashed keys. Invalidates when any
4//! source file under the project is newer than the cache file.
5
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use super::types::{Finding, HuntMode};
10
11/// Cached findings stored on disk.
12#[derive(Debug, serde::Serialize, serde::Deserialize)]
13pub struct CachedFindings {
14    pub findings: Vec<Finding>,
15    pub mode: HuntMode,
16    pub config_hash: String,
17}
18
19/// Compute an FNV-1a hash of the cache-relevant config fields.
20///
21/// Key components: project path, mode, targets, min_suspiciousness, use_pmat_quality.
22pub fn cache_key(project_path: &Path, config: &super::types::HuntConfig) -> String {
23    let mut hash: u64 = 0xcbf29ce484222325; // FNV offset basis
24    let prime: u64 = 0x00000100000001B3;
25
26    let input = format!(
27        "{}:{}:{:?}:{:.4}:{}:{}:{}:{}:{}",
28        project_path.display(),
29        config.mode,
30        config.targets,
31        config.min_suspiciousness,
32        config.use_pmat_quality,
33        config.contracts_auto,
34        config.contracts_path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(),
35        config.model_parity_auto,
36        config.model_parity_path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(),
37    );
38
39    for byte in input.bytes() {
40        hash ^= byte as u64;
41        hash = hash.wrapping_mul(prime);
42    }
43
44    format!("{:016x}", hash)
45}
46
47/// Check if any `.rs` source file under `project_path` is newer than `cache_mtime`.
48pub fn any_source_newer_than(project_path: &Path, cache_mtime: SystemTime) -> bool {
49    let pattern = format!("{}/**/*.rs", project_path.display());
50    if let Ok(entries) = glob::glob(&pattern) {
51        for entry in entries.flatten() {
52            if let Ok(meta) = entry.metadata() {
53                if let Ok(mtime) = meta.modified() {
54                    if mtime > cache_mtime {
55                        return true;
56                    }
57                }
58            }
59        }
60    }
61    false
62}
63
64/// Cache directory for bug-hunter findings.
65fn cache_dir(project_path: &Path) -> PathBuf {
66    project_path.join(".pmat").join("bug-hunter-cache")
67}
68
69/// Load cached findings if the cache exists and is still valid.
70///
71/// Returns `None` if the cache file doesn't exist, is corrupt, or if any
72/// source file has been modified since the cache was written.
73pub fn load_cached(
74    project_path: &Path,
75    config: &super::types::HuntConfig,
76) -> Option<CachedFindings> {
77    let key = cache_key(project_path, config);
78    let cache_file = cache_dir(project_path).join(format!("{}.json", key));
79
80    if !cache_file.exists() {
81        return None;
82    }
83
84    let cache_mtime = cache_file.metadata().ok()?.modified().ok()?;
85
86    if any_source_newer_than(project_path, cache_mtime) {
87        return None;
88    }
89
90    let content = std::fs::read_to_string(&cache_file).ok()?;
91    let cached: CachedFindings = serde_json::from_str(&content).ok()?;
92
93    // Verify config hash matches
94    if cached.config_hash != key {
95        return None;
96    }
97
98    Some(cached)
99}
100
101/// Save findings to the cache.
102pub fn save_cache(
103    project_path: &Path,
104    config: &super::types::HuntConfig,
105    findings: &[Finding],
106    mode: HuntMode,
107) {
108    let key = cache_key(project_path, config);
109    let dir = cache_dir(project_path);
110
111    if std::fs::create_dir_all(&dir).is_err() {
112        return;
113    }
114
115    let cached = CachedFindings { findings: findings.to_vec(), mode, config_hash: key.clone() };
116
117    let cache_file = dir.join(format!("{}.json", key));
118    if let Ok(json) = serde_json::to_string(&cached) {
119        let _ = std::fs::write(&cache_file, json);
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::bug_hunter::types::HuntConfig;
127
128    #[test]
129    fn test_cache_key_deterministic() {
130        let config = HuntConfig::default();
131        let k1 = cache_key(Path::new("/tmp/proj"), &config);
132        let k2 = cache_key(Path::new("/tmp/proj"), &config);
133        assert_eq!(k1, k2, "Same inputs must produce same key");
134    }
135
136    #[test]
137    fn test_cache_key_varies() {
138        let c1 = HuntConfig::default();
139        let c2 = HuntConfig { mode: HuntMode::Quick, ..Default::default() };
140
141        let k1 = cache_key(Path::new("/tmp/proj"), &c1);
142        let k2 = cache_key(Path::new("/tmp/proj"), &c2);
143        assert_ne!(k1, k2, "Different modes must produce different keys");
144
145        let k3 = cache_key(Path::new("/tmp/other"), &c1);
146        assert_ne!(k1, k3, "Different paths must produce different keys");
147    }
148
149    #[test]
150    fn test_load_cached_empty_dir() {
151        let temp = std::env::temp_dir().join("test_bh_cache_empty");
152        let _ = std::fs::create_dir_all(&temp);
153        let config = HuntConfig::default();
154
155        let result = load_cached(&temp, &config);
156        assert!(result.is_none(), "Empty dir should return None");
157
158        let _ = std::fs::remove_dir_all(&temp);
159    }
160
161    #[test]
162    fn test_save_and_load_roundtrip() {
163        let temp = std::env::temp_dir().join("test_bh_cache_roundtrip");
164        let _ = std::fs::remove_dir_all(&temp);
165        let _ = std::fs::create_dir_all(&temp);
166
167        let config = HuntConfig { mode: HuntMode::Quick, ..Default::default() };
168
169        let findings = vec![Finding::new("BH-001", "src/lib.rs", 42, "Test finding")];
170
171        save_cache(&temp, &config, &findings, HuntMode::Quick);
172
173        let cached = load_cached(&temp, &config);
174        assert!(cached.is_some(), "Should load cached findings");
175
176        let cached = cached.expect("unexpected failure");
177        assert_eq!(cached.findings.len(), 1);
178        assert_eq!(cached.findings[0].id, "BH-001");
179        assert_eq!(cached.mode, HuntMode::Quick);
180
181        let _ = std::fs::remove_dir_all(&temp);
182    }
183
184    #[test]
185    fn test_any_source_newer_than_returns_true() {
186        // Create a temp dir with a .rs file, then use an old timestamp
187        let temp = std::env::temp_dir().join("test_bh_cache_newer");
188        let _ = std::fs::remove_dir_all(&temp);
189        let _ = std::fs::create_dir_all(&temp);
190        std::fs::write(temp.join("test.rs"), "fn main() {}").expect("write");
191
192        // Use UNIX_EPOCH as cache_mtime; any file will be newer than epoch
193        let old_time = std::time::UNIX_EPOCH;
194        assert!(
195            any_source_newer_than(&temp, old_time),
196            "Source file should be newer than UNIX_EPOCH"
197        );
198
199        let _ = std::fs::remove_dir_all(&temp);
200    }
201
202    #[test]
203    fn test_load_cached_invalidated_by_newer_source() {
204        // Save a cache, then create a .rs file newer than the cache
205        let temp = std::env::temp_dir().join("test_bh_cache_invalidate");
206        let _ = std::fs::remove_dir_all(&temp);
207        let _ = std::fs::create_dir_all(&temp);
208
209        let config = HuntConfig { mode: HuntMode::Quick, ..Default::default() };
210
211        let findings = vec![Finding::new("BH-002", "src/lib.rs", 1, "Finding")];
212        save_cache(&temp, &config, &findings, HuntMode::Quick);
213
214        // Now create a .rs file so it's newer than the cache
215        std::thread::sleep(std::time::Duration::from_millis(50));
216        std::fs::write(temp.join("new_file.rs"), "// new").expect("write");
217
218        let cached = load_cached(&temp, &config);
219        assert!(cached.is_none(), "Cache should be invalidated by newer source");
220
221        let _ = std::fs::remove_dir_all(&temp);
222    }
223
224    #[test]
225    fn test_cache_key_varies_with_contracts_auto() {
226        let c1 = HuntConfig::default();
227        let c2 = HuntConfig { contracts_auto: true, ..Default::default() };
228
229        let k1 = cache_key(Path::new("/tmp/proj"), &c1);
230        let k2 = cache_key(Path::new("/tmp/proj"), &c2);
231        assert_ne!(k1, k2, "contracts_auto must change cache key");
232    }
233
234    #[test]
235    fn test_cache_key_varies_with_model_parity_auto() {
236        let c1 = HuntConfig::default();
237        let c2 = HuntConfig { model_parity_auto: true, ..Default::default() };
238
239        let k1 = cache_key(Path::new("/tmp/proj"), &c1);
240        let k2 = cache_key(Path::new("/tmp/proj"), &c2);
241        assert_ne!(k1, k2, "model_parity_auto must change cache key");
242    }
243
244    #[test]
245    fn test_cache_key_varies_with_contracts_path() {
246        let c1 = HuntConfig::default();
247        let c2 = HuntConfig {
248            contracts_path: Some(PathBuf::from("/tmp/contracts")),
249            ..Default::default()
250        };
251
252        let k1 = cache_key(Path::new("/tmp/proj"), &c1);
253        let k2 = cache_key(Path::new("/tmp/proj"), &c2);
254        assert_ne!(k1, k2, "contracts_path must change cache key");
255    }
256
257    #[test]
258    fn test_load_cached_config_hash_mismatch() {
259        // Manually write a cache file with a wrong config_hash
260        let temp = std::env::temp_dir().join("test_bh_cache_hash_mismatch");
261        let _ = std::fs::remove_dir_all(&temp);
262        let _ = std::fs::create_dir_all(&temp);
263
264        let config = HuntConfig { mode: HuntMode::Quick, ..Default::default() };
265
266        let key = cache_key(&temp, &config);
267        let dir = temp.join(".pmat").join("bug-hunter-cache");
268        let _ = std::fs::create_dir_all(&dir);
269
270        let cached = CachedFindings {
271            findings: vec![],
272            mode: HuntMode::Quick,
273            config_hash: "wrong_hash_value".to_string(),
274        };
275        let cache_file = dir.join(format!("{}.json", key));
276        let json = serde_json::to_string(&cached).expect("serialize");
277        std::fs::write(&cache_file, json).expect("write");
278
279        let result = load_cached(&temp, &config);
280        assert!(result.is_none(), "Should return None on config hash mismatch");
281
282        let _ = std::fs::remove_dir_all(&temp);
283    }
284}