use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::enrich::Severity;
pub const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
pub fn effective_ttl_secs(override_hours: Option<u64>) -> u64 {
match override_hours {
Some(h) if h > 0 => h.saturating_mul(3600),
_ => CACHE_TTL_SECS,
}
}
const OSV_SUBDIR: &str = "osv";
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry {
fetched_at: u64,
severity: Severity,
#[serde(default)]
aliases: Vec<String>,
}
pub struct Cache {
root: PathBuf,
now_secs: fn() -> u64,
ttl_secs: u64,
}
impl Cache {
pub fn open() -> Option<Self> {
Self::open_with_ttl(None)
}
pub fn open_with_ttl(ttl_hours: Option<u64>) -> Option<Self> {
let root = crate::refresh::default_cache_root().ok()?.join(OSV_SUBDIR);
Some(Self {
root,
now_secs: default_now_secs,
ttl_secs: effective_ttl_secs(ttl_hours),
})
}
#[cfg(test)]
pub fn with_root(root: PathBuf, now_secs: fn() -> u64) -> Self {
Self {
root,
now_secs,
ttl_secs: CACHE_TTL_SECS,
}
}
pub fn get(&self, advisory_id: &str) -> Option<Severity> {
self.get_full(advisory_id).map(|(s, _)| s)
}
pub fn get_full(&self, advisory_id: &str) -> Option<(Severity, Vec<String>)> {
let path = self.path_for(advisory_id);
let body = std::fs::read(&path).ok()?;
let entry: CacheEntry = serde_json::from_slice(&body).ok()?;
let now = (self.now_secs)();
if now.saturating_sub(entry.fetched_at) > self.ttl_secs {
return None;
}
Some((entry.severity, entry.aliases))
}
pub fn put(&self, advisory_id: &str, severity: Severity) {
self.put_full(advisory_id, severity, &[]);
}
pub fn put_full(&self, advisory_id: &str, severity: Severity, aliases: &[String]) {
if std::fs::create_dir_all(&self.root).is_err() {
return;
}
let entry = CacheEntry {
fetched_at: (self.now_secs)(),
severity,
aliases: aliases.to_vec(),
};
let Ok(body) = serde_json::to_vec(&entry) else {
return;
};
let target = self.path_for(advisory_id);
let mut tmp = target.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
if std::fs::write(&tmp, body).is_err() {
return;
}
let _ = std::fs::rename(&tmp, &target);
}
fn path_for(&self, advisory_id: &str) -> PathBuf {
self.root.join(format!("{}.json", sanitize(advisory_id)))
}
}
fn default_now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn sanitize(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect()
}
pub fn open_unless_disabled(disabled: bool) -> Option<Cache> {
open_unless_disabled_with_ttl(disabled, None)
}
pub fn open_unless_disabled_with_ttl(disabled: bool, ttl_hours: Option<u64>) -> Option<Cache> {
if disabled {
None
} else {
Cache::open_with_ttl(ttl_hours)
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::todo,
clippy::unimplemented
)]
use super::*;
fn fixed_clock() -> u64 {
1_700_000_000
}
fn one_day_later() -> u64 {
1_700_000_000 + CACHE_TTL_SECS + 1
}
fn tempdir_unique(stem: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!(
"bomdrift-cache-test-{stem}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn put_then_get_roundtrips_severity() {
let dir = tempdir_unique("roundtrip");
let cache = Cache::with_root(dir.clone(), fixed_clock);
cache.put("GHSA-xxxx-yyyy-zzzz", Severity::Critical);
let got = cache.get("GHSA-xxxx-yyyy-zzzz");
assert_eq!(got, Some(Severity::Critical));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn put_full_roundtrips_aliases() {
let dir = tempdir_unique("aliases");
let cache = Cache::with_root(dir.clone(), fixed_clock);
cache.put_full(
"GHSA-aliases-1",
Severity::High,
&["CVE-2024-1".to_string(), "CVE-2024-2".to_string()],
);
let (sev, aliases) = cache.get_full("GHSA-aliases-1").unwrap();
assert_eq!(sev, Severity::High);
assert_eq!(
aliases,
vec!["CVE-2024-1".to_string(), "CVE-2024-2".to_string()]
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn get_returns_none_for_missing_advisory() {
let dir = tempdir_unique("miss");
let cache = Cache::with_root(dir.clone(), fixed_clock);
assert_eq!(cache.get("CVE-NEVER-CACHED"), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn expired_entry_returns_none() {
let dir = tempdir_unique("expired");
let writer = Cache::with_root(dir.clone(), fixed_clock);
writer.put("GHSA-aged", Severity::High);
let reader = Cache::with_root(dir.clone(), one_day_later);
assert_eq!(
reader.get("GHSA-aged"),
None,
"entry past TTL must read as miss"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn entry_within_ttl_is_returned() {
let dir = tempdir_unique("fresh");
let writer = Cache::with_root(dir.clone(), fixed_clock);
writer.put("GHSA-fresh", Severity::Medium);
let almost_a_day = || 1_700_000_000 + (23 * 60 * 60);
let reader = Cache::with_root(dir.clone(), almost_a_day);
assert_eq!(reader.get("GHSA-fresh"), Some(Severity::Medium));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_cache_file_is_treated_as_miss() {
let dir = tempdir_unique("corrupt");
std::fs::write(dir.join("CVE-2025-broken.json"), "<not json>").unwrap();
let cache = Cache::with_root(dir.clone(), fixed_clock);
assert_eq!(
cache.get("CVE-2025-broken"),
None,
"unparseable entry must miss, not panic"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sanitize_replaces_path_separators_with_underscore() {
assert_eq!(sanitize("GHSA-abc-def"), "GHSA-abc-def");
assert_eq!(sanitize("weird/id"), "weird_id");
assert_eq!(sanitize("../../etc/passwd"), ".._.._etc_passwd");
}
#[test]
fn put_uses_temp_file_then_rename_no_torn_writes() {
let dir = tempdir_unique("atomic");
let cache = Cache::with_root(dir.clone(), fixed_clock);
cache.put("GHSA-atomic", Severity::Low);
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
assert!(
!entries.iter().any(|n| n.ends_with(".tmp")),
"leftover temp file in {entries:?}"
);
assert!(
entries.iter().any(|n| n == "GHSA-atomic.json"),
"expected committed file in {entries:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn open_unless_disabled_respects_flag() {
assert!(open_unless_disabled(true).is_none());
let _ = open_unless_disabled(false);
}
#[test]
fn effective_ttl_secs_falls_back_to_default_when_none() {
assert_eq!(effective_ttl_secs(None), CACHE_TTL_SECS);
assert_eq!(effective_ttl_secs(Some(0)), CACHE_TTL_SECS);
}
#[test]
fn effective_ttl_secs_converts_hours_to_seconds() {
assert_eq!(effective_ttl_secs(Some(1)), 3600);
assert_eq!(effective_ttl_secs(Some(48)), 48 * 3600);
}
#[test]
fn cache_with_overridden_ttl_expires_independently_of_const() {
let dir = tempdir_unique("override-ttl");
let writer = Cache::with_root(dir.clone(), fixed_clock);
writer.put("GHSA-override", Severity::Low);
let mut reader = Cache::with_root(dir.clone(), || 1_700_000_000 + 3600 + 1);
reader.ttl_secs = effective_ttl_secs(Some(1));
assert_eq!(
reader.get("GHSA-override"),
None,
"1h-TTL cache must miss after 1h+1s"
);
let _ = std::fs::remove_dir_all(&dir);
}
}