pub mod cache;
pub mod cargo;
pub mod npm;
pub mod nuget;
pub mod osv;
pub mod pip;
use std::path::Path;
use chrono::Utc;
use crate::collector::deps::LockedDep;
use crate::deps::{DepAge, DepTier, Ecosystem};
use cache::{CacheEntry, DepsCache};
pub fn fetch_dep(dep: &LockedDep, cache: &mut DepsCache, repo_root: &Path) -> Option<DepAge> {
let key = cache::cache_key(dep.ecosystem.display_name(), &dep.name, &dep.version);
if let Some(entry) = cache.get(&key) {
if entry.is_fresh() {
return entry_to_dep_age(dep, entry);
}
}
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();
let entry = CacheEntry {
current_published,
latest_version: Some(latest_version),
latest_published: Some(latest_published),
vulnerabilities: vulns,
cached_at: Utc::now(),
};
cache.insert(key, entry.clone());
cache::save(repo_root, cache);
entry_to_dep_age(dep, &entry)
}
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;
use tempfile::tempdir;
fn make_dep() -> LockedDep {
LockedDep {
name: "serde".into(),
version: "1.0.130".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(),
}
}
#[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 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]
fn fetch_dep_returns_fresh_cached_entry_without_network() {
let dir = tempdir().unwrap();
let dep = make_dep();
let mut cache: DepsCache = HashMap::new();
let key = cache::cache_key(dep.ecosystem.display_name(), &dep.name, &dep.version);
cache.insert(
key,
make_entry(Some(Utc::now() - Duration::days(365)), Some(Utc::now())),
);
let result = fetch_dep(&dep, &mut cache, dir.path());
assert!(result.is_some());
assert_eq!(result.unwrap().name, "serde");
}
}