1use std::fs;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22use anyhow::{Context, Result};
23
24use crate::enrichment::source::root_cache_dir;
25use crate::pipeline::exit_codes;
26
27#[derive(Debug, Clone, clap::Subcommand)]
29pub enum CacheAction {
30 Status,
32 Warm {
34 sbom: PathBuf,
36 #[arg(long)]
38 all_sources: bool,
39 },
40 Clear,
42 Export {
44 path: PathBuf,
46 },
47 Import {
49 path: PathBuf,
51 },
52}
53
54pub fn run_cache(action: CacheAction, quiet: bool) -> Result<i32> {
56 match action {
57 CacheAction::Status => cache_status(quiet),
58 CacheAction::Warm { sbom, all_sources } => cache_warm(&sbom, all_sources, quiet),
59 CacheAction::Clear => cache_clear(quiet),
60 CacheAction::Export { path } => cache_export(&path, quiet),
61 CacheAction::Import { path } => cache_import(&path, quiet),
62 }
63}
64
65const SOURCE_NAMESPACES: &[&str] = &["osv", "eol", "kev", "epss", "staleness", "huggingface"];
72
73struct SourceStatus {
75 name: String,
76 entries: usize,
77 total_size: u64,
78 oldest: Option<Duration>,
79 newest: Option<Duration>,
80}
81
82fn source_status(name: &str, dir: &Path) -> SourceStatus {
83 let mut status = SourceStatus {
84 name: name.to_string(),
85 entries: 0,
86 total_size: 0,
87 oldest: None,
88 newest: None,
89 };
90 collect_status(dir, &mut status);
91 status
92}
93
94fn collect_status(dir: &Path, status: &mut SourceStatus) {
101 if let Ok(read_dir) = fs::read_dir(dir) {
102 for entry in read_dir.flatten() {
103 let path = entry.path();
104 if entry.file_type().is_ok_and(|t| t.is_dir()) {
105 collect_status(&path, status);
106 continue;
107 }
108 if path.extension().is_none_or(|e| e != "json") {
109 continue;
110 }
111 status.entries += 1;
112 if let Ok(meta) = entry.metadata() {
113 status.total_size += meta.len();
114 if let Ok(modified) = meta.modified()
115 && let Ok(age) = modified.elapsed()
116 {
117 status.oldest = Some(status.oldest.map_or(age, |o| o.max(age)));
118 status.newest = Some(status.newest.map_or(age, |n| n.min(age)));
119 }
120 }
121 }
122 }
123}
124
125fn cache_status(quiet: bool) -> Result<i32> {
126 let root = root_cache_dir();
127 if !root.exists() {
128 if !quiet {
129 println!("No cache directory yet ({}).", root.display());
130 }
131 return Ok(exit_codes::SUCCESS);
132 }
133
134 let mut total_entries = 0usize;
135 let mut total_size = 0u64;
136 let mut rows: Vec<SourceStatus> = Vec::new();
137 for ns in SOURCE_NAMESPACES {
138 let dir = root.join(ns);
139 if dir.exists() {
140 let status = source_status(ns, &dir);
141 total_entries += status.entries;
142 total_size += status.total_size;
143 rows.push(status);
144 }
145 }
146
147 if quiet {
148 return Ok(exit_codes::SUCCESS);
149 }
150
151 println!("Cache directory: {}", root.display());
152 if rows.is_empty() {
153 println!(" (no cached enrichment data)");
154 return Ok(exit_codes::SUCCESS);
155 }
156
157 println!(
158 "{:<12} {:>8} {:>12} {:>12} {:>12}",
159 "SOURCE", "ENTRIES", "SIZE", "OLDEST", "NEWEST"
160 );
161 for row in &rows {
162 println!(
163 "{:<12} {:>8} {:>12} {:>12} {:>12}",
164 row.name,
165 row.entries,
166 human_size(row.total_size),
167 row.oldest.map_or_else(|| "-".to_string(), human_age),
168 row.newest.map_or_else(|| "-".to_string(), human_age),
169 );
170 }
171 println!(
172 "{:<12} {:>8} {:>12}",
173 "TOTAL",
174 total_entries,
175 human_size(total_size)
176 );
177
178 Ok(exit_codes::SUCCESS)
179}
180
181fn cache_warm(sbom_path: &Path, all_sources: bool, quiet: bool) -> Result<i32> {
184 use crate::config::EnrichmentConfig;
185
186 if crate::enrichment::source::is_offline() {
188 anyhow::bail!("cannot warm the cache in offline mode: run `cache warm` while online");
189 }
190
191 let mut parsed = crate::pipeline::parse_sbom_with_context(sbom_path, quiet)?;
192
193 let mut config = EnrichmentConfig::osv();
194 config.enable_eol = all_sources;
195 config.enable_kev = all_sources;
196 config.enable_epss = all_sources;
197 config.enable_staleness = all_sources;
198 config.enable_huggingface = all_sources;
199 config.bypass_cache = true;
201 config.offline = false;
202
203 let stats = crate::pipeline::enrich_sbom_full(parsed.sbom_mut(), &config, quiet);
204
205 if !quiet {
206 for warning in &stats.warnings {
207 eprintln!("Warning: {warning}");
208 }
209 let n = parsed.sbom().component_count();
210 println!(
211 "Warmed cache for {n} component(s) from {} ({}).",
212 sbom_path.display(),
213 if all_sources {
214 "OSV, EOL, KEV, EPSS, staleness, HuggingFace"
215 } else {
216 "OSV"
217 }
218 );
219 }
220
221 Ok(exit_codes::SUCCESS)
222}
223
224fn cache_clear(quiet: bool) -> Result<i32> {
225 let root = root_cache_dir();
226 if !root.exists() {
227 if !quiet {
228 println!("Nothing to clear ({} does not exist).", root.display());
229 }
230 return Ok(exit_codes::SUCCESS);
231 }
232
233 let mut removed = 0usize;
234 for ns in SOURCE_NAMESPACES {
235 removed += remove_json_recursive(&root.join(ns));
236 }
237
238 if !quiet {
239 println!("Cleared {removed} cached entr{}.", plural(removed));
240 }
241 Ok(exit_codes::SUCCESS)
242}
243
244fn remove_json_recursive(dir: &Path) -> usize {
249 let mut removed = 0usize;
250 if let Ok(read_dir) = fs::read_dir(dir) {
251 for entry in read_dir.flatten() {
252 let path = entry.path();
253 if entry.file_type().is_ok_and(|t| t.is_dir()) {
254 removed += remove_json_recursive(&path);
255 let _ = fs::remove_dir(&path);
257 } else if path.extension().is_some_and(|e| e == "json")
258 && fs::remove_file(&path).is_ok()
259 {
260 removed += 1;
261 }
262 }
263 }
264 removed
265}
266
267fn cache_export(dest: &Path, quiet: bool) -> Result<i32> {
268 let root = root_cache_dir();
269 if !root.exists() {
270 anyhow::bail!("no cache to export ({} does not exist)", root.display());
271 }
272
273 if paths_overlap(&root, dest) == Some(Containment::SecondInsideFirst) {
276 anyhow::bail!(
277 "refusing to export the cache into itself: {} is inside the cache directory {}",
278 dest.display(),
279 root.display()
280 );
281 }
282
283 fs::create_dir_all(dest)
284 .with_context(|| format!("creating export directory {}", dest.display()))?;
285 let copied = copy_dir_recursive(&root, dest)?;
286
287 if !quiet {
288 println!(
289 "Exported {copied} cache file(s) to {} (copy this to the air-gapped host, then `cache import`).",
290 dest.display()
291 );
292 }
293 Ok(exit_codes::SUCCESS)
294}
295
296fn cache_import(src: &Path, quiet: bool) -> Result<i32> {
297 if !src.exists() {
298 anyhow::bail!("import source {} does not exist", src.display());
299 }
300
301 let root = root_cache_dir();
302 if paths_overlap(&root, src).is_some() {
305 anyhow::bail!(
306 "refusing to import the cache into itself: {} overlaps the cache directory {}",
307 src.display(),
308 root.display()
309 );
310 }
311 fs::create_dir_all(&root)
312 .with_context(|| format!("creating cache directory {}", root.display()))?;
313 let copied = copy_dir_recursive(src, &root)?;
314
315 if !quiet {
316 println!(
317 "Imported {copied} cache file(s) into {}. Run with --offline to use them.",
318 root.display()
319 );
320 }
321 Ok(exit_codes::SUCCESS)
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326enum Containment {
327 Same,
329 SecondInsideFirst,
331 FirstInsideSecond,
333}
334
335fn paths_overlap(a: &Path, b: &Path) -> Option<Containment> {
339 let a = resolve_for_containment(a);
340 let b = resolve_for_containment(b);
341 if a == b {
342 Some(Containment::Same)
343 } else if b.starts_with(&a) {
344 Some(Containment::SecondInsideFirst)
345 } else if a.starts_with(&b) {
346 Some(Containment::FirstInsideSecond)
347 } else {
348 None
349 }
350}
351
352fn resolve_for_containment(path: &Path) -> PathBuf {
357 let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
358 let mut existing = abs.as_path();
359 let mut rest: Vec<std::ffi::OsString> = Vec::new();
360 while !existing.exists() {
361 match (existing.parent(), existing.file_name()) {
362 (Some(parent), Some(name)) => {
363 rest.push(name.to_os_string());
364 existing = parent;
365 }
366 _ => return abs,
367 }
368 }
369 let mut resolved = fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf());
370 for name in rest.iter().rev() {
371 resolved.push(name);
372 }
373 resolved
374}
375
376fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<usize> {
379 let mut copied = 0usize;
380 for entry in
381 fs::read_dir(src).with_context(|| format!("reading directory {}", src.display()))?
382 {
383 let entry = entry?;
384 let file_type = entry.file_type()?;
385 let from = entry.path();
386 let to = dest.join(entry.file_name());
387 if file_type.is_dir() {
388 fs::create_dir_all(&to).with_context(|| format!("creating {}", to.display()))?;
389 copied += copy_dir_recursive(&from, &to)?;
390 } else if file_type.is_file() {
391 if let Some(parent) = to.parent() {
392 fs::create_dir_all(parent).ok();
393 }
394 fs::copy(&from, &to)
395 .with_context(|| format!("copying {} -> {}", from.display(), to.display()))?;
396 copied += 1;
397 }
398 }
399 Ok(copied)
400}
401
402fn human_size(bytes: u64) -> String {
404 const KB: f64 = 1024.0;
405 const MB: f64 = KB * 1024.0;
406 let b = bytes as f64;
407 if b >= MB {
408 format!("{:.1} MB", b / MB)
409 } else if b >= KB {
410 format!("{:.1} KB", b / KB)
411 } else {
412 format!("{bytes} B")
413 }
414}
415
416fn human_age(age: Duration) -> String {
418 let secs = age.as_secs();
419 if secs >= 86_400 {
420 format!("{}d", secs / 86_400)
421 } else if secs >= 3_600 {
422 format!("{}h", secs / 3_600)
423 } else if secs >= 60 {
424 format!("{}m", secs / 60)
425 } else {
426 "<1m".to_string()
427 }
428}
429
430const fn plural(n: usize) -> &'static str {
431 if n == 1 { "y" } else { "ies" }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn human_size_formats() {
440 assert_eq!(human_size(512), "512 B");
441 assert_eq!(human_size(2048), "2.0 KB");
442 assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB");
443 }
444
445 #[test]
446 fn human_age_formats() {
447 assert_eq!(human_age(Duration::from_secs(30)), "<1m");
448 assert_eq!(human_age(Duration::from_secs(120)), "2m");
449 assert_eq!(human_age(Duration::from_secs(7200)), "2h");
450 assert_eq!(human_age(Duration::from_secs(2 * 86_400)), "2d");
451 }
452
453 #[test]
454 fn paths_overlap_detects_nesting_even_for_nonexistent_dest() {
455 let root = tempfile::tempdir().unwrap();
456 let other = tempfile::tempdir().unwrap();
457
458 let dest = root.path().join("sub").join("deeper");
460 assert_eq!(
461 paths_overlap(root.path(), &dest),
462 Some(Containment::SecondInsideFirst)
463 );
464 assert_eq!(
466 paths_overlap(root.path(), root.path()),
467 Some(Containment::Same)
468 );
469 let inner = other.path().join("cache");
471 fs::create_dir_all(&inner).unwrap();
472 assert_eq!(
473 paths_overlap(&inner, other.path()),
474 Some(Containment::FirstInsideSecond)
475 );
476 assert_eq!(paths_overlap(root.path(), other.path()), None);
478 }
479
480 #[test]
481 fn source_status_counts_nested_entries() {
482 let dir = tempfile::tempdir().unwrap();
485 fs::write(dir.path().join("top.json"), "{}").unwrap();
486 let nested = dir.path().join("nested").join("deep");
487 fs::create_dir_all(&nested).unwrap();
488 fs::write(nested.join("a.json"), "{\"k\":1}").unwrap();
489 fs::write(nested.join("ignored.txt"), "x").unwrap();
490
491 let status = source_status("osv", dir.path());
492 assert_eq!(status.entries, 2, "top-level + nested json must count");
493 assert!(status.total_size >= 2);
494 }
495
496 #[test]
497 fn remove_json_recursive_clears_nested_entries_and_prunes_dirs() {
498 let dir = tempfile::tempdir().unwrap();
499 fs::write(dir.path().join("top.json"), "{}").unwrap();
500 let nested = dir.path().join("nested").join("deep");
501 fs::create_dir_all(&nested).unwrap();
502 fs::write(nested.join("a.json"), "{}").unwrap();
503 fs::write(nested.join("keep.txt"), "x").unwrap();
504
505 let removed = remove_json_recursive(dir.path());
506 assert_eq!(removed, 2);
507 assert!(!dir.path().join("top.json").exists());
508 assert!(!nested.join("a.json").exists());
509 assert!(nested.join("keep.txt").exists());
511 }
512
513 #[test]
514 fn copy_dir_recursive_roundtrip() {
515 let src = tempfile::tempdir().unwrap();
516 let dst = tempfile::tempdir().unwrap();
517 fs::create_dir_all(src.path().join("osv")).unwrap();
518 fs::write(src.path().join("osv").join("a.json"), "{}").unwrap();
519 fs::write(src.path().join("osv").join("b.json"), "{}").unwrap();
520
521 let copied = copy_dir_recursive(src.path(), dst.path()).unwrap();
522 assert_eq!(copied, 2);
523 assert!(dst.path().join("osv").join("a.json").exists());
524 assert!(dst.path().join("osv").join("b.json").exists());
525 }
526
527 #[test]
532 fn source_namespaces_cover_epss_and_huggingface() {
533 assert!(
534 SOURCE_NAMESPACES.contains(&"epss"),
535 "epss namespace must be covered by cache status/clear"
536 );
537 assert!(
538 SOURCE_NAMESPACES.contains(&"huggingface"),
539 "huggingface namespace must be covered by cache status/clear"
540 );
541
542 let epss_dir = crate::enrichment::epss::EpssClientConfig::default().cache_dir;
546 assert!(
547 epss_dir.ends_with("epss"),
548 "EPSS client writes under the 'epss' namespace"
549 );
550 let hf_dir = crate::enrichment::huggingface::HuggingFaceConfig::default().cache_dir;
551 assert!(
552 hf_dir.ends_with("huggingface"),
553 "HuggingFace client writes under the 'huggingface' namespace"
554 );
555 }
556
557 #[test]
560 fn clear_logic_removes_all_namespaces() {
561 let root = tempfile::tempdir().unwrap();
562 let mut expected_removed = 0usize;
563 for ns in SOURCE_NAMESPACES {
564 let dir = root.path().join(ns);
565 fs::create_dir_all(&dir).unwrap();
566 fs::write(dir.join("entry.json"), "{}").unwrap();
567 expected_removed += 1;
568 }
569
570 let mut removed = 0usize;
572 for ns in SOURCE_NAMESPACES {
573 let dir = root.path().join(ns);
574 if let Ok(read_dir) = fs::read_dir(&dir) {
575 for entry in read_dir.flatten() {
576 let path = entry.path();
577 if path.extension().is_some_and(|e| e == "json")
578 && fs::remove_file(&path).is_ok()
579 {
580 removed += 1;
581 }
582 }
583 }
584 }
585
586 assert_eq!(removed, expected_removed);
587 assert!(
588 !root.path().join("epss").join("entry.json").exists(),
589 "epss entry must be cleared"
590 );
591 assert!(
592 !root.path().join("huggingface").join("entry.json").exists(),
593 "huggingface entry must be cleared"
594 );
595 }
596}