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| 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        // TTL of 0 seconds means everything is expired immediately
132        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}