Skip to main content

sbom_tools/enrichment/
source.rs

1//! Shared enrichment infrastructure: cache directory, HTTP client, retry
2//! policy, and a generic versioned file cache.
3//!
4//! Every enrichment source (OSV, KEV, EOL, staleness) used to carry its own
5//! copy of the platform cache-directory shim and an ad-hoc, non-atomic
6//! mtime-TTL JSON file cache. This module centralizes that plumbing:
7//!
8//! - [`cache_dir`] / [`namespaced_cache_dir`] — one platform-aware helper.
9//! - [`http_client`] — one [`reqwest::blocking::Client`] builder with a
10//!   consistent `CARGO_PKG_NAME/CARGO_PKG_VERSION` User-Agent.
11//! - [`get_with_retry`] — a shared retry policy honoring `429`/`Retry-After`
12//!   with bounded exponential backoff.
13//! - [`JsonCache`] — a generic file cache with **atomic** writes
14//!   (temp-file + rename) and a `schema_version` envelope so that a version
15//!   bump transparently invalidates stale entries.
16
17use crate::error::Result;
18use crate::model::Component;
19use serde::Serialize;
20use serde::de::DeserializeOwned;
21use sha2::{Digest, Sha256};
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::time::Duration;
26
27/// Process-global offline switch.
28///
29/// In air-gapped (CRA / FDA / defense) deployments the tool must serve
30/// enrichment data purely from cache and never attempt a network call. This is
31/// a process-wide flag rather than per-config because the HTTP choke point
32/// ([`http_client`]) and the cache ([`JsonCache`]) are reached through many
33/// independently-constructed source configs; threading a bool through every one
34/// would be invasive and easy to miss. It is set once, early, from the global
35/// `--offline` flag / `SBOM_TOOLS_OFFLINE` env.
36static OFFLINE: AtomicBool = AtomicBool::new(false);
37
38/// Enable or disable offline mode for the whole process.
39pub fn set_offline(offline: bool) {
40    OFFLINE.store(offline, Ordering::Relaxed);
41}
42
43/// Whether the process is running in offline mode.
44#[must_use]
45pub fn is_offline() -> bool {
46    OFFLINE.load(Ordering::Relaxed)
47}
48
49/// Schema version embedded in every cache envelope.
50///
51/// Bumping this value invalidates all previously written cache entries: any
52/// envelope whose `schema_version` does not match is treated as a miss (and
53/// removed) so a changed payload shape can never be deserialized as the old
54/// one.
55pub const CACHE_SCHEMA_VERSION: u32 = 1;
56
57// ============================================================================
58// Cache directory
59// ============================================================================
60
61/// Platform-specific base cache directory.
62///
63/// - macOS: `$HOME/Library/Caches`
64/// - Linux: `$XDG_CACHE_HOME` or `$HOME/.cache`
65/// - Windows: `%LOCALAPPDATA%`
66/// - other: `$HOME/.cache`
67///
68/// Returns `None` when the relevant environment variables are unset.
69#[must_use]
70pub fn cache_dir() -> Option<PathBuf> {
71    #[cfg(target_os = "macos")]
72    {
73        std::env::var("HOME")
74            .ok()
75            .map(|h| PathBuf::from(h).join("Library").join("Caches"))
76    }
77    #[cfg(target_os = "linux")]
78    {
79        std::env::var("XDG_CACHE_HOME")
80            .ok()
81            .map(PathBuf::from)
82            .or_else(|| {
83                std::env::var("HOME")
84                    .ok()
85                    .map(|h| PathBuf::from(h).join(".cache"))
86            })
87    }
88    #[cfg(target_os = "windows")]
89    {
90        std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)
91    }
92    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
93    {
94        std::env::var("HOME")
95            .ok()
96            .map(|h| PathBuf::from(h).join(".cache"))
97    }
98}
99
100/// Root cache directory for all enrichment sources, i.e. `…/sbom-tools`.
101///
102/// This is the parent of every per-source namespace directory; the `cache`
103/// subcommand uses it to enumerate, size, export, and import the cache as a
104/// whole. Falls back to a `.cache`-relative path when no platform cache
105/// directory is available, matching [`namespaced_cache_dir`].
106#[must_use]
107pub fn root_cache_dir() -> PathBuf {
108    cache_dir()
109        .unwrap_or_else(|| PathBuf::from(".cache"))
110        .join("sbom-tools")
111}
112
113/// Cache directory for an enrichment source, e.g. `…/sbom-tools/osv`.
114///
115/// Falls back to a `.cache`-relative path only when no platform cache
116/// directory is available, matching the previous per-source behavior.
117#[must_use]
118pub fn namespaced_cache_dir(namespace: &str) -> PathBuf {
119    root_cache_dir().join(namespace)
120}
121
122// ============================================================================
123// HTTP client + retry policy
124// ============================================================================
125
126/// Build the shared blocking HTTP client used by all enrichment sources.
127///
128/// All sources share the same `CARGO_PKG_NAME/CARGO_PKG_VERSION` User-Agent so
129/// upstream registries see a single, identifiable client.
130/// Refuse a network operation when the process is in offline mode.
131///
132/// Building a [`reqwest`] client makes no network call, so [`http_client`] is
133/// not gated; the guard is applied at the request-dispatch layer
134/// ([`get_with_retry`] and each source's direct `.send()`) so that a warm cache
135/// is still served before any refusal. `what` describes the resource the caller
136/// was about to fetch, for a clear "offline: not in cache (<what>)" error.
137pub fn offline_guard(what: &str) -> Result<()> {
138    if is_offline() {
139        return Err(crate::error::SbomDiffError::enrichment(
140            "offline mode",
141            crate::error::EnrichmentErrorKind::Offline(what.to_string()),
142        ));
143    }
144    Ok(())
145}
146
147#[cfg(feature = "enrichment")]
148pub fn http_client(timeout: Duration) -> reqwest::Result<reqwest::blocking::Client> {
149    reqwest::blocking::Client::builder()
150        .timeout(timeout)
151        .user_agent(concat!(
152            env!("CARGO_PKG_NAME"),
153            "/",
154            env!("CARGO_PKG_VERSION")
155        ))
156        .build()
157}
158
159/// Maximum backoff applied between retries, regardless of attempt count or a
160/// server-provided `Retry-After`.
161const MAX_BACKOFF: Duration = Duration::from_secs(30);
162
163/// Maximum a cache entry may be served *past its TTL* in offline mode before
164/// it is treated as a hard miss. Stale-if-offline keeps air-gapped runs
165/// working, but security data this far out of date must not be presented as
166/// current (90 days past TTL is generous — enrichment TTLs are ~24h).
167const MAX_OFFLINE_STALENESS: Duration = Duration::from_secs(90 * 24 * 3600);
168
169/// Upper bound on a single enrichment response body, in bytes.
170///
171/// Enrichment responses are read fully into memory (the EPSS CSV into a
172/// `HashMap`, the KEV/HuggingFace JSON into structs). Without a cap a malicious
173/// or MITM'd endpoint advertising (or streaming) a huge body could OOM the
174/// process. The largest legitimate payload is the full daily EPSS dataset
175/// (~10-20 MB uncompressed); 256 MiB is a generous-but-finite ceiling that no
176/// real source approaches.
177pub const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
178
179/// Read a response body into memory, refusing bodies larger than
180/// [`MAX_RESPONSE_BYTES`].
181///
182/// The cap is enforced twice: first against a declared `Content-Length` (a cheap
183/// up-front reject), then against the actually-buffered length (a defense against
184/// a lying or absent header). The returned bytes are guaranteed `<= MAX`.
185#[cfg(feature = "enrichment")]
186pub fn read_bounded(response: reqwest::blocking::Response) -> Result<Vec<u8>> {
187    read_bounded_with_max(response, MAX_RESPONSE_BYTES)
188}
189
190/// [`read_bounded`] with an explicit cap.
191///
192/// Split out so the cap behavior can be exercised against a real (small)
193/// response without having to transmit a 256 MiB body in tests.
194#[cfg(feature = "enrichment")]
195pub(crate) fn read_bounded_with_max(
196    response: reqwest::blocking::Response,
197    max_bytes: u64,
198) -> Result<Vec<u8>> {
199    if let Some(len) = response.content_length()
200        && len > max_bytes
201    {
202        return Err(oversized_error(len, max_bytes));
203    }
204
205    let bytes = response
206        .bytes()
207        .map_err(|e| network_error("reading response body", &e))?;
208
209    if bytes.len() as u64 > max_bytes {
210        return Err(oversized_error(bytes.len() as u64, max_bytes));
211    }
212
213    Ok(bytes.to_vec())
214}
215
216/// Build the "response too large" enrichment error.
217#[cfg(feature = "enrichment")]
218fn oversized_error(len: u64, max_bytes: u64) -> crate::error::SbomDiffError {
219    crate::error::SbomDiffError::enrichment(
220        "response too large",
221        crate::error::EnrichmentErrorKind::NetworkError(format!(
222            "response body of {len} bytes exceeds the {max_bytes}-byte limit"
223        )),
224    )
225}
226
227/// Compute the backoff delay before retry `attempt` (1-based).
228///
229/// Honors a server-provided `Retry-After` (seconds) when present, otherwise
230/// applies bounded exponential backoff (1s, 2s, 4s, …) capped at
231/// [`MAX_BACKOFF`].
232#[must_use]
233pub fn backoff_delay(attempt: u32, retry_after: Option<Duration>) -> Duration {
234    if let Some(after) = retry_after {
235        return after.min(MAX_BACKOFF);
236    }
237    let secs = 1u64
238        .checked_shl(attempt.saturating_sub(1))
239        .unwrap_or(u64::MAX);
240    Duration::from_secs(secs).min(MAX_BACKOFF)
241}
242
243/// Parse a `Retry-After` header value expressed as a number of seconds.
244///
245/// The HTTP-date form of `Retry-After` is not supported (registries used here
246/// only emit the delta-seconds form); unparseable values yield `None`.
247#[cfg(feature = "enrichment")]
248fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
249    headers
250        .get(reqwest::header::RETRY_AFTER)?
251        .to_str()
252        .ok()?
253        .trim()
254        .parse::<u64>()
255        .ok()
256        .map(Duration::from_secs)
257}
258
259/// Perform a `GET` with the shared retry policy.
260///
261/// Retries on transport errors and on `429`/`5xx` responses up to
262/// `max_retries` times, honoring `Retry-After` on `429`. Non-retryable
263/// responses (including `4xx` other than `429`) are returned to the caller on
264/// the first attempt for status inspection.
265///
266/// In offline mode the request is refused up front with an
267/// [`EnrichmentErrorKind::Offline`](crate::error::EnrichmentErrorKind::Offline)
268/// error before any socket is opened.
269#[cfg(feature = "enrichment")]
270pub fn get_with_retry(
271    client: &reqwest::blocking::Client,
272    url: &str,
273    max_retries: u8,
274) -> Result<reqwest::blocking::Response> {
275    offline_guard(url)?;
276
277    for attempt in 0..=u32::from(max_retries) {
278        if attempt > 0 {
279            tracing::debug!("retry attempt {attempt} for {url}");
280        }
281
282        match client.get(url).send() {
283            Ok(response) => {
284                let status = response.status();
285                let retryable =
286                    status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
287                if retryable && attempt < u32::from(max_retries) {
288                    let retry_after = if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
289                        parse_retry_after(response.headers())
290                    } else {
291                        None
292                    };
293                    std::thread::sleep(backoff_delay(attempt + 1, retry_after));
294                    continue;
295                }
296                return Ok(response);
297            }
298            Err(e) => {
299                if attempt < u32::from(max_retries) {
300                    std::thread::sleep(backoff_delay(attempt + 1, None));
301                    continue;
302                }
303                return Err(network_error("request failed", &e));
304            }
305        }
306    }
307
308    // Unreachable in practice: the loop always returns on the final attempt.
309    Err(network_error_msg("retry loop returned no response"))
310}
311
312/// Wrap a [`reqwest::Error`] in our enrichment network-error variant.
313#[cfg(feature = "enrichment")]
314fn network_error(context: &str, err: &reqwest::Error) -> crate::error::SbomDiffError {
315    crate::error::SbomDiffError::enrichment(
316        context,
317        crate::error::EnrichmentErrorKind::NetworkError(err.to_string()),
318    )
319}
320
321/// Build a network-error variant from a bare message.
322#[cfg(feature = "enrichment")]
323fn network_error_msg(msg: &str) -> crate::error::SbomDiffError {
324    crate::error::SbomDiffError::enrichment(
325        "network",
326        crate::error::EnrichmentErrorKind::NetworkError(msg.to_string()),
327    )
328}
329
330// ============================================================================
331// Cache key
332// ============================================================================
333
334/// Cache key for vulnerability lookups.
335#[derive(Debug, Clone, Hash, PartialEq, Eq)]
336pub struct CacheKey {
337    /// Package URL (preferred)
338    pub purl: Option<String>,
339    /// Component name
340    pub name: String,
341    /// Ecosystem (npm, pypi, etc.)
342    pub ecosystem: Option<String>,
343    /// Version
344    pub version: Option<String>,
345}
346
347impl CacheKey {
348    /// Create a cache key from component data.
349    #[must_use]
350    pub const fn new(
351        purl: Option<String>,
352        name: String,
353        ecosystem: Option<String>,
354        version: Option<String>,
355    ) -> Self {
356        Self {
357            purl,
358            name,
359            ecosystem,
360            version,
361        }
362    }
363
364    /// Convert to a filesystem-safe filename using SHA256 hash.
365    #[must_use]
366    pub fn to_filename(&self) -> String {
367        let mut hasher = Sha256::new();
368        hasher.update(format!(
369            "purl:{:?}|name:{}|eco:{:?}|ver:{:?}",
370            self.purl, self.name, self.ecosystem, self.version
371        ));
372        let hash = hasher.finalize();
373        let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
374        format!("{hex}.json")
375    }
376
377    /// Check if this key can be used for an OSV query.
378    #[must_use]
379    pub const fn is_queryable(&self) -> bool {
380        // Need either a PURL or name + ecosystem + version
381        self.purl.is_some() || (self.ecosystem.is_some() && self.version.is_some())
382    }
383}
384
385/// Derive a collision-free, traversal-safe cache filename from an arbitrary
386/// (possibly untrusted) string key, via SHA256 → hex.
387///
388/// A `replace(['/', ':'], "_")`-style sanitizer is NOT injective — `a/b`,
389/// `a:b`, and `a_b` all collapse to one file, so metadata for one package
390/// could be served for another — and it misses `\\` (Windows traversal).
391/// Hashing the full key makes distinct keys distinct and the name is always
392/// `[0-9a-f]{64}.json`.
393#[must_use]
394pub(crate) fn key_to_filename(key: &str) -> String {
395    let mut hasher = Sha256::new();
396    hasher.update(key.as_bytes());
397    let hex: String = hasher
398        .finalize()
399        .iter()
400        .map(|b| format!("{b:02x}"))
401        .collect();
402    format!("{hex}.json")
403}
404
405// ============================================================================
406// Generic versioned file cache
407// ============================================================================
408
409/// Envelope wrapping every cached payload with a schema version.
410///
411/// The version is checked on read; a mismatch is treated as a miss so a
412/// changed payload shape can never be deserialized as the old one.
413#[derive(Debug, Serialize, serde::Deserialize)]
414struct CacheEnvelope<T> {
415    /// Schema version of the embedded payload.
416    schema_version: u32,
417    /// The cached value.
418    payload: T,
419}
420
421/// A generic file-based cache with atomic writes, TTL, and schema versioning.
422///
423/// Each entry is a JSON file under `cache_dir` whose name derives from the
424/// entry key. Writes go to a temporary sibling file that is then renamed over
425/// the target, so a reader never observes a torn write. Reads enforce both the
426/// configured TTL (via file mtime) and the [`CACHE_SCHEMA_VERSION`] envelope.
427pub struct JsonCache<T> {
428    cache_dir: PathBuf,
429    ttl: Duration,
430    _marker: std::marker::PhantomData<fn() -> T>,
431}
432
433impl<T> JsonCache<T>
434where
435    T: Serialize + DeserializeOwned,
436{
437    /// Create a cache rooted at `cache_dir`, creating the directory if needed.
438    pub fn new(cache_dir: PathBuf, ttl: Duration) -> Result<Self> {
439        if !cache_dir.exists() {
440            fs::create_dir_all(&cache_dir)?;
441        }
442        Ok(Self {
443            cache_dir,
444            ttl,
445            _marker: std::marker::PhantomData,
446        })
447    }
448
449    /// Path for a named entry (the name must already be filesystem-safe).
450    #[must_use]
451    pub fn path_for(&self, file_name: &str) -> PathBuf {
452        self.cache_dir.join(file_name)
453    }
454
455    /// Cache directory root.
456    #[must_use]
457    pub fn dir(&self) -> &Path {
458        &self.cache_dir
459    }
460
461    /// Read a cached value by file name.
462    ///
463    /// Returns `None` (evicting the file) when the entry is missing, older
464    /// than the TTL, has a mismatched schema version, or fails to parse.
465    ///
466    /// One exception: in process-wide [offline](is_offline) mode a TTL-expired
467    /// entry is **served** (and *not* evicted) so an air-gapped run can fall
468    /// back to whatever data is on disk. A staleness warning is logged. The
469    /// online path is unchanged — expired entries are still evicted on read.
470    #[must_use]
471    pub fn get_named(&self, file_name: &str) -> Option<T> {
472        let (value, stale_by) = self.get_named_allow_stale(file_name)?;
473        if let Some(age) = stale_by {
474            tracing::warn!(
475                "serving stale cache entry {file_name}: {} day(s) past its TTL (offline mode)",
476                age.as_secs() / 86_400
477            );
478        }
479        Some(value)
480    }
481
482    /// Read a cached value, tolerating TTL expiry when offline.
483    ///
484    /// Returns the payload together with a staleness signal: `Some(age)` is the
485    /// amount the entry is *past* its TTL when it was served despite being
486    /// expired (only possible in offline mode), or `None` when the entry was
487    /// still fresh.
488    ///
489    /// Unlike [`get_named`](Self::get_named), this never logs; it is the
490    /// building block callers use when they want to surface the staleness
491    /// (e.g. as CRA evidence) rather than only warn.
492    #[must_use]
493    pub fn get_named_allow_stale(&self, file_name: &str) -> Option<(T, Option<Duration>)> {
494        let path = self.path_for(file_name);
495
496        let metadata = fs::metadata(&path).ok()?;
497        let modified = metadata.modified().ok()?;
498        let age = modified.elapsed().ok()?;
499        let mut stale_by = None;
500        if age > self.ttl {
501            // Online: an expired entry is a miss and is evicted on read (the
502            // E1 cache tests assert this). Offline: keep it and serve it so an
503            // air-gapped run has a stale-if-offline fallback — but BOUND how
504            // stale. Presenting security data (vuln/KEV/EPSS) that is many
505            // months past its TTL as if current is misleading; beyond
506            // MAX_OFFLINE_STALENESS it is a hard miss even offline, forcing
507            // the operator to notice the data is too old to trust.
508            let past_ttl = age - self.ttl;
509            if is_offline() && past_ttl <= MAX_OFFLINE_STALENESS {
510                stale_by = Some(past_ttl);
511            } else {
512                let _ = fs::remove_file(&path);
513                return None;
514            }
515        }
516
517        let data = fs::read_to_string(&path).ok()?;
518        let envelope: CacheEnvelope<T> = serde_json::from_str(&data).ok()?;
519        if envelope.schema_version != CACHE_SCHEMA_VERSION {
520            // A schema mismatch is a hard miss in every mode: the payload shape
521            // changed and must never be deserialized as the old one.
522            let _ = fs::remove_file(&path);
523            return None;
524        }
525        Some((envelope.payload, stale_by))
526    }
527
528    /// Atomically write a value under `file_name`.
529    pub fn set_named<V: Serialize + ?Sized>(&self, file_name: &str, value: &V) -> Result<()> {
530        if !self.cache_dir.exists() {
531            fs::create_dir_all(&self.cache_dir)?;
532        }
533        let envelope = CacheEnvelope {
534            schema_version: CACHE_SCHEMA_VERSION,
535            payload: value,
536        };
537        let data = serde_json::to_string(&envelope)?;
538        write_atomic(&self.path_for(file_name), data.as_bytes())?;
539        Ok(())
540    }
541
542    /// Read a cached value by [`CacheKey`].
543    #[must_use]
544    pub fn get(&self, key: &CacheKey) -> Option<T> {
545        self.get_named(&key.to_filename())
546    }
547
548    /// Atomically write a value keyed by [`CacheKey`].
549    pub fn set<V: Serialize + ?Sized>(&self, key: &CacheKey, value: &V) -> Result<()> {
550        self.set_named(&key.to_filename(), value)
551    }
552
553    /// Remove a cached entry by [`CacheKey`].
554    pub fn remove(&self, key: &CacheKey) -> Result<()> {
555        let path = self.path_for(&key.to_filename());
556        if path.exists() {
557            fs::remove_file(path)?;
558        }
559        Ok(())
560    }
561
562    /// Remove every JSON entry from the cache directory.
563    pub fn clear(&self) -> Result<()> {
564        if self.cache_dir.exists() {
565            for entry in fs::read_dir(&self.cache_dir)? {
566                let entry = entry?;
567                if entry.path().extension().is_some_and(|e| e == "json") {
568                    let _ = fs::remove_file(entry.path());
569                }
570            }
571        }
572        Ok(())
573    }
574
575    /// Aggregate statistics over the cache directory.
576    #[must_use]
577    pub fn stats(&self) -> CacheStats {
578        let mut stats = CacheStats::default();
579
580        if let Ok(entries) = fs::read_dir(&self.cache_dir) {
581            for entry in entries.flatten() {
582                if entry.path().extension().is_some_and(|e| e == "json") {
583                    stats.total_entries += 1;
584                    if let Ok(metadata) = entry.metadata() {
585                        stats.total_size += metadata.len();
586                        if let Ok(modified) = metadata.modified()
587                            && let Ok(age) = modified.elapsed()
588                            && age > self.ttl
589                        {
590                            stats.expired_entries += 1;
591                        }
592                    }
593                }
594            }
595        }
596
597        stats
598    }
599}
600
601/// Write `bytes` to `path` atomically via a temp file + rename.
602///
603/// The temp file lives in the same directory as the target so the final
604/// `rename` is a same-filesystem operation and therefore atomic.
605fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
606    let parent = path.parent().unwrap_or_else(|| Path::new("."));
607    let file_name = path
608        .file_name()
609        .map_or_else(|| "cache".to_string(), |n| n.to_string_lossy().into_owned());
610    let tmp = parent.join(format!(".{file_name}.{}.tmp", std::process::id()));
611
612    fs::write(&tmp, bytes)?;
613    match fs::rename(&tmp, path) {
614        Ok(()) => Ok(()),
615        Err(e) => {
616            let _ = fs::remove_file(&tmp);
617            Err(e.into())
618        }
619    }
620}
621
622/// Cache statistics.
623#[derive(Debug, Default)]
624pub struct CacheStats {
625    /// Total number of cached entries
626    pub total_entries: usize,
627    /// Number of expired entries
628    pub expired_entries: usize,
629    /// Total size in bytes
630    pub total_size: u64,
631}
632
633// ============================================================================
634// EnrichmentSource trait
635// ============================================================================
636
637/// A source of external data that enriches SBOM components.
638///
639/// This models the shape shared by the OSV, KEV, EOL, and staleness enrichers:
640/// each has a stable name, a cache namespace and TTL, and an operation that
641/// applies fetched data to a slice of [`Component`]s in place, returning a
642/// source-specific statistics value.
643///
644/// It is a thin description layer over the existing enrichers — implementing it
645/// does not change what any source fetches or how results are mapped.
646pub trait EnrichmentSource {
647    /// Per-source statistics returned by [`enrich`](EnrichmentSource::enrich).
648    type Stats;
649
650    /// Stable, human-readable source name (e.g. `"OSV"`, `"KEV"`).
651    fn name(&self) -> &'static str;
652
653    /// Cache namespace (the directory under `…/sbom-tools/`).
654    fn cache_namespace(&self) -> &'static str;
655
656    /// Time-to-live for this source's cache entries.
657    fn cache_ttl(&self) -> Duration;
658
659    /// Apply this source's data to the given components in place.
660    fn enrich(&mut self, components: &mut [Component]) -> Result<Self::Stats>;
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use serde::Deserialize;
667
668    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
669    struct Payload {
670        value: u32,
671        label: String,
672    }
673
674    fn sample() -> Payload {
675        Payload {
676            value: 7,
677            label: "hello".to_string(),
678        }
679    }
680
681    /// Distinct keys that a `replace(['/', ':'], "_")` sanitizer would collapse
682    /// into one file must map to DISTINCT cache filenames — otherwise metadata
683    /// for one package could be served for another.
684    #[test]
685    fn key_to_filename_is_collision_free() {
686        let a = key_to_filename("npm:lodash");
687        let b = key_to_filename("npm/lodash");
688        let c = key_to_filename("npm_lodash");
689        assert_ne!(a, b);
690        assert_ne!(a, c);
691        assert_ne!(b, c);
692        // Deterministic and traversal-safe: only [0-9a-f] + ".json".
693        assert_eq!(a, key_to_filename("npm:lodash"));
694        assert!(
695            a.strip_suffix(".json")
696                .unwrap()
697                .chars()
698                .all(|ch| ch.is_ascii_hexdigit()),
699            "filename must be hex only (no separators / traversal)"
700        );
701        // A traversal attempt cannot escape the directory.
702        let evil = key_to_filename("../../etc/passwd");
703        assert!(!evil.contains('/') && !evil.contains(".."));
704    }
705
706    #[test]
707    fn namespaced_cache_dir_includes_namespace() {
708        let dir = namespaced_cache_dir("osv");
709        let s = dir.to_string_lossy();
710        assert!(s.contains("sbom-tools"));
711        assert!(s.ends_with("osv"));
712    }
713
714    #[test]
715    fn backoff_is_exponential_and_capped() {
716        assert_eq!(backoff_delay(1, None), Duration::from_secs(1));
717        assert_eq!(backoff_delay(2, None), Duration::from_secs(2));
718        assert_eq!(backoff_delay(3, None), Duration::from_secs(4));
719        // Capped at MAX_BACKOFF regardless of attempt.
720        assert_eq!(backoff_delay(20, None), MAX_BACKOFF);
721    }
722
723    #[test]
724    fn backoff_honors_retry_after_but_caps_it() {
725        assert_eq!(
726            backoff_delay(1, Some(Duration::from_secs(3))),
727            Duration::from_secs(3)
728        );
729        assert_eq!(
730            backoff_delay(1, Some(Duration::from_secs(120))),
731            MAX_BACKOFF
732        );
733    }
734
735    #[test]
736    fn cache_roundtrip_survives_reopen() {
737        let tmp = tempfile::tempdir().unwrap();
738        {
739            let cache: JsonCache<Payload> =
740                JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
741            cache.set_named("entry", &sample()).unwrap();
742        }
743        // A freshly constructed cache reads the persisted, atomically written file.
744        let cache: JsonCache<Payload> =
745            JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
746        assert_eq!(cache.get_named("entry"), Some(sample()));
747    }
748
749    #[test]
750    fn atomic_write_leaves_no_temp_files() {
751        let tmp = tempfile::tempdir().unwrap();
752        let cache: JsonCache<Payload> =
753            JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
754        cache.set_named("entry", &sample()).unwrap();
755
756        let tmp_files: Vec<_> = fs::read_dir(tmp.path())
757            .unwrap()
758            .flatten()
759            .filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
760            .collect();
761        assert!(tmp_files.is_empty(), "temp file should be renamed away");
762    }
763
764    #[test]
765    fn schema_version_mismatch_invalidates_entry() {
766        let tmp = tempfile::tempdir().unwrap();
767        let cache: JsonCache<Payload> =
768            JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
769
770        // Hand-write an envelope with a different schema version.
771        let stale = format!(
772            "{{\"schema_version\":{},\"payload\":{{\"value\":1,\"label\":\"x\"}}}}",
773            CACHE_SCHEMA_VERSION + 1
774        );
775        let path = cache.path_for("entry");
776        fs::write(&path, stale).unwrap();
777
778        assert!(
779            cache.get_named("entry").is_none(),
780            "mismatched schema version must be a miss"
781        );
782        assert!(!path.exists(), "stale entry should be evicted");
783    }
784
785    #[test]
786    fn ttl_expiry_evicts_entry() {
787        let tmp = tempfile::tempdir().unwrap();
788        // TTL margin must comfortably exceed the set+get round-trip (atomic
789        // write + read + deserialize), which can spike on a loaded CI runner.
790        let cache: JsonCache<Payload> =
791            JsonCache::new(tmp.path().to_path_buf(), Duration::from_millis(200)).unwrap();
792        cache.set_named("entry", &sample()).unwrap();
793        assert!(cache.get_named("entry").is_some());
794
795        std::thread::sleep(Duration::from_millis(400));
796        assert!(
797            cache.get_named("entry").is_none(),
798            "entry past TTL must be a miss"
799        );
800        assert!(
801            !cache.path_for("entry").exists(),
802            "expired entry should be evicted"
803        );
804    }
805
806    #[cfg(feature = "enrichment")]
807    #[test]
808    fn read_bounded_rejects_oversized_body() {
809        use httpmock::prelude::*;
810
811        let server = MockServer::start();
812        // A body that comfortably exceeds the tiny test cap below.
813        let body = "x".repeat(1024);
814        let mock = server.mock(|when, then| {
815            when.method(GET).path("/big");
816            then.status(200).body(&body);
817        });
818
819        let client = http_client(Duration::from_secs(5)).unwrap();
820        let resp = get_with_retry(&client, &format!("{}/big", server.base_url()), 0).unwrap();
821        mock.assert();
822
823        let err =
824            read_bounded_with_max(resp, 16).expect_err("a body over the cap must be rejected");
825        // The user-facing message names the size-cap; the byte-precise detail
826        // ("exceeds the N-byte limit") is carried on the error's source chain.
827        assert!(
828            err.to_string().contains("too large"),
829            "error must explain the size-cap rejection, got: {err}"
830        );
831        let detail = std::error::Error::source(&err)
832            .map(ToString::to_string)
833            .unwrap_or_default();
834        assert!(
835            detail.contains("exceeds"),
836            "source must carry the byte-precise detail, got: {detail}"
837        );
838    }
839
840    #[cfg(feature = "enrichment")]
841    #[test]
842    fn read_bounded_accepts_body_within_cap() {
843        use httpmock::prelude::*;
844
845        let server = MockServer::start();
846        let mock = server.mock(|when, then| {
847            when.method(GET).path("/ok");
848            then.status(200).body("hello");
849        });
850
851        let client = http_client(Duration::from_secs(5)).unwrap();
852        let resp = get_with_retry(&client, &format!("{}/ok", server.base_url()), 0).unwrap();
853        mock.assert();
854
855        let bytes = read_bounded_with_max(resp, MAX_RESPONSE_BYTES).unwrap();
856        assert_eq!(bytes, b"hello");
857    }
858
859    #[test]
860    fn clear_and_stats() {
861        let tmp = tempfile::tempdir().unwrap();
862        let cache: JsonCache<Payload> =
863            JsonCache::new(tmp.path().to_path_buf(), Duration::from_secs(3600)).unwrap();
864        // `stats`/`clear` operate on `.json` entries, matching how all real
865        // callers name their files (CacheKey::to_filename, cache_filename, …).
866        cache.set_named("a.json", &sample()).unwrap();
867        cache.set_named("b.json", &sample()).unwrap();
868        assert_eq!(cache.stats().total_entries, 2);
869        cache.clear().unwrap();
870        assert_eq!(cache.stats().total_entries, 0);
871    }
872}