pub mod cache;
pub mod cargo;
pub(crate) mod client;
pub mod npm;
pub mod nuget;
pub mod osv;
pub mod pip;
use chrono::Utc;
use rayon::prelude::*;
use crate::collector::deps::LockedDep;
use crate::deps::{DepAge, DepTier, Ecosystem};
use cache::{CacheEntry, DepsCache};
pub fn fetch_dep_network(dep: &LockedDep) -> Option<CacheEntry> {
let result = match dep.ecosystem {
Ecosystem::Cargo => cargo::fetch_dates(&dep.name, &dep.version).ok(),
Ecosystem::Npm => npm::fetch_dates(&dep.name, &dep.version).ok(),
Ecosystem::Pip => pip::fetch_dates(&dep.name, &dep.version).ok(),
Ecosystem::Nuget => nuget::fetch_dates(&dep.name, &dep.version).ok(),
};
let (current_published, latest_version, latest_published) = result?;
let vulns =
osv::fetch_vulns(dep.ecosystem.osv_name(), &dep.name, &dep.version).unwrap_or_default();
Some(CacheEntry {
current_published,
latest_version: Some(latest_version),
latest_published: Some(latest_published),
vulnerabilities: vulns,
cached_at: Utc::now(),
})
}
pub fn partition_cached<'a>(
locked: &'a [LockedDep],
cache: &DepsCache,
) -> (Vec<&'a LockedDep>, Vec<&'a LockedDep>) {
locked.iter().partition(|dep| {
let key = cache::cache_key(dep.ecosystem.display_name(), &dep.name, &dep.version);
cache.get(&key).is_some_and(|e| e.is_fresh())
})
}
pub fn resolve_dep_ages(
all: &[LockedDep],
uncached: &[&LockedDep],
cache: &mut DepsCache,
fetch_fn: impl Fn(&LockedDep) -> Option<CacheEntry> + Sync,
) -> Vec<DepAge> {
if !uncached.is_empty() {
let fetched: Vec<_> = uncached
.par_iter()
.filter_map(|dep| {
let entry = fetch_fn(dep)?;
let key = cache::cache_key(dep.ecosystem.display_name(), &dep.name, &dep.version);
Some((key, entry))
})
.collect();
for (key, entry) in fetched {
cache.insert(key, entry);
}
}
all.iter()
.filter_map(|dep| {
let key = cache::cache_key(dep.ecosystem.display_name(), &dep.name, &dep.version);
cache
.get(&key)
.and_then(|entry| entry_to_dep_age(dep, entry))
})
.collect()
}
fn entry_to_dep_age(dep: &LockedDep, entry: &CacheEntry) -> Option<DepAge> {
let current = entry.current_published?;
let latest = entry.latest_published?;
let drift_seconds = (latest - current).num_seconds().max(0);
let drift_years = drift_seconds as f64 / (365.25 * 24.0 * 3600.0);
Some(DepAge {
name: dep.name.clone(),
ecosystem: dep.ecosystem.clone(),
current_version: dep.version.clone(),
drift_years,
tier: DepTier::from_drift(drift_years),
vulnerabilities: entry.vulnerabilities.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deps::{Ecosystem, Vuln};
use chrono::{Duration, Utc};
use std::collections::HashMap;
fn make_dep() -> LockedDep {
LockedDep {
name: "serde".into(),
version: "1.0.130".into(),
ecosystem: Ecosystem::Cargo,
}
}
fn make_locked(name: &str, version: &str) -> LockedDep {
LockedDep {
name: name.into(),
version: version.into(),
ecosystem: Ecosystem::Cargo,
}
}
fn make_entry(
current_published: Option<chrono::DateTime<Utc>>,
latest_published: Option<chrono::DateTime<Utc>>,
) -> CacheEntry {
CacheEntry {
current_published,
latest_published,
latest_version: Some("1.0.197".into()),
vulnerabilities: vec![],
cached_at: Utc::now(),
}
}
fn make_fresh_entry() -> CacheEntry {
make_entry(Some(Utc::now() - Duration::days(365)), Some(Utc::now()))
}
#[test]
fn drift_clamps_to_zero_when_latest_before_current() {
let now = Utc::now();
let entry = make_entry(Some(now), Some(now - Duration::days(30)));
let dep_age = entry_to_dep_age(&make_dep(), &entry).unwrap();
assert_eq!(dep_age.drift_years, 0.0);
assert_eq!(dep_age.tier, DepTier::Fresh);
}
#[test]
fn drift_years_arithmetic_is_correct() {
let now = Utc::now();
let entry = make_entry(Some(now - Duration::days(366)), Some(now));
let dep_age = entry_to_dep_age(&make_dep(), &entry).unwrap();
assert!(
(dep_age.drift_years - 1.0).abs() < 0.1,
"expected ~1 year, got {}",
dep_age.drift_years
);
}
#[test]
fn none_when_current_published_missing() {
let entry = make_entry(None, Some(Utc::now()));
assert!(entry_to_dep_age(&make_dep(), &entry).is_none());
}
#[test]
fn none_when_latest_published_missing() {
let entry = make_entry(Some(Utc::now()), None);
assert!(entry_to_dep_age(&make_dep(), &entry).is_none());
}
#[test]
fn vulnerabilities_are_propagated() {
let vuln = Vuln {
id: "GHSA-0000-0000-0000".into(),
description: "test vuln".into(),
severity: "HIGH".into(),
};
let mut entry = make_entry(Some(Utc::now() - Duration::days(365)), Some(Utc::now()));
entry.vulnerabilities = vec![vuln.clone()];
let dep_age = entry_to_dep_age(&make_dep(), &entry).unwrap();
assert_eq!(dep_age.vulnerabilities.len(), 1);
assert_eq!(dep_age.vulnerabilities[0].id, vuln.id);
}
#[test]
#[ignore = "network"]
fn fetch_dep_network_returns_none_for_nonexistent_package() {
let dep = LockedDep {
name: "this-package-does-not-exist-barad-dur-test-xyz".into(),
version: "0.0.0".into(),
ecosystem: Ecosystem::Cargo,
};
assert!(fetch_dep_network(&dep).is_none());
}
#[test]
fn fetch_dep_network_has_correct_signature() {
let _ = fetch_dep_network as fn(&LockedDep) -> Option<CacheEntry>;
}
#[test]
fn partition_cached_splits_correctly() {
let fresh = make_locked("cached-dep", "1.0.0");
let stale = make_locked("uncached-dep", "2.0.0");
let mut cache: DepsCache = HashMap::new();
cache.insert(
cache::cache_key("cargo", "cached-dep", "1.0.0"),
make_fresh_entry(),
);
let locked = [fresh, stale];
let (cached, uncached) = partition_cached(&locked, &cache);
assert_eq!(cached.len(), 1);
assert_eq!(cached[0].name, "cached-dep");
assert_eq!(uncached.len(), 1);
assert_eq!(uncached[0].name, "uncached-dep");
}
#[test]
fn resolve_dep_ages_returns_cached_deps_without_calling_fetch_fn() {
let dep = make_locked("serde", "1.0.0");
let mut cache: DepsCache = HashMap::new();
cache.insert(
cache::cache_key("cargo", "serde", "1.0.0"),
make_fresh_entry(),
);
let locked = [dep];
let (_, uncached) = partition_cached(&locked, &cache);
let result = resolve_dep_ages(&locked, &uncached, &mut cache, |_| {
panic!("fetch_fn must not be called for cached deps");
});
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "serde");
}
#[test]
fn resolve_dep_ages_fetches_uncached_dep_via_fetch_fn() {
let dep = make_locked("new-dep", "2.0.0");
let mut cache: DepsCache = HashMap::new();
let locked = [dep];
let (_, uncached) = partition_cached(&locked, &cache);
let result = resolve_dep_ages(&locked, &uncached, &mut cache, |_| Some(make_fresh_entry()));
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "new-dep");
assert!(cache.contains_key(&cache::cache_key("cargo", "new-dep", "2.0.0")));
}
#[test]
fn resolve_dep_ages_skips_dep_when_fetch_fn_returns_none() {
let dep = make_locked("broken-dep", "0.1.0");
let mut cache: DepsCache = HashMap::new();
let locked = [dep];
let (_, uncached) = partition_cached(&locked, &cache);
let result = resolve_dep_ages(&locked, &uncached, &mut cache, |_| None);
assert!(result.is_empty());
}
#[test]
fn resolve_dep_ages_resolves_cached_and_skips_failed_uncached() {
let cached = make_locked("serde", "1.0.0");
let uncached_dep = make_locked("broken", "0.0.1");
let mut cache: DepsCache = HashMap::new();
cache.insert(
cache::cache_key("cargo", "serde", "1.0.0"),
make_fresh_entry(),
);
let locked = [cached, uncached_dep];
let (_, uncached) = partition_cached(&locked, &cache);
let result = resolve_dep_ages(&locked, &uncached, &mut cache, |_| None);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "serde");
}
}