Skip to main content

sbom_tools/enrichment/
cache.rs

1//! File-based cache for vulnerability data.
2//!
3//! [`FileCache`] is the vulnerability-specific view over the shared
4//! [`JsonCache`](super::source::JsonCache): it stores `Vec<VulnerabilityRef>`
5//! payloads and inherits the shared cache's atomic writes, TTL eviction, and
6//! schema-versioned envelope.
7
8use super::source::{CacheStats, JsonCache};
9use crate::error::Result;
10use crate::model::VulnerabilityRef;
11use std::path::PathBuf;
12use std::time::Duration;
13
14pub use super::source::CacheKey;
15
16/// File-based cache of `Vec<VulnerabilityRef>` with TTL support.
17pub struct FileCache {
18    inner: JsonCache<Vec<VulnerabilityRef>>,
19}
20
21impl FileCache {
22    /// Create a new file cache.
23    pub fn new(cache_dir: PathBuf, ttl: Duration) -> Result<Self> {
24        Ok(Self {
25            inner: JsonCache::new(cache_dir, ttl)?,
26        })
27    }
28
29    /// Get cached vulnerabilities for a key.
30    ///
31    /// Returns None if not cached or cache is expired.
32    #[must_use]
33    pub fn get(&self, key: &CacheKey) -> Option<Vec<VulnerabilityRef>> {
34        self.inner.get(key)
35    }
36
37    /// Store vulnerabilities in the cache.
38    pub fn set(&self, key: &CacheKey, vulns: &[VulnerabilityRef]) -> Result<()> {
39        self.inner.set(key, vulns)
40    }
41
42    /// Remove a cached entry.
43    pub fn remove(&self, key: &CacheKey) -> Result<()> {
44        self.inner.remove(key)
45    }
46
47    /// Clear all cached entries.
48    pub fn clear(&self) -> Result<()> {
49        self.inner.clear()
50    }
51
52    /// Get cache statistics.
53    #[must_use]
54    pub fn stats(&self) -> CacheStats {
55        self.inner.stats()
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    fn make_key(purl: Option<&str>, name: &str, eco: Option<&str>, ver: Option<&str>) -> CacheKey {
64        CacheKey::new(
65            purl.map(String::from),
66            name.to_string(),
67            eco.map(String::from),
68            ver.map(String::from),
69        )
70    }
71
72    #[test]
73    fn test_cache_key_filename_deterministic() {
74        let key = make_key(Some("pkg:npm/foo@1.0"), "foo", Some("npm"), Some("1.0"));
75        let f1 = key.to_filename();
76        let f2 = key.to_filename();
77        assert_eq!(f1, f2);
78        assert!(f1.ends_with(".json"));
79    }
80
81    #[test]
82    fn test_cache_key_filename_different() {
83        let k1 = make_key(Some("pkg:npm/foo@1.0"), "foo", Some("npm"), Some("1.0"));
84        let k2 = make_key(Some("pkg:npm/bar@1.0"), "bar", Some("npm"), Some("1.0"));
85        assert_ne!(k1.to_filename(), k2.to_filename());
86    }
87
88    #[test]
89    fn test_cache_key_is_queryable_purl() {
90        let key = make_key(Some("pkg:npm/foo@1.0"), "foo", None, None);
91        assert!(key.is_queryable());
92    }
93
94    #[test]
95    fn test_cache_key_is_queryable_eco_ver() {
96        let key = make_key(None, "foo", Some("npm"), Some("1.0"));
97        assert!(key.is_queryable());
98    }
99
100    #[test]
101    fn test_cache_key_is_queryable_name_only() {
102        let key = make_key(None, "foo", None, None);
103        assert!(!key.is_queryable());
104    }
105
106    #[test]
107    fn test_file_cache_new_creates_dir() {
108        let tmp = tempfile::tempdir().unwrap();
109        let cache_dir = tmp.path().join("vuln_cache");
110        assert!(!cache_dir.exists());
111        let _cache = FileCache::new(cache_dir.clone(), Duration::from_secs(3600)).unwrap();
112        assert!(cache_dir.exists());
113    }
114
115    #[test]
116    fn test_file_cache_set_get_roundtrip() {
117        let tmp = tempfile::tempdir().unwrap();
118        let cache = FileCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
119        let key = make_key(Some("pkg:npm/foo@1.0"), "foo", Some("npm"), Some("1.0"));
120
121        let vulns = vec![VulnerabilityRef::new(
122            "CVE-2024-0001".to_string(),
123            crate::model::VulnerabilitySource::Osv,
124        )];
125
126        cache.set(&key, &vulns).unwrap();
127        let result = cache.get(&key);
128        assert!(result.is_some());
129        let retrieved = result.unwrap();
130        assert_eq!(retrieved.len(), 1);
131        assert_eq!(retrieved[0].id, "CVE-2024-0001");
132    }
133
134    #[test]
135    fn test_file_cache_get_miss() {
136        let tmp = tempfile::tempdir().unwrap();
137        let cache = FileCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
138        let key = make_key(Some("pkg:npm/nope@1.0"), "nope", Some("npm"), Some("1.0"));
139        assert!(cache.get(&key).is_none());
140    }
141
142    #[test]
143    fn test_file_cache_remove() {
144        let tmp = tempfile::tempdir().unwrap();
145        let cache = FileCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
146        let key = make_key(Some("pkg:npm/rm@1.0"), "rm", Some("npm"), Some("1.0"));
147
148        cache.set(&key, &[]).unwrap();
149        assert!(cache.get(&key).is_some());
150        cache.remove(&key).unwrap();
151        assert!(cache.get(&key).is_none());
152    }
153
154    #[test]
155    fn test_file_cache_clear() {
156        let tmp = tempfile::tempdir().unwrap();
157        let cache = FileCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
158
159        for i in 0..3 {
160            let key = make_key(None, &format!("pkg{i}"), Some("npm"), Some("1.0"));
161            cache.set(&key, &[]).unwrap();
162        }
163
164        assert_eq!(cache.stats().total_entries, 3);
165        cache.clear().unwrap();
166        assert_eq!(cache.stats().total_entries, 0);
167    }
168
169    #[test]
170    fn test_file_cache_stats_counts() {
171        let tmp = tempfile::tempdir().unwrap();
172        let cache = FileCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
173
174        for i in 0..3 {
175            let key = make_key(None, &format!("stats{i}"), Some("npm"), Some("1.0"));
176            cache.set(&key, &[]).unwrap();
177        }
178
179        let stats = cache.stats();
180        assert_eq!(stats.total_entries, 3);
181        assert_eq!(stats.expired_entries, 0);
182    }
183}