use crate::error::Result;
use crate::model::Component;
use serde::Serialize;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
static OFFLINE: AtomicBool = AtomicBool::new(false);
pub fn set_offline(offline: bool) {
OFFLINE.store(offline, Ordering::Relaxed);
}
#[must_use]
pub fn is_offline() -> bool {
OFFLINE.load(Ordering::Relaxed)
}
pub const CACHE_SCHEMA_VERSION: u32 = 1;
#[must_use]
pub fn cache_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join("Library").join("Caches"))
}
#[cfg(target_os = "linux")]
{
std::env::var("XDG_CACHE_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".cache"))
})
}
#[cfg(target_os = "windows")]
{
std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".cache"))
}
}
#[must_use]
pub fn root_cache_dir() -> PathBuf {
cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("sbom-tools")
}
#[must_use]
pub fn namespaced_cache_dir(namespace: &str) -> PathBuf {
root_cache_dir().join(namespace)
}
pub fn offline_guard(what: &str) -> Result<()> {
if is_offline() {
return Err(crate::error::SbomDiffError::enrichment(
"offline mode",
crate::error::EnrichmentErrorKind::Offline(what.to_string()),
));
}
Ok(())
}
#[cfg(feature = "enrichment")]
pub fn http_client(timeout: Duration) -> reqwest::Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.timeout(timeout)
.user_agent(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION")
))
.build()
}
const MAX_BACKOFF: Duration = Duration::from_secs(30);
pub const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
#[cfg(feature = "enrichment")]
pub fn read_bounded(response: reqwest::blocking::Response) -> Result<Vec<u8>> {
read_bounded_with_max(response, MAX_RESPONSE_BYTES)
}
#[cfg(feature = "enrichment")]
pub(crate) fn read_bounded_with_max(
response: reqwest::blocking::Response,
max_bytes: u64,
) -> Result<Vec<u8>> {
if let Some(len) = response.content_length()
&& len > max_bytes
{
return Err(oversized_error(len, max_bytes));
}
let bytes = response
.bytes()
.map_err(|e| network_error("reading response body", &e))?;
if bytes.len() as u64 > max_bytes {
return Err(oversized_error(bytes.len() as u64, max_bytes));
}
Ok(bytes.to_vec())
}
#[cfg(feature = "enrichment")]
fn oversized_error(len: u64, max_bytes: u64) -> crate::error::SbomDiffError {
crate::error::SbomDiffError::enrichment(
"response too large",
crate::error::EnrichmentErrorKind::NetworkError(format!(
"response body of {len} bytes exceeds the {max_bytes}-byte limit"
)),
)
}
#[must_use]
pub fn backoff_delay(attempt: u32, retry_after: Option<Duration>) -> Duration {
if let Some(after) = retry_after {
return after.min(MAX_BACKOFF);
}
let secs = 1u64
.checked_shl(attempt.saturating_sub(1))
.unwrap_or(u64::MAX);
Duration::from_secs(secs).min(MAX_BACKOFF)
}
#[cfg(feature = "enrichment")]
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
headers
.get(reqwest::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse::<u64>()
.ok()
.map(Duration::from_secs)
}
#[cfg(feature = "enrichment")]
pub fn get_with_retry(
client: &reqwest::blocking::Client,
url: &str,
max_retries: u8,
) -> Result<reqwest::blocking::Response> {
offline_guard(url)?;
for attempt in 0..=u32::from(max_retries) {
if attempt > 0 {
tracing::debug!("retry attempt {attempt} for {url}");
}
match client.get(url).send() {
Ok(response) => {
let status = response.status();
let retryable =
status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
if retryable && attempt < u32::from(max_retries) {
let retry_after = if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
parse_retry_after(response.headers())
} else {
None
};
std::thread::sleep(backoff_delay(attempt + 1, retry_after));
continue;
}
return Ok(response);
}
Err(e) => {
if attempt < u32::from(max_retries) {
std::thread::sleep(backoff_delay(attempt + 1, None));
continue;
}
return Err(network_error("request failed", &e));
}
}
}
Err(network_error_msg("retry loop returned no response"))
}
#[cfg(feature = "enrichment")]
fn network_error(context: &str, err: &reqwest::Error) -> crate::error::SbomDiffError {
crate::error::SbomDiffError::enrichment(
context,
crate::error::EnrichmentErrorKind::NetworkError(err.to_string()),
)
}
#[cfg(feature = "enrichment")]
fn network_error_msg(msg: &str) -> crate::error::SbomDiffError {
crate::error::SbomDiffError::enrichment(
"network",
crate::error::EnrichmentErrorKind::NetworkError(msg.to_string()),
)
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct CacheKey {
pub purl: Option<String>,
pub name: String,
pub ecosystem: Option<String>,
pub version: Option<String>,
}
impl CacheKey {
#[must_use]
pub const fn new(
purl: Option<String>,
name: String,
ecosystem: Option<String>,
version: Option<String>,
) -> Self {
Self {
purl,
name,
ecosystem,
version,
}
}
#[must_use]
pub fn to_filename(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(format!(
"purl:{:?}|name:{}|eco:{:?}|ver:{:?}",
self.purl, self.name, self.ecosystem, self.version
));
let hash = hasher.finalize();
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
format!("{hex}.json")
}
#[must_use]
pub const fn is_queryable(&self) -> bool {
self.purl.is_some() || (self.ecosystem.is_some() && self.version.is_some())
}
}
#[derive(Debug, Serialize, serde::Deserialize)]
struct CacheEnvelope<T> {
schema_version: u32,
payload: T,
}
pub struct JsonCache<T> {
cache_dir: PathBuf,
ttl: Duration,
_marker: std::marker::PhantomData<fn() -> T>,
}
impl<T> JsonCache<T>
where
T: Serialize + DeserializeOwned,
{
pub fn new(cache_dir: PathBuf, ttl: Duration) -> Result<Self> {
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir)?;
}
Ok(Self {
cache_dir,
ttl,
_marker: std::marker::PhantomData,
})
}
#[must_use]
pub fn path_for(&self, file_name: &str) -> PathBuf {
self.cache_dir.join(file_name)
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.cache_dir
}
#[must_use]
pub fn get_named(&self, file_name: &str) -> Option<T> {
let (value, stale_by) = self.get_named_allow_stale(file_name)?;
if let Some(age) = stale_by {
tracing::warn!(
"serving stale cache entry {file_name}: {} day(s) past its TTL (offline mode)",
age.as_secs() / 86_400
);
}
Some(value)
}
#[must_use]
pub fn get_named_allow_stale(&self, file_name: &str) -> Option<(T, Option<Duration>)> {
let path = self.path_for(file_name);
let metadata = fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
let age = modified.elapsed().ok()?;
let mut stale_by = None;
if age > self.ttl {
if is_offline() {
stale_by = Some(age - self.ttl);
} else {
let _ = fs::remove_file(&path);
return None;
}
}
let data = fs::read_to_string(&path).ok()?;
let envelope: CacheEnvelope<T> = serde_json::from_str(&data).ok()?;
if envelope.schema_version != CACHE_SCHEMA_VERSION {
let _ = fs::remove_file(&path);
return None;
}
Some((envelope.payload, stale_by))
}
pub fn set_named<V: Serialize + ?Sized>(&self, file_name: &str, value: &V) -> Result<()> {
if !self.cache_dir.exists() {
fs::create_dir_all(&self.cache_dir)?;
}
let envelope = CacheEnvelope {
schema_version: CACHE_SCHEMA_VERSION,
payload: value,
};
let data = serde_json::to_string(&envelope)?;
write_atomic(&self.path_for(file_name), data.as_bytes())?;
Ok(())
}
#[must_use]
pub fn get(&self, key: &CacheKey) -> Option<T> {
self.get_named(&key.to_filename())
}
pub fn set<V: Serialize + ?Sized>(&self, key: &CacheKey, value: &V) -> Result<()> {
self.set_named(&key.to_filename(), value)
}
pub fn remove(&self, key: &CacheKey) -> Result<()> {
let path = self.path_for(&key.to_filename());
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
pub fn clear(&self) -> Result<()> {
if self.cache_dir.exists() {
for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
if entry.path().extension().is_some_and(|e| e == "json") {
let _ = fs::remove_file(entry.path());
}
}
}
Ok(())
}
#[must_use]
pub fn stats(&self) -> CacheStats {
let mut stats = CacheStats::default();
if let Ok(entries) = fs::read_dir(&self.cache_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|e| e == "json") {
stats.total_entries += 1;
if let Ok(metadata) = entry.metadata() {
stats.total_size += metadata.len();
if let Ok(modified) = metadata.modified()
&& let Ok(age) = modified.elapsed()
&& age > self.ttl
{
stats.expired_entries += 1;
}
}
}
}
}
stats
}
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.map_or_else(|| "cache".to_string(), |n| n.to_string_lossy().into_owned());
let tmp = parent.join(format!(".{file_name}.{}.tmp", std::process::id()));
fs::write(&tmp, bytes)?;
match fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_file(&tmp);
Err(e.into())
}
}
}
#[derive(Debug, Default)]
pub struct CacheStats {
pub total_entries: usize,
pub expired_entries: usize,
pub total_size: u64,
}
pub trait EnrichmentSource {
type Stats;
fn name(&self) -> &'static str;
fn cache_namespace(&self) -> &'static str;
fn cache_ttl(&self) -> Duration;
fn enrich(&mut self, components: &mut [Component]) -> Result<Self::Stats>;
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Payload {
value: u32,
label: String,
}
fn sample() -> Payload {
Payload {
value: 7,
label: "hello".to_string(),
}
}
#[test]
fn namespaced_cache_dir_includes_namespace() {
let dir = namespaced_cache_dir("osv");
let s = dir.to_string_lossy();
assert!(s.contains("sbom-tools"));
assert!(s.ends_with("osv"));
}
#[test]
fn backoff_is_exponential_and_capped() {
assert_eq!(backoff_delay(1, None), Duration::from_secs(1));
assert_eq!(backoff_delay(2, None), Duration::from_secs(2));
assert_eq!(backoff_delay(3, None), Duration::from_secs(4));
assert_eq!(backoff_delay(20, None), MAX_BACKOFF);
}
#[test]
fn backoff_honors_retry_after_but_caps_it() {
assert_eq!(
backoff_delay(1, Some(Duration::from_secs(3))),
Duration::from_secs(3)
);
assert_eq!(
backoff_delay(1, Some(Duration::from_secs(120))),
MAX_BACKOFF
);
}
#[test]
fn cache_roundtrip_survives_reopen() {
let tmp = tempfile::tempdir().unwrap();
{
let cache: JsonCache<Payload> =
JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
cache.set_named("entry", &sample()).unwrap();
}
let cache: JsonCache<Payload> =
JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
assert_eq!(cache.get_named("entry"), Some(sample()));
}
#[test]
fn atomic_write_leaves_no_temp_files() {
let tmp = tempfile::tempdir().unwrap();
let cache: JsonCache<Payload> =
JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
cache.set_named("entry", &sample()).unwrap();
let tmp_files: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
.collect();
assert!(tmp_files.is_empty(), "temp file should be renamed away");
}
#[test]
fn schema_version_mismatch_invalidates_entry() {
let tmp = tempfile::tempdir().unwrap();
let cache: JsonCache<Payload> =
JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
let stale = format!(
"{{\"schema_version\":{},\"payload\":{{\"value\":1,\"label\":\"x\"}}}}",
CACHE_SCHEMA_VERSION + 1
);
let path = cache.path_for("entry");
fs::write(&path, stale).unwrap();
assert!(
cache.get_named("entry").is_none(),
"mismatched schema version must be a miss"
);
assert!(!path.exists(), "stale entry should be evicted");
}
#[test]
fn ttl_expiry_evicts_entry() {
let tmp = tempfile::tempdir().unwrap();
let cache: JsonCache<Payload> =
JsonCache::new(tmp.path().to_path_buf(), Duration::from_millis(200)).unwrap();
cache.set_named("entry", &sample()).unwrap();
assert!(cache.get_named("entry").is_some());
std::thread::sleep(Duration::from_millis(400));
assert!(
cache.get_named("entry").is_none(),
"entry past TTL must be a miss"
);
assert!(
!cache.path_for("entry").exists(),
"expired entry should be evicted"
);
}
#[cfg(feature = "enrichment")]
#[test]
fn read_bounded_rejects_oversized_body() {
use httpmock::prelude::*;
let server = MockServer::start();
let body = "x".repeat(1024);
let mock = server.mock(|when, then| {
when.method(GET).path("/big");
then.status(200).body(&body);
});
let client = http_client(Duration::from_secs(5)).unwrap();
let resp = get_with_retry(&client, &format!("{}/big", server.base_url()), 0).unwrap();
mock.assert();
let err =
read_bounded_with_max(resp, 16).expect_err("a body over the cap must be rejected");
assert!(
err.to_string().contains("too large"),
"error must explain the size-cap rejection, got: {err}"
);
let detail = std::error::Error::source(&err)
.map(ToString::to_string)
.unwrap_or_default();
assert!(
detail.contains("exceeds"),
"source must carry the byte-precise detail, got: {detail}"
);
}
#[cfg(feature = "enrichment")]
#[test]
fn read_bounded_accepts_body_within_cap() {
use httpmock::prelude::*;
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/ok");
then.status(200).body("hello");
});
let client = http_client(Duration::from_secs(5)).unwrap();
let resp = get_with_retry(&client, &format!("{}/ok", server.base_url()), 0).unwrap();
mock.assert();
let bytes = read_bounded_with_max(resp, MAX_RESPONSE_BYTES).unwrap();
assert_eq!(bytes, b"hello");
}
#[test]
fn clear_and_stats() {
let tmp = tempfile::tempdir().unwrap();
let cache: JsonCache<Payload> =
JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
cache.set_named("a.json", &sample()).unwrap();
cache.set_named("b.json", &sample()).unwrap();
assert_eq!(cache.stats().total_entries, 2);
cache.clear().unwrap();
assert_eq!(cache.stats().total_entries, 0);
}
}