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| {
62 if c.is_alphanumeric() || c == '-' || c == '_' {
63 c
64 } else {
65 '_'
66 }
67 })
68 .collect();
69 self.dir.join(format!("{sanitized}.json"))
70 }
71
72 fn is_fresh(path: &Path, ttl_secs: u64) -> bool {
73 path.metadata()
74 .and_then(|m| m.modified())
75 .ok()
76 .and_then(|mtime| mtime.elapsed().ok())
77 .is_some_and(|age| age.as_secs() < ttl_secs)
78 }
79}
80
81impl EvidenceCache for FsCache {
82 fn get(&self, key: &str) -> Option<EvidenceBundle> {
83 let path = self.path_for(key);
84 if !Self::is_fresh(&path, self.ttl_secs) {
85 return None;
86 }
87 let data = std::fs::read_to_string(&path).ok()?;
88 serde_json::from_str(&data).ok()
89 }
90
91 fn put(&self, key: &str, bundle: &EvidenceBundle) {
92 let path = self.path_for(key);
93 if let Ok(json) = serde_json::to_string(bundle) {
94 let _ = std::fs::write(&path, json);
95 }
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn cache_key_format() {
105 assert_eq!(
106 cache_key("pr", "owner/repo#42", "abc1234"),
107 "pr:owner/repo#42:abc1234"
108 );
109 }
110
111 #[test]
112 fn no_cache_always_misses() {
113 let cache = NoCache;
114 assert!(cache.get("anything").is_none());
115 }
116
117 #[test]
118 fn fs_cache_round_trip() {
119 let dir = std::env::temp_dir().join("libverify-cache-test");
120 let _ = std::fs::remove_dir_all(&dir);
121 let cache = FsCache::new(&dir, 3600).unwrap();
122
123 let bundle = EvidenceBundle::default();
124 cache.put("test-key", &bundle);
125
126 let retrieved = cache.get("test-key");
127 assert!(retrieved.is_some());
128 assert_eq!(retrieved.unwrap(), bundle);
129
130 let _ = std::fs::remove_dir_all(&dir);
131 }
132
133 #[test]
134 fn fs_cache_expired_returns_none() {
135 let dir = std::env::temp_dir().join("libverify-cache-expire-test");
136 let _ = std::fs::remove_dir_all(&dir);
137 let cache = FsCache::new(&dir, 0).unwrap();
139
140 let bundle = EvidenceBundle::default();
141 cache.put("expired-key", &bundle);
142
143 assert!(cache.get("expired-key").is_none());
144
145 let _ = std::fs::remove_dir_all(&dir);
146 }
147}