Skip to main content

sbom_tools/cli/
cache.rs

1//! CLI handler for the `cache` command.
2//!
3//! Manages the on-disk enrichment cache so that air-gapped (`--offline`) runs
4//! are fully served from local data:
5//!
6//! - `cache status` — list cached sources, entry counts, ages, total size.
7//! - `cache warm <sbom>` — pre-fetch enrichment data for an SBOM's components.
8//! - `cache clear` — remove all cached entries.
9//! - `cache export <path>` / `cache import <path>` — copy the whole cache tree
10//!   for sneakernet transfer between an online and an air-gapped machine.
11//!
12//! Export/import use a plain recursive directory copy (no archive format, no new
13//! dependency): the cache is already a tree of small JSON files, so a directory
14//! copy is the simplest portable bundle and keeps the tool dependency-free under
15//! cargo-deny. The destination is a self-contained `sbom-tools` cache tree the
16//! `--offline` reader consumes directly.
17
18use 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/// Cache management action.
28#[derive(Debug, Clone, clap::Subcommand)]
29pub enum CacheAction {
30    /// List cached sources with entry counts, ages, and total size
31    Status,
32    /// Pre-fetch enrichment data for an SBOM so a later --offline run is served
33    Warm {
34        /// SBOM file to warm the cache for
35        sbom: PathBuf,
36        /// Warm every source (OSV, EOL, KEV, EPSS, staleness, HuggingFace), not just OSV
37        #[arg(long)]
38        all_sources: bool,
39    },
40    /// Remove all cached enrichment entries
41    Clear,
42    /// Copy the whole cache tree to a directory for sneakernet transfer
43    Export {
44        /// Destination directory (created if absent)
45        path: PathBuf,
46    },
47    /// Import a previously exported cache tree into the local cache
48    Import {
49        /// Source directory produced by `cache export`
50        path: PathBuf,
51    },
52}
53
54/// Run the `cache` command.
55pub 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
65/// The enrichment source namespaces that live under the root cache directory.
66///
67/// Must list every namespace any client writes under (see each
68/// `namespaced_cache_dir(...)` call) so `cache status`/`cache clear` cover them
69/// all; EPSS and HuggingFace were previously omitted, so their caches were never
70/// purged or reported.
71const SOURCE_NAMESPACES: &[&str] = &["osv", "eol", "kev", "epss", "staleness", "huggingface"];
72
73/// Aggregate counts for one source's cache directory.
74struct 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
94/// Recursively fold every `*.json` file under `dir` into `status`.
95///
96/// Export/import copy the cache as a full tree, so nested entries (e.g. a
97/// source that shards its cache into subdirectories, or an imported bundle)
98/// must be counted too — `status` and `clear` must see exactly what
99/// `export`/`import` copy.
100fn 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
181/// Warm the cache by enriching the SBOM with all (or just OSV) sources, forcing
182/// fresh fetches so the on-disk cache is fully populated for a later offline run.
183fn cache_warm(sbom_path: &Path, all_sources: bool, quiet: bool) -> Result<i32> {
184    use crate::config::EnrichmentConfig;
185
186    // Warming requires the network, so it must not run in offline mode.
187    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    // Force fresh fetches so every queryable component lands in the cache.
200    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
244/// Recursively remove every `*.json` file under `dir`, pruning subdirectories
245/// that become empty. Returns the number of files removed. `clear` must
246/// remove exactly what `import` brought in (a full tree), not just the
247/// top-level entries.
248fn 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                // Prune the subdirectory if it is now empty (best-effort).
256                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    // A destination inside the cache tree would be copied into while it is
274    // being read, nesting the cache into itself until the path length limit.
275    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    // Importing from inside the cache (or from a directory that contains the
303    // cache) would copy the live cache into itself while reading it.
304    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/// How two paths overlap (see [`paths_overlap`]).
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326enum Containment {
327    /// The two paths resolve to the same directory.
328    Same,
329    /// The second path is inside the first.
330    SecondInsideFirst,
331    /// The first path is inside the second.
332    FirstInsideSecond,
333}
334
335/// Determine whether `a` and `b` overlap (same directory, or one inside the
336/// other), after resolving both to absolute, symlink-free forms. Returns
337/// `None` when the paths are disjoint.
338fn 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
352/// Resolve `path` for containment checks: absolute, with symlinks in the
353/// existing portion resolved. The path itself may not exist yet — the nearest
354/// existing ancestor is canonicalized and the non-existing remainder
355/// re-appended, so `<cache>/new-subdir` still compares as inside `<cache>`.
356fn 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
376/// Recursively copy every file from `src` into `dest`, mirroring the directory
377/// structure. Returns the number of files copied.
378fn 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
402/// Human-readable byte size (e.g. `12.3 KB`).
403fn 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
416/// Human-readable age (e.g. `3d`, `5h`, `12m`, `<1m`).
417fn 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        // dest inside root (does not exist yet) — the export runaway case.
459        let dest = root.path().join("sub").join("deeper");
460        assert_eq!(
461            paths_overlap(root.path(), &dest),
462            Some(Containment::SecondInsideFirst)
463        );
464        // Same directory.
465        assert_eq!(
466            paths_overlap(root.path(), root.path()),
467            Some(Containment::Same)
468        );
469        // root inside src — the import-from-parent runaway case.
470        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        // Disjoint paths do not overlap.
477        assert_eq!(paths_overlap(root.path(), other.path()), None);
478    }
479
480    #[test]
481    fn source_status_counts_nested_entries() {
482        // status must see what import copied: full trees, not only the
483        // namespace directory's top level.
484        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        // Non-json files survive; their directory is not pruned.
510        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    /// Regression: `cache status`/`cache clear` enumerate a fixed namespace
528    /// list. EPSS and HuggingFace clients write under their own namespaces, so
529    /// those must appear in the list or their caches are silently never purged
530    /// or reported.
531    #[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        // The default cache dirs of the EPSS/HF clients must end in exactly the
543        // namespace strings the const enumerates, tying this test to the real
544        // write locations.
545        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    /// Regression: clearing the cache root removes EPSS and HuggingFace JSON
558    /// entries, not just the original four namespaces.
559    #[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        // Mirror cache_clear's per-namespace removal against the temp root.
571        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}