1use 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
27static OFFLINE: AtomicBool = AtomicBool::new(false);
37
38pub fn set_offline(offline: bool) {
40 OFFLINE.store(offline, Ordering::Relaxed);
41}
42
43#[must_use]
45pub fn is_offline() -> bool {
46 OFFLINE.load(Ordering::Relaxed)
47}
48
49pub const CACHE_SCHEMA_VERSION: u32 = 1;
56
57#[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#[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#[must_use]
118pub fn namespaced_cache_dir(namespace: &str) -> PathBuf {
119 root_cache_dir().join(namespace)
120}
121
122pub 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
159const MAX_BACKOFF: Duration = Duration::from_secs(30);
162
163const MAX_OFFLINE_STALENESS: Duration = Duration::from_secs(90 * 24 * 3600);
168
169pub const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
178
179#[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#[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#[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#[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#[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#[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 Err(network_error_msg("retry loop returned no response"))
310}
311
312#[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#[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#[derive(Debug, Clone, Hash, PartialEq, Eq)]
336pub struct CacheKey {
337 pub purl: Option<String>,
339 pub name: String,
341 pub ecosystem: Option<String>,
343 pub version: Option<String>,
345}
346
347impl CacheKey {
348 #[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 #[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 #[must_use]
379 pub const fn is_queryable(&self) -> bool {
380 self.purl.is_some() || (self.ecosystem.is_some() && self.version.is_some())
382 }
383}
384
385#[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#[derive(Debug, Serialize, serde::Deserialize)]
414struct CacheEnvelope<T> {
415 schema_version: u32,
417 payload: T,
419}
420
421pub 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 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 #[must_use]
451 pub fn path_for(&self, file_name: &str) -> PathBuf {
452 self.cache_dir.join(file_name)
453 }
454
455 #[must_use]
457 pub fn dir(&self) -> &Path {
458 &self.cache_dir
459 }
460
461 #[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 #[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 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 let _ = fs::remove_file(&path);
523 return None;
524 }
525 Some((envelope.payload, stale_by))
526 }
527
528 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 #[must_use]
544 pub fn get(&self, key: &CacheKey) -> Option<T> {
545 self.get_named(&key.to_filename())
546 }
547
548 pub fn set<V: Serialize + ?Sized>(&self, key: &CacheKey, value: &V) -> Result<()> {
550 self.set_named(&key.to_filename(), value)
551 }
552
553 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 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 #[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
601fn 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#[derive(Debug, Default)]
624pub struct CacheStats {
625 pub total_entries: usize,
627 pub expired_entries: usize,
629 pub total_size: u64,
631}
632
633pub trait EnrichmentSource {
647 type Stats;
649
650 fn name(&self) -> &'static str;
652
653 fn cache_namespace(&self) -> &'static str;
655
656 fn cache_ttl(&self) -> Duration;
658
659 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 #[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 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 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 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 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 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 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 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 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 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}