1use anyhow::Result;
9use chrono::Duration;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use tracing::instrument;
13
14use crate::cache::{FileCache, FileCacheImpl};
15
16use super::ValidatedFinding;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct CachedFinding {
21 pub validated: ValidatedFinding,
23}
24
25impl CachedFinding {
26 #[must_use]
28 pub fn new(validated: ValidatedFinding) -> Self {
29 Self { validated }
30 }
31}
32
33#[must_use]
56pub fn cache_key(
57 repo_owner: &str,
58 repo_name: &str,
59 file_path: &str,
60 pattern_id: &str,
61 matched_text: &str,
62) -> String {
63 let mut hasher = Sha256::new();
64 hasher.update(repo_owner.as_bytes());
65 hasher.update(b"/");
66 hasher.update(repo_name.as_bytes());
67 hasher.update(b":");
68 hasher.update(file_path.as_bytes());
69 hasher.update(b":");
70 hasher.update(pattern_id.as_bytes());
71 hasher.update(b":");
72 hasher.update(matched_text.as_bytes());
73 format!("{:x}", hasher.finalize())
74}
75
76pub struct FindingCache {
81 cache: FileCacheImpl<CachedFinding>,
82}
83
84impl FindingCache {
85 #[must_use]
89 pub fn new() -> Self {
90 Self {
91 cache: FileCacheImpl::new(
92 "security",
93 Duration::days(crate::cache::DEFAULT_SECURITY_TTL_DAYS),
94 ),
95 }
96 }
97
98 #[instrument(skip(self, matched_text), fields(cache_key))]
112 pub fn get(
113 &self,
114 repo_owner: &str,
115 repo_name: &str,
116 file_path: &str,
117 pattern_id: &str,
118 matched_text: &str,
119 ) -> Result<Option<ValidatedFinding>> {
120 let key = cache_key(repo_owner, repo_name, file_path, pattern_id, matched_text);
121 tracing::Span::current().record("cache_key", &key);
122
123 self.cache
124 .get(&key)
125 .map(|opt| opt.map(|cached| cached.validated))
126 }
127
128 #[instrument(skip(self, matched_text, validated), fields(cache_key))]
139 pub fn set(
140 &self,
141 repo_owner: &str,
142 repo_name: &str,
143 file_path: &str,
144 pattern_id: &str,
145 matched_text: &str,
146 validated: ValidatedFinding,
147 ) -> Result<()> {
148 let key = cache_key(repo_owner, repo_name, file_path, pattern_id, matched_text);
149 tracing::Span::current().record("cache_key", &key);
150
151 let cached = CachedFinding::new(validated);
152 self.cache.set(&key, &cached)
153 }
154}
155
156impl Default for FindingCache {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::security::{Confidence, Finding, Severity};
166
167 #[test]
168 fn test_cache_key_uniqueness() {
169 let key1 = cache_key("owner1", "repo1", "src/main.rs", "pattern1", "code");
171 let key2 = cache_key("owner2", "repo1", "src/main.rs", "pattern1", "code");
172 assert_ne!(key1, key2);
173
174 let key3 = cache_key("owner1", "repo1", "src/lib.rs", "pattern1", "code");
176 assert_ne!(key1, key3);
177
178 let key4 = cache_key("owner1", "repo1", "src/main.rs", "pattern2", "code");
180 assert_ne!(key1, key4);
181
182 let key5 = cache_key("owner1", "repo1", "src/main.rs", "pattern1", "different");
184 assert_ne!(key1, key5);
185
186 let key6 = cache_key("owner1", "repo1", "src/main.rs", "pattern1", "code");
188 assert_eq!(key1, key6);
189 }
190
191 #[test]
192 fn test_cache_key_format() {
193 let key = cache_key("owner", "repo", "file.rs", "pattern", "code");
194 assert_eq!(key.len(), 64);
196 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
197 }
198
199 #[test]
200 fn test_cache_key_privacy() {
201 let key = cache_key(
203 "owner",
204 "repo",
205 "config.rs",
206 "hardcoded-secret",
207 "api_key = \"sk-secret123\"",
208 );
209 assert!(!key.contains("secret"));
210 assert!(!key.contains("api_key"));
211 assert!(!key.contains("sk-"));
212 }
213
214 #[test]
215 fn test_finding_cache_hit() {
216 let cache = FindingCache::new();
217 let validated = ValidatedFinding {
218 finding: Finding {
219 pattern_id: "test-pattern".to_string(),
220 description: "Test finding".to_string(),
221 severity: Severity::High,
222 confidence: Confidence::Medium,
223 file_path: "src/test.rs".to_string(),
224 line_number: 42,
225 matched_text: "test code".to_string(),
226 cwe: None,
227 },
228 is_valid: true,
229 reasoning: "Test reasoning".to_string(),
230 model_version: Some("test-model".to_string()),
231 };
232
233 cache
235 .set(
236 "owner",
237 "repo",
238 "src/test.rs",
239 "test-pattern",
240 "test code",
241 validated.clone(),
242 )
243 .expect("set cache");
244
245 let result = cache
247 .get("owner", "repo", "src/test.rs", "test-pattern", "test code")
248 .expect("get cache");
249
250 assert!(result.is_some());
251 assert_eq!(result.unwrap(), validated);
252
253 let key = cache_key("owner", "repo", "src/test.rs", "test-pattern", "test code");
255 cache.cache.remove(&key).ok();
256 }
257
258 #[test]
259 fn test_finding_cache_miss() {
260 let cache = FindingCache::new();
261
262 let result = cache
263 .get("owner", "repo", "src/nonexistent.rs", "pattern", "code")
264 .expect("get cache");
265
266 assert!(result.is_none());
267 }
268
269 #[test]
270 fn test_finding_cache_different_context() {
271 let cache = FindingCache::new();
272 let validated = ValidatedFinding {
273 finding: Finding {
274 pattern_id: "pattern".to_string(),
275 description: "Finding".to_string(),
276 severity: Severity::Medium,
277 confidence: Confidence::High,
278 file_path: "src/file.rs".to_string(),
279 line_number: 10,
280 matched_text: "code".to_string(),
281 cwe: None,
282 },
283 is_valid: false,
284 reasoning: "False positive".to_string(),
285 model_version: None,
286 };
287
288 cache
290 .set(
291 "owner1",
292 "repo1",
293 "src/file.rs",
294 "pattern",
295 "code",
296 validated,
297 )
298 .expect("set cache");
299
300 let result = cache
302 .get("owner2", "repo1", "src/file.rs", "pattern", "code")
303 .expect("get cache");
304 assert!(result.is_none());
305
306 let key = cache_key("owner1", "repo1", "src/file.rs", "pattern", "code");
308 cache.cache.remove(&key).ok();
309 }
310
311 #[test]
312 fn test_cached_finding_serialization() {
313 let validated = ValidatedFinding {
314 finding: Finding {
315 pattern_id: "test".to_string(),
316 description: "Test".to_string(),
317 severity: Severity::Low,
318 confidence: Confidence::Low,
319 file_path: "test.rs".to_string(),
320 line_number: 1,
321 matched_text: "test".to_string(),
322 cwe: Some("CWE-123".to_string()),
323 },
324 is_valid: true,
325 reasoning: "Valid".to_string(),
326 model_version: Some("model-v1".to_string()),
327 };
328
329 let cached = CachedFinding::new(validated.clone());
330 let json = serde_json::to_string(&cached).expect("serialize");
331 let deserialized: CachedFinding = serde_json::from_str(&json).expect("deserialize");
332
333 assert_eq!(cached, deserialized);
334 assert_eq!(deserialized.validated, validated);
335 }
336}