1use std::path::{Path, PathBuf};
2
3use crate::evidence::EvidenceBundle;
4
5pub trait EvidenceCache: Send + Sync {
11 fn get(&self, key: &str) -> Option<EvidenceBundle>;
13 fn put(&self, key: &str, bundle: &EvidenceBundle);
15}
16
17pub fn cache_key(subject_type: &str, subject_id: &str, revision: &str) -> String {
25 format!("{subject_type}:{subject_id}:{revision}")
26}
27
28pub struct NoCache;
30
31impl EvidenceCache for NoCache {
32 fn get(&self, _key: &str) -> Option<EvidenceBundle> {
33 None
34 }
35 fn put(&self, _key: &str, _bundle: &EvidenceBundle) {}
36}
37
38pub struct FsCache {
44 dir: PathBuf,
45 ttl_secs: u64,
46}
47
48impl FsCache {
49 pub fn new(dir: impl Into<PathBuf>, ttl_secs: u64) -> std::io::Result<Self> {
53 let dir = dir.into();
54 std::fs::create_dir_all(&dir)?;
55 Ok(Self { dir, ttl_secs })
56 }
57
58 fn path_for(&self, key: &str) -> PathBuf {
59 let sanitized: String = key
60 .chars()
61 .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
62 .collect();
63 self.dir.join(format!("{sanitized}.json"))
64 }
65
66 fn is_fresh(path: &Path, ttl_secs: u64) -> bool {
67 path.metadata()
68 .and_then(|m| m.modified())
69 .ok()
70 .and_then(|mtime| mtime.elapsed().ok())
71 .is_some_and(|age| age.as_secs() < ttl_secs)
72 }
73}
74
75impl EvidenceCache for FsCache {
76 fn get(&self, key: &str) -> Option<EvidenceBundle> {
77 let path = self.path_for(key);
78 if !Self::is_fresh(&path, self.ttl_secs) {
79 return None;
80 }
81 let data = std::fs::read_to_string(&path).ok()?;
82 serde_json::from_str(&data).ok()
83 }
84
85 fn put(&self, key: &str, bundle: &EvidenceBundle) {
86 let path = self.path_for(key);
87 if let Ok(json) = serde_json::to_string(bundle) {
88 let _ = std::fs::write(&path, json);
89 }
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn cache_key_format() {
99 assert_eq!(
100 cache_key("pr", "owner/repo#42", "abc1234"),
101 "pr:owner/repo#42:abc1234"
102 );
103 }
104
105 #[test]
106 fn no_cache_always_misses() {
107 let cache = NoCache;
108 assert!(cache.get("anything").is_none());
109 }
110
111 #[test]
112 fn fs_cache_round_trip() {
113 let dir = std::env::temp_dir().join("libverify-cache-test");
114 let _ = std::fs::remove_dir_all(&dir);
115 let cache = FsCache::new(&dir, 3600).unwrap();
116
117 let bundle = EvidenceBundle::default();
118 cache.put("test-key", &bundle);
119
120 let retrieved = cache.get("test-key");
121 assert!(retrieved.is_some());
122 assert_eq!(retrieved.unwrap(), bundle);
123
124 let _ = std::fs::remove_dir_all(&dir);
125 }
126
127 #[test]
128 fn fs_cache_expired_returns_none() {
129 let dir = std::env::temp_dir().join("libverify-cache-expire-test");
130 let _ = std::fs::remove_dir_all(&dir);
131 let cache = FsCache::new(&dir, 0).unwrap();
133
134 let bundle = EvidenceBundle::default();
135 cache.put("expired-key", &bundle);
136
137 assert!(cache.get("expired-key").is_none());
138
139 let _ = std::fs::remove_dir_all(&dir);
140 }
141}