Skip to main content

libverify_core/
cache.rs

1use std::path::{Path, PathBuf};
2
3use crate::evidence::EvidenceBundle;
4
5/// Trait for caching collected evidence bundles.
6///
7/// Implementors store and retrieve [`EvidenceBundle`] by a string key,
8/// enabling incremental verification and multi-policy evaluation without
9/// redundant API calls.
10pub trait EvidenceCache: Send + Sync {
11    /// Retrieve a cached bundle, or `None` if not present / expired.
12    fn get(&self, key: &str) -> Option<EvidenceBundle>;
13    /// Store a bundle under the given key.
14    fn put(&self, key: &str, bundle: &EvidenceBundle);
15}
16
17/// Build a deterministic cache key from a subject type and identifier.
18///
19/// # Examples
20/// ```
21/// use libverify_core::cache::cache_key;
22/// assert_eq!(cache_key("pr", "owner/repo#42", "abc1234"), "pr:owner/repo#42:abc1234");
23/// ```
24pub fn cache_key(subject_type: &str, subject_id: &str, revision: &str) -> String {
25    format!("{subject_type}:{subject_id}:{revision}")
26}
27
28/// A no-op cache that never stores or retrieves anything.
29pub 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
38/// Filesystem-backed evidence cache.
39///
40/// Stores bundles as JSON files in a directory, keyed by a sanitized
41/// filename derived from the cache key. Files older than `ttl` seconds
42/// are treated as expired.
43pub struct FsCache {
44    dir: PathBuf,
45    ttl_secs: u64,
46}
47
48impl FsCache {
49    /// Create a new filesystem cache rooted at `dir` with the given TTL.
50    ///
51    /// The directory is created if it does not exist.
52    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        // TTL of 0 seconds means everything is expired immediately
138        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}