Skip to main content

sbom_tools/enrichment/
cache.rs

1//! File-based cache for vulnerability data.
2
3use crate::error::Result;
4use crate::model::VulnerabilityRef;
5use sha2::{Digest, Sha256};
6use std::fs;
7use std::path::PathBuf;
8use std::time::Duration;
9
10/// Cache key for vulnerability lookups.
11#[derive(Debug, Clone, Hash, PartialEq, Eq)]
12pub struct CacheKey {
13    /// Package URL (preferred)
14    pub purl: Option<String>,
15    /// Component name
16    pub name: String,
17    /// Ecosystem (npm, pypi, etc.)
18    pub ecosystem: Option<String>,
19    /// Version
20    pub version: Option<String>,
21}
22
23impl CacheKey {
24    /// Create a cache key from component data.
25    pub fn new(
26        purl: Option<String>,
27        name: String,
28        ecosystem: Option<String>,
29        version: Option<String>,
30    ) -> Self {
31        Self {
32            purl,
33            name,
34            ecosystem,
35            version,
36        }
37    }
38
39    /// Convert to a filesystem-safe filename using SHA256 hash.
40    pub fn to_filename(&self) -> String {
41        let mut hasher = Sha256::new();
42        hasher.update(format!(
43            "purl:{:?}|name:{}|eco:{:?}|ver:{:?}",
44            self.purl, self.name, self.ecosystem, self.version
45        ));
46        let hash = hasher.finalize();
47        format!("{:x}.json", hash)
48    }
49
50    /// Check if this key can be used for an OSV query.
51    pub fn is_queryable(&self) -> bool {
52        // Need either a PURL or name + ecosystem + version
53        self.purl.is_some() || (self.ecosystem.is_some() && self.version.is_some())
54    }
55}
56
57/// File-based cache with TTL support.
58pub struct FileCache {
59    /// Cache directory
60    cache_dir: PathBuf,
61    /// Time-to-live for cached entries
62    ttl: Duration,
63}
64
65impl FileCache {
66    /// Create a new file cache.
67    pub fn new(cache_dir: PathBuf, ttl: Duration) -> Result<Self> {
68        // Ensure cache directory exists
69        if !cache_dir.exists() {
70            fs::create_dir_all(&cache_dir)?;
71        }
72        Ok(Self { cache_dir, ttl })
73    }
74
75    /// Get cached vulnerabilities for a key.
76    ///
77    /// Returns None if not cached or cache is expired.
78    pub fn get(&self, key: &CacheKey) -> Option<Vec<VulnerabilityRef>> {
79        let path = self.cache_dir.join(key.to_filename());
80
81        // Check if file exists
82        let metadata = fs::metadata(&path).ok()?;
83
84        // Check TTL
85        let modified = metadata.modified().ok()?;
86        let age = modified.elapsed().ok()?;
87        if age > self.ttl {
88            // Cache expired, remove it
89            let _ = fs::remove_file(&path);
90            return None;
91        }
92
93        // Read and parse
94        let data = fs::read_to_string(&path).ok()?;
95        serde_json::from_str(&data).ok()
96    }
97
98    /// Store vulnerabilities in the cache.
99    pub fn set(&self, key: &CacheKey, vulns: &[VulnerabilityRef]) -> Result<()> {
100        let path = self.cache_dir.join(key.to_filename());
101        let data = serde_json::to_string(vulns)?;
102        fs::write(path, data)?;
103        Ok(())
104    }
105
106    /// Remove a cached entry.
107    pub fn remove(&self, key: &CacheKey) -> Result<()> {
108        let path = self.cache_dir.join(key.to_filename());
109        if path.exists() {
110            fs::remove_file(path)?;
111        }
112        Ok(())
113    }
114
115    /// Clear all cached entries.
116    pub fn clear(&self) -> Result<()> {
117        if self.cache_dir.exists() {
118            for entry in fs::read_dir(&self.cache_dir)? {
119                let entry = entry?;
120                if entry
121                    .path()
122                    .extension()
123                    .map(|e| e == "json")
124                    .unwrap_or(false)
125                {
126                    let _ = fs::remove_file(entry.path());
127                }
128            }
129        }
130        Ok(())
131    }
132
133    /// Get cache statistics.
134    pub fn stats(&self) -> CacheStats {
135        let mut stats = CacheStats::default();
136
137        if let Ok(entries) = fs::read_dir(&self.cache_dir) {
138            for entry in entries.flatten() {
139                if entry
140                    .path()
141                    .extension()
142                    .map(|e| e == "json")
143                    .unwrap_or(false)
144                {
145                    stats.total_entries += 1;
146                    if let Ok(metadata) = entry.metadata() {
147                        stats.total_size += metadata.len();
148
149                        // Check if expired
150                        if let Ok(modified) = metadata.modified() {
151                            if let Ok(age) = modified.elapsed() {
152                                if age > self.ttl {
153                                    stats.expired_entries += 1;
154                                }
155                            }
156                        }
157                    }
158                }
159            }
160        }
161
162        stats
163    }
164}
165
166/// Cache statistics.
167#[derive(Debug, Default)]
168pub struct CacheStats {
169    /// Total number of cached entries
170    pub total_entries: usize,
171    /// Number of expired entries
172    pub expired_entries: usize,
173    /// Total size in bytes
174    pub total_size: u64,
175}