use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use crate::enrichment::source::root_cache_dir;
use crate::pipeline::exit_codes;
#[derive(Debug, Clone, clap::Subcommand)]
pub enum CacheAction {
Status,
Warm {
sbom: PathBuf,
#[arg(long)]
all_sources: bool,
},
Clear,
Export {
path: PathBuf,
},
Import {
path: PathBuf,
},
}
pub fn run_cache(action: CacheAction, quiet: bool) -> Result<i32> {
match action {
CacheAction::Status => cache_status(quiet),
CacheAction::Warm { sbom, all_sources } => cache_warm(&sbom, all_sources, quiet),
CacheAction::Clear => cache_clear(quiet),
CacheAction::Export { path } => cache_export(&path, quiet),
CacheAction::Import { path } => cache_import(&path, quiet),
}
}
const SOURCE_NAMESPACES: &[&str] = &["osv", "eol", "kev", "epss", "staleness", "huggingface"];
struct SourceStatus {
name: String,
entries: usize,
total_size: u64,
oldest: Option<Duration>,
newest: Option<Duration>,
}
fn source_status(name: &str, dir: &Path) -> SourceStatus {
let mut entries = 0usize;
let mut total_size = 0u64;
let mut oldest: Option<Duration> = None;
let mut newest: Option<Duration> = None;
if let Ok(read_dir) = fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
entries += 1;
if let Ok(meta) = entry.metadata() {
total_size += meta.len();
if let Ok(modified) = meta.modified()
&& let Ok(age) = modified.elapsed()
{
oldest = Some(oldest.map_or(age, |o| o.max(age)));
newest = Some(newest.map_or(age, |n| n.min(age)));
}
}
}
}
SourceStatus {
name: name.to_string(),
entries,
total_size,
oldest,
newest,
}
}
fn cache_status(quiet: bool) -> Result<i32> {
let root = root_cache_dir();
if !root.exists() {
if !quiet {
println!("No cache directory yet ({}).", root.display());
}
return Ok(exit_codes::SUCCESS);
}
let mut total_entries = 0usize;
let mut total_size = 0u64;
let mut rows: Vec<SourceStatus> = Vec::new();
for ns in SOURCE_NAMESPACES {
let dir = root.join(ns);
if dir.exists() {
let status = source_status(ns, &dir);
total_entries += status.entries;
total_size += status.total_size;
rows.push(status);
}
}
if quiet {
return Ok(exit_codes::SUCCESS);
}
println!("Cache directory: {}", root.display());
if rows.is_empty() {
println!(" (no cached enrichment data)");
return Ok(exit_codes::SUCCESS);
}
println!(
"{:<12} {:>8} {:>12} {:>12} {:>12}",
"SOURCE", "ENTRIES", "SIZE", "OLDEST", "NEWEST"
);
for row in &rows {
println!(
"{:<12} {:>8} {:>12} {:>12} {:>12}",
row.name,
row.entries,
human_size(row.total_size),
row.oldest.map_or_else(|| "-".to_string(), human_age),
row.newest.map_or_else(|| "-".to_string(), human_age),
);
}
println!(
"{:<12} {:>8} {:>12}",
"TOTAL",
total_entries,
human_size(total_size)
);
Ok(exit_codes::SUCCESS)
}
fn cache_warm(sbom_path: &Path, all_sources: bool, quiet: bool) -> Result<i32> {
use crate::config::EnrichmentConfig;
if crate::enrichment::source::is_offline() {
anyhow::bail!("cannot warm the cache in offline mode: run `cache warm` while online");
}
let mut parsed = crate::pipeline::parse_sbom_with_context(sbom_path, quiet)?;
let mut config = EnrichmentConfig::osv();
config.enable_eol = all_sources;
config.enable_kev = all_sources;
config.enable_epss = all_sources;
config.enable_staleness = all_sources;
config.enable_huggingface = all_sources;
config.bypass_cache = true;
config.offline = false;
let stats = crate::pipeline::enrich_sbom_full(parsed.sbom_mut(), &config, quiet);
if !quiet {
for warning in &stats.warnings {
eprintln!("Warning: {warning}");
}
let n = parsed.sbom().component_count();
println!(
"Warmed cache for {n} component(s) from {} ({}).",
sbom_path.display(),
if all_sources {
"OSV, EOL, KEV, EPSS, staleness, HuggingFace"
} else {
"OSV"
}
);
}
Ok(exit_codes::SUCCESS)
}
fn cache_clear(quiet: bool) -> Result<i32> {
let root = root_cache_dir();
if !root.exists() {
if !quiet {
println!("Nothing to clear ({} does not exist).", root.display());
}
return Ok(exit_codes::SUCCESS);
}
let mut removed = 0usize;
for ns in SOURCE_NAMESPACES {
let dir = root.join(ns);
if let Ok(read_dir) = fs::read_dir(&dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") && fs::remove_file(&path).is_ok() {
removed += 1;
}
}
}
}
if !quiet {
println!("Cleared {removed} cached entr{}.", plural(removed));
}
Ok(exit_codes::SUCCESS)
}
fn cache_export(dest: &Path, quiet: bool) -> Result<i32> {
let root = root_cache_dir();
if !root.exists() {
anyhow::bail!("no cache to export ({} does not exist)", root.display());
}
fs::create_dir_all(dest)
.with_context(|| format!("creating export directory {}", dest.display()))?;
let copied = copy_dir_recursive(&root, dest)?;
if !quiet {
println!(
"Exported {copied} cache file(s) to {} (copy this to the air-gapped host, then `cache import`).",
dest.display()
);
}
Ok(exit_codes::SUCCESS)
}
fn cache_import(src: &Path, quiet: bool) -> Result<i32> {
if !src.exists() {
anyhow::bail!("import source {} does not exist", src.display());
}
let root = root_cache_dir();
fs::create_dir_all(&root)
.with_context(|| format!("creating cache directory {}", root.display()))?;
let copied = copy_dir_recursive(src, &root)?;
if !quiet {
println!(
"Imported {copied} cache file(s) into {}. Run with --offline to use them.",
root.display()
);
}
Ok(exit_codes::SUCCESS)
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<usize> {
let mut copied = 0usize;
for entry in
fs::read_dir(src).with_context(|| format!("reading directory {}", src.display()))?
{
let entry = entry?;
let file_type = entry.file_type()?;
let from = entry.path();
let to = dest.join(entry.file_name());
if file_type.is_dir() {
fs::create_dir_all(&to).with_context(|| format!("creating {}", to.display()))?;
copied += copy_dir_recursive(&from, &to)?;
} else if file_type.is_file() {
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).ok();
}
fs::copy(&from, &to)
.with_context(|| format!("copying {} -> {}", from.display(), to.display()))?;
copied += 1;
}
}
Ok(copied)
}
fn human_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
let b = bytes as f64;
if b >= MB {
format!("{:.1} MB", b / MB)
} else if b >= KB {
format!("{:.1} KB", b / KB)
} else {
format!("{bytes} B")
}
}
fn human_age(age: Duration) -> String {
let secs = age.as_secs();
if secs >= 86_400 {
format!("{}d", secs / 86_400)
} else if secs >= 3_600 {
format!("{}h", secs / 3_600)
} else if secs >= 60 {
format!("{}m", secs / 60)
} else {
"<1m".to_string()
}
}
const fn plural(n: usize) -> &'static str {
if n == 1 { "y" } else { "ies" }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_size_formats() {
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(2048), "2.0 KB");
assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB");
}
#[test]
fn human_age_formats() {
assert_eq!(human_age(Duration::from_secs(30)), "<1m");
assert_eq!(human_age(Duration::from_secs(120)), "2m");
assert_eq!(human_age(Duration::from_secs(7200)), "2h");
assert_eq!(human_age(Duration::from_secs(2 * 86_400)), "2d");
}
#[test]
fn copy_dir_recursive_roundtrip() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
fs::create_dir_all(src.path().join("osv")).unwrap();
fs::write(src.path().join("osv").join("a.json"), "{}").unwrap();
fs::write(src.path().join("osv").join("b.json"), "{}").unwrap();
let copied = copy_dir_recursive(src.path(), dst.path()).unwrap();
assert_eq!(copied, 2);
assert!(dst.path().join("osv").join("a.json").exists());
assert!(dst.path().join("osv").join("b.json").exists());
}
#[test]
fn source_namespaces_cover_epss_and_huggingface() {
assert!(
SOURCE_NAMESPACES.contains(&"epss"),
"epss namespace must be covered by cache status/clear"
);
assert!(
SOURCE_NAMESPACES.contains(&"huggingface"),
"huggingface namespace must be covered by cache status/clear"
);
let epss_dir = crate::enrichment::epss::EpssClientConfig::default().cache_dir;
assert!(
epss_dir.ends_with("epss"),
"EPSS client writes under the 'epss' namespace"
);
let hf_dir = crate::enrichment::huggingface::HuggingFaceConfig::default().cache_dir;
assert!(
hf_dir.ends_with("huggingface"),
"HuggingFace client writes under the 'huggingface' namespace"
);
}
#[test]
fn clear_logic_removes_all_namespaces() {
let root = tempfile::tempdir().unwrap();
let mut expected_removed = 0usize;
for ns in SOURCE_NAMESPACES {
let dir = root.path().join(ns);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("entry.json"), "{}").unwrap();
expected_removed += 1;
}
let mut removed = 0usize;
for ns in SOURCE_NAMESPACES {
let dir = root.path().join(ns);
if let Ok(read_dir) = fs::read_dir(&dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json")
&& fs::remove_file(&path).is_ok()
{
removed += 1;
}
}
}
}
assert_eq!(removed, expected_removed);
assert!(
!root.path().join("epss").join("entry.json").exists(),
"epss entry must be cleared"
);
assert!(
!root.path().join("huggingface").join("entry.json").exists(),
"huggingface entry must be cleared"
);
}
}