mod local_match;
mod severity;
mod types;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use local_match::{LocalVersion, Verdict, match_affected};
pub use severity::to_diagnostic_severity as diagnostic_severity_for;
use types::worst_severity;
pub use types::{
Advisory, CandidateStatusMap, CandidateStatuses, Capped, DependencyVulnerabilities,
EmptyOsvPackageName, FixRecommendation, LatestStatusMap, MatchedTags, OsvEcosystem,
OsvPackageName, OsvQueryName, OsvVersion, ScanOutcome, ScanTarget, ScanVersion, SiblingMatches,
SkipReason, StructuralSkipReason, UpgradeStatus, VersionMatching, VulnKey, VulnKeys,
VulnSeverity, VulnerabilityMap, is_valid_osv_id, validated_osv_url, vuln_key_for,
vulnerability_keys,
};
use types::{OsvBatchRequest, OsvBatchResponse, OsvQuery, OsvSingleQueryResponse, OsvVulnRecord};
use crate::ConcreteVersion;
use crate::cache::HttpCache;
pub const ADVISORY_DISPLAY_CAP: usize = 5;
pub const MAX_ADVISORY_RECORDS: usize = 50;
const QUERY_CACHE_TTL: Duration = Duration::from_hours(6);
const BATCH_CHUNK_SIZE: usize = 1000;
const MAX_TRUNCATED_REQUERY_BUDGET: usize = 20;
const RECORD_FETCH_CONCURRENCY: usize = 10;
const MAX_CACHE_ENTRIES: usize = 10_000;
const OSV_API_BASE: &str = "https://api.osv.dev";
fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
fn release_prefix(s: &str) -> &str {
let end = s
.char_indices()
.find(|(_, c)| !(c.is_ascii_digit() || *c == '.'))
.map_or(s.len(), |(i, _)| i);
s.get(..end).unwrap_or(s).trim_end_matches('.')
}
fn segments(s: &str) -> Vec<u64> {
release_prefix(s)
.split('.')
.map(|part| part.parse().unwrap_or(0))
.collect()
}
fn is_plain_release(s: &str) -> bool {
release_prefix(s).len() == s.len()
}
let a = crate::github::normalize_tag(a);
let b = crate::github::normalize_tag(b);
let (sa, sb) = (segments(a), segments(b));
sa.cmp(&sb)
.then_with(|| is_plain_release(a).cmp(&is_plain_release(b)))
.then_with(|| a.cmp(b))
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum QueryCacheKey {
Versioned(OsvEcosystem, OsvPackageName, OsvVersion),
Unversioned(OsvEcosystem, OsvPackageName),
}
impl QueryCacheKey {
fn for_target(osv_eco: OsvEcosystem, target: &ScanTarget) -> Self {
match osv_eco.version_matching() {
VersionMatching::ServerSide => Self::Versioned(
osv_eco,
target.osv_name.name().clone(),
target.version.clone(),
),
VersionMatching::LocalUnversioned => {
Self::Unversioned(osv_eco, target.osv_name.name().clone())
}
}
}
}
struct QueryCacheEntry {
vuln_ids: Vec<(String, String)>,
fetched_at: Instant,
}
struct RecordCacheEntry {
advisory: Arc<Advisory>,
modified: String,
fetched_at: Instant,
}
struct RawRecordCacheEntry {
record: Arc<OsvVulnRecord>,
fetched_at: Instant,
}
struct InFlightRecord {
cell: tokio::sync::OnceCell<Option<Arc<OsvVulnRecord>>>,
waiters: std::sync::atomic::AtomicUsize,
}
impl InFlightRecord {
fn new() -> Self {
Self {
cell: tokio::sync::OnceCell::new(),
waiters: std::sync::atomic::AtomicUsize::new(0),
}
}
}
pub struct OsvClient {
cache: Arc<HttpCache>,
query_cache: DashMap<QueryCacheKey, QueryCacheEntry>,
record_cache: DashMap<String, RecordCacheEntry>,
raw_record_cache: DashMap<String, RawRecordCacheEntry>,
record_fetch_semaphore: Arc<tokio::sync::Semaphore>,
record_in_flight: DashMap<String, Arc<InFlightRecord>>,
#[cfg(any(test, feature = "test-util"))]
base_url: String,
}
impl OsvClient {
#[must_use]
pub fn new(cache: Arc<HttpCache>) -> Self {
Self {
cache,
query_cache: DashMap::new(),
record_cache: DashMap::new(),
raw_record_cache: DashMap::new(),
record_fetch_semaphore: Arc::new(tokio::sync::Semaphore::new(RECORD_FETCH_CONCURRENCY)),
record_in_flight: DashMap::new(),
#[cfg(any(test, feature = "test-util"))]
base_url: OSV_API_BASE.to_string(),
}
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub fn for_test(cache: Arc<HttpCache>, base_url: impl Into<String>) -> Self {
Self::with_base_url(cache, base_url.into())
}
#[cfg(any(test, feature = "test-util"))]
fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
Self {
cache,
query_cache: DashMap::new(),
record_cache: DashMap::new(),
raw_record_cache: DashMap::new(),
record_fetch_semaphore: Arc::new(tokio::sync::Semaphore::new(RECORD_FETCH_CONCURRENCY)),
record_in_flight: DashMap::new(),
base_url,
}
}
#[cfg(any(test, feature = "test-util"))]
fn api_base(&self) -> &str {
&self.base_url
}
#[cfg(not(any(test, feature = "test-util")))]
const fn api_base(&self) -> &str {
OSV_API_BASE
}
fn batch_url(&self) -> String {
format!("{}/v1/querybatch", self.api_base())
}
fn single_query_url(&self) -> String {
format!("{}/v1/query", self.api_base())
}
fn vuln_record_url(&self, id: &str) -> String {
format!("{}/v1/vulns/{id}", self.api_base())
}
pub async fn scan(
&self,
ecosystem: crate::EcosystemId,
deps: &[ScanTarget],
timeout: Duration,
) -> VulnerabilityMap {
if deps.is_empty() {
return VulnerabilityMap::new();
}
let outcomes = self.resolve(ecosystem, deps, timeout).await;
log_scan_summary(&outcomes);
outcomes
}
pub async fn check_candidates(
&self,
ecosystem: crate::EcosystemId,
candidates: &[ScanTarget],
timeout: Duration,
) -> LatestStatusMap {
if candidates.is_empty() {
return HashMap::new();
}
let outcomes = self.resolve(ecosystem, candidates, timeout).await;
candidates
.iter()
.map(|candidate| {
let key = candidate.key.clone();
let version = candidate.display_version.clone();
let status = match outcomes.get(&key) {
Some(ScanOutcome::Clean) => UpgradeStatus::CandidateClean { version },
Some(ScanOutcome::Vulnerable(dv)) => {
let worst_severity = dv
.advisories
.is_complete()
.then(|| worst_severity(dv.advisories.items()))
.flatten();
UpgradeStatus::CandidateVulnerable {
version,
advisory_ids: Capped::new(
dv.advisories.items().iter().map(|a| a.id.clone()).collect(),
dv.advisories.total(),
),
worst_severity,
}
}
Some(ScanOutcome::Skipped(reason)) => UpgradeStatus::CandidateUnverified {
version,
reason: *reason,
},
None => UpgradeStatus::CandidateUnverified {
version,
reason: SkipReason::QueryFailed,
},
};
(key, status)
})
.collect()
}
async fn resolve(
&self,
ecosystem: crate::EcosystemId,
targets: &[ScanTarget],
timeout: Duration,
) -> VulnerabilityMap {
let mut outcomes = self.resolve_by_name(ecosystem, targets, timeout).await;
let mut seen: HashSet<&VulnKey> = HashSet::with_capacity(targets.len());
for t in targets {
if !seen.insert(&t.key) {
continue;
}
match &t.osv_name {
OsvQueryName::Confirmed(_) => {}
OsvQueryName::Provisional(_) => {
if let Some(outcome @ ScanOutcome::Clean) = outcomes.get_mut(&t.key) {
*outcome = ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed);
}
}
}
}
outcomes
}
async fn resolve_by_name(
&self,
ecosystem: crate::EcosystemId,
targets: &[ScanTarget],
timeout: Duration,
) -> VulnerabilityMap {
let deadline = Instant::now() + timeout;
let mut outcomes = HashMap::with_capacity(targets.len());
let Some(osv_eco) = ecosystem.osv_ecosystem() else {
for t in targets {
outcomes.insert(
t.key.clone(),
ScanOutcome::Skipped(SkipReason::UnmappableEcosystem),
);
}
return outcomes;
};
let mut to_query: Vec<ScanTarget> = Vec::new();
let mut cached_stubs: Vec<(ScanTarget, Vec<(String, String)>)> = Vec::new();
let mut seen: HashSet<&VulnKey> = HashSet::with_capacity(targets.len());
for t in targets {
if !seen.insert(&t.key) {
continue;
}
if osv_eco.version_matching() == VersionMatching::ServerSide && !t.siblings().is_empty()
{
tracing::warn!(
dep = %t.key,
"sibling release tags cannot be matched server-side, skipping"
);
outcomes.insert(
t.key.clone(),
ScanOutcome::Skipped(SkipReason::UnmatchableVersion),
);
continue;
}
if osv_eco.version_matching() == VersionMatching::LocalUnversioned
&& LocalVersion::parse(&t.version).is_none()
{
outcomes.insert(
t.key.clone(),
ScanOutcome::Skipped(SkipReason::UnmatchableVersion),
);
continue;
}
let cached_ids = self
.query_cache
.get(&QueryCacheKey::for_target(osv_eco, t))
.and_then(|entry| {
(entry.fetched_at.elapsed() < QUERY_CACHE_TTL).then(|| entry.vuln_ids.clone())
});
match (cached_ids, osv_eco.version_matching()) {
(Some(vuln_ids), VersionMatching::ServerSide) => {
outcomes.insert(
t.key.clone(),
self.build_outcome(osv_eco, t.osv_name.name(), &vuln_ids, deadline)
.await,
);
}
(Some(vuln_ids), VersionMatching::LocalUnversioned) => {
cached_stubs.push((t.clone(), vuln_ids));
}
(None, _) => to_query.push(t.clone()),
}
}
self.resolve_local_stubs(osv_eco, cached_stubs, &mut outcomes, deadline)
.await;
if to_query.is_empty() {
return outcomes;
}
let mut truncated: Vec<ScanTarget> = Vec::new();
let mut chunks = to_query.chunks(BATCH_CHUNK_SIZE);
while let Some(chunk) = chunks.next() {
if Instant::now() >= deadline {
tracing::warn!(
remaining = chunk.len(),
"OSV scan deadline exceeded, marking remaining chunks as query-failed"
);
mark_chunk_failed(chunk, &mut outcomes);
for remaining in chunks {
mark_chunk_failed(remaining, &mut outcomes);
}
return outcomes;
}
self.resolve_chunk(osv_eco, chunk, &mut outcomes, &mut truncated, deadline)
.await;
}
if Instant::now() >= deadline {
tracing::warn!(
count = truncated.len(),
"OSV scan deadline exceeded before truncation recovery"
);
for target in &truncated {
outcomes.insert(
target.key.clone(),
ScanOutcome::Skipped(SkipReason::Truncated),
);
}
return outcomes;
}
self.recover_truncated(osv_eco, &truncated, &mut outcomes, deadline)
.await;
outcomes
}
#[tracing::instrument(
skip(self, osv_eco, chunk, outcomes, truncated),
fields(url = tracing::field::Empty, osv_eco = osv_eco.as_str())
)]
async fn resolve_chunk(
&self,
osv_eco: OsvEcosystem,
chunk: &[ScanTarget],
outcomes: &mut VulnerabilityMap,
truncated: &mut Vec<ScanTarget>,
deadline: Instant,
) {
let url = self.batch_url();
tracing::Span::current().record(
"url",
tracing::field::display(crate::redact::RedactedUrl::new(&url)),
);
let queries: Vec<OsvQuery> = chunk.iter().map(|t| OsvQuery::new(t, osv_eco)).collect();
let body = OsvBatchRequest { queries };
let response_bytes = match self.cache.post_json(&url, &body).await {
Ok(b) => b,
Err(e) => {
let (status, cause) = e.safe_tracing_summary();
tracing::warn!(status = ?status, cause, "OSV batch query failed");
mark_chunk_failed(chunk, outcomes);
return;
}
};
let parsed: OsvBatchResponse = match crate::parser::parse_json_checked(&response_bytes) {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, "failed to parse OSV batch response");
mark_chunk_failed(chunk, outcomes);
return;
}
};
if parsed.results.len() != chunk.len() {
tracing::warn!(
expected = chunk.len(),
got = parsed.results.len(),
"OSV batch result count mismatch, dropping chunk"
);
mark_chunk_failed(chunk, outcomes);
return;
}
let mut local_stubs: Vec<(ScanTarget, Vec<(String, String)>)> = Vec::new();
for (target, result) in chunk.iter().zip(parsed.results) {
if result.next_page_token.is_some() {
truncated.push(target.clone());
continue;
}
let vuln_ids: Vec<(String, String)> = result
.vulns
.into_iter()
.map(|v| (v.id, v.modified))
.collect();
self.store_query_cache(osv_eco, target, &vuln_ids);
match osv_eco.version_matching() {
VersionMatching::ServerSide => {
let outcome = self
.build_outcome(osv_eco, target.osv_name.name(), &vuln_ids, deadline)
.await;
outcomes.insert(target.key.clone(), outcome);
}
VersionMatching::LocalUnversioned => local_stubs.push((target.clone(), vuln_ids)),
}
}
self.resolve_local_stubs(osv_eco, local_stubs, outcomes, deadline)
.await;
}
async fn resolve_local_stubs(
&self,
osv_eco: OsvEcosystem,
pending: Vec<(ScanTarget, Vec<(String, String)>)>,
outcomes: &mut VulnerabilityMap,
deadline: Instant,
) {
use futures::stream::{self, StreamExt};
let resolved: Vec<(VulnKey, ScanOutcome)> = stream::iter(pending)
.map(|(target, stubs)| async move {
let outcome = self
.outcome_from_local_stubs(osv_eco, &target, &stubs, deadline)
.await;
(target.key, outcome)
})
.buffer_unordered(RECORD_FETCH_CONCURRENCY)
.collect()
.await;
outcomes.extend(resolved);
}
async fn outcome_from_local_stubs(
&self,
osv_eco: OsvEcosystem,
target: &ScanTarget,
stubs: &[(String, String)],
deadline: Instant,
) -> ScanOutcome {
use futures::stream::{self, StreamExt};
if stubs.is_empty() {
return ScanOutcome::Clean;
}
if stubs.len() > MAX_ADVISORY_RECORDS {
return ScanOutcome::Skipped(SkipReason::Truncated);
}
let fetched: Vec<Option<Arc<OsvVulnRecord>>> =
stream::iter(stubs.iter().cloned())
.map(|(id, modified)| async move {
self.fetch_raw_record(&id, &modified, deadline).await
})
.buffer_unordered(RECORD_FETCH_CONCURRENCY)
.collect()
.await;
let Some(records) = fetched
.into_iter()
.map(|r| r.map(|r| (*r).clone()))
.collect::<Option<Vec<_>>>()
else {
return ScanOutcome::Skipped(SkipReason::QueryFailed);
};
self.outcome_from_local_records(osv_eco, target, records)
}
fn outcome_from_local_records(
&self,
osv_eco: OsvEcosystem,
target: &ScanTarget,
records: Vec<OsvVulnRecord>,
) -> ScanOutcome {
let Some(version) = LocalVersion::parse(&target.version) else {
return ScanOutcome::Skipped(SkipReason::UnmatchableVersion);
};
let siblings: Vec<(Option<LocalVersion>, &ConcreteVersion)> = target
.siblings()
.iter()
.map(|s| (LocalVersion::parse(s.version()), s.display_version()))
.collect();
let mut affected = Vec::new();
let mut sibling_only: HashMap<String, MatchedTags> = HashMap::new();
let mut undeterminable = 0usize;
for record in records {
let ranges = record.affected_for(target.osv_name.name(), osv_eco);
let primary_verdict = match_affected(&ranges, &version);
let mut matched_siblings = Vec::new();
let mut sibling_undeterminable = false;
for (sibling, display) in &siblings {
match sibling
.as_ref()
.map_or(Verdict::Undeterminable, |v| match_affected(&ranges, v))
{
Verdict::Affected => matched_siblings.push((*display).clone()),
Verdict::NotAffected => {}
Verdict::Undeterminable => sibling_undeterminable = true,
}
}
let is_affected = primary_verdict == Verdict::Affected || !matched_siblings.is_empty();
if is_affected {
if primary_verdict != Verdict::Affected
&& let Some(tags) = MatchedTags::from_tags(matched_siblings)
{
sibling_only.insert(record.id.clone(), tags);
}
affected.push(record);
} else if primary_verdict == Verdict::Undeterminable || sibling_undeterminable {
tracing::warn!(id = %record.id, "OSV record ranges could not be matched locally");
undeterminable += 1;
}
}
if affected.is_empty() && undeterminable > 0 {
return ScanOutcome::Skipped(SkipReason::UnevaluableAdvisoryRange);
}
match self.outcome_from_full_records(osv_eco, target, affected) {
ScanOutcome::Vulnerable(dv) => {
let mut matches = SiblingMatches::new(target.version.clone());
for advisory in dv.advisories.items() {
if let Some(tags) = sibling_only.remove(&advisory.id) {
matches.insert(advisory.id.clone(), tags);
}
}
ScanOutcome::Vulnerable(if matches.is_empty() {
dv
} else {
dv.with_sibling_matches(matches)
})
}
ScanOutcome::Clean => ScanOutcome::Clean,
ScanOutcome::Skipped(reason) => ScanOutcome::Skipped(reason),
}
}
async fn recover_truncated(
&self,
osv_eco: OsvEcosystem,
truncated: &[ScanTarget],
outcomes: &mut VulnerabilityMap,
deadline: Instant,
) {
use futures::stream::{self, StreamExt};
let budget = MAX_TRUNCATED_REQUERY_BUDGET.min(truncated.len());
let (to_recover, exhausted) = truncated.split_at(budget);
let recovered: Vec<(VulnKey, ScanOutcome)> = stream::iter(to_recover.iter().cloned())
.map(|target| async move {
let _permit = match tokio::time::timeout_at(
tokio::time::Instant::from_std(deadline),
Arc::clone(&self.record_fetch_semaphore).acquire_owned(),
)
.await
{
Ok(Ok(permit)) => permit,
Ok(Err(_)) => {
return (target.key.clone(), ScanOutcome::Skipped(SkipReason::QueryFailed));
}
Err(_) => {
tracing::warn!(
dep = %target.key,
"OSV truncation-requery permit wait exceeded scan deadline"
);
return (target.key.clone(), ScanOutcome::Skipped(SkipReason::QueryFailed));
}
};
let outcome = match self.query_single(osv_eco, &target).await {
Some(resp) if resp.next_page_token.is_some() => {
tracing::warn!(
dep = %target.key,
"OSV single-package requery itself paginated; treating as still truncated"
);
ScanOutcome::Skipped(SkipReason::Truncated)
}
Some(resp) => match osv_eco.version_matching() {
VersionMatching::ServerSide => {
self.outcome_from_full_records(osv_eco, &target, resp.vulns)
}
VersionMatching::LocalUnversioned => {
self.outcome_from_local_records(osv_eco, &target, resp.vulns)
}
},
None => ScanOutcome::Skipped(SkipReason::QueryFailed),
};
(target.key.clone(), outcome)
})
.buffer_unordered(RECORD_FETCH_CONCURRENCY)
.collect()
.await;
for (key, outcome) in recovered {
outcomes.insert(key, outcome);
}
for target in exhausted {
outcomes.insert(
target.key.clone(),
ScanOutcome::Skipped(SkipReason::Truncated),
);
}
}
fn outcome_from_full_records(
&self,
osv_eco: OsvEcosystem,
target: &ScanTarget,
records: Vec<OsvVulnRecord>,
) -> ScanOutcome {
let total = records.len();
let mut advisories = Vec::with_capacity(total.min(MAX_ADVISORY_RECORDS));
let mut vuln_ids = Vec::with_capacity(total);
for record in records {
let Some(advisory) = record.into_advisory(target.osv_name.name(), osv_eco) else {
continue;
};
let advisory = Arc::new(advisory);
vuln_ids.push((advisory.id.clone(), advisory.modified.clone()));
self.store_record_cache(&advisory);
if advisories.len() < MAX_ADVISORY_RECORDS {
advisories.push(advisory);
}
}
match osv_eco.version_matching() {
VersionMatching::ServerSide => self.store_query_cache(osv_eco, target, &vuln_ids),
VersionMatching::LocalUnversioned => {}
}
if total == 0 {
ScanOutcome::Clean
} else {
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(advisories, total),
fix_target_status: UpgradeStatus::NotChecked,
})
}
}
async fn build_outcome(
&self,
osv_eco: OsvEcosystem,
osv_name: &OsvPackageName,
vuln_ids: &[(String, String)],
deadline: Instant,
) -> ScanOutcome {
if vuln_ids.is_empty() {
return ScanOutcome::Clean;
}
#[expect(
clippy::indexing_slicing,
reason = "the slice upper bound is min(vuln_ids.len(), MAX_ADVISORY_RECORDS), \
always <= vuln_ids.len()"
)]
let to_fetch = &vuln_ids[..vuln_ids.len().min(MAX_ADVISORY_RECORDS)];
let advisories = self
.fetch_records(osv_eco, osv_name, to_fetch, deadline)
.await;
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(advisories, vuln_ids.len()),
fix_target_status: UpgradeStatus::NotChecked,
})
}
async fn fetch_records(
&self,
osv_eco: OsvEcosystem,
osv_name: &OsvPackageName,
ids: &[(String, String)],
deadline: Instant,
) -> Vec<Arc<Advisory>> {
use futures::stream::{self, StreamExt};
stream::iter(ids.iter().cloned())
.map(|(id, modified)| async move {
let cached = self.record_cache.get(&id).and_then(|entry| {
(entry.modified == modified).then(|| Arc::clone(&entry.advisory))
});
if let Some(advisory) = cached {
return Some(advisory);
}
let record = self.fetch_record_single_flight(&id, deadline).await?;
let advisory = Arc::new((*record).clone().into_advisory(osv_name, osv_eco)?);
self.store_record_cache(&advisory);
Some(advisory)
})
.buffer_unordered(RECORD_FETCH_CONCURRENCY)
.collect::<Vec<_>>()
.await
.into_iter()
.flatten()
.collect()
}
async fn fetch_raw_record(
&self,
id: &str,
modified: &str,
deadline: Instant,
) -> Option<Arc<OsvVulnRecord>> {
let cached = self.raw_record_cache.get(id).and_then(|entry| {
(entry.record.modified == modified).then(|| Arc::clone(&entry.record))
});
if cached.is_some() {
return cached;
}
let record = self.fetch_record_single_flight(id, deadline).await?;
self.store_raw_record_cache(&record);
Some(record)
}
async fn fetch_record_single_flight(
&self,
id: &str,
deadline: Instant,
) -> Option<Arc<OsvVulnRecord>> {
let entry = Arc::clone(
self.record_in_flight
.entry(id.to_string())
.or_insert_with(|| Arc::new(InFlightRecord::new()))
.value(),
);
entry
.waiters
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
match tokio::time::timeout_at(
tokio::time::Instant::from_std(deadline),
entry.cell.get_or_init(|| async {
let permit = Arc::clone(&self.record_fetch_semaphore)
.acquire_owned()
.await
.ok()?;
let record = self.fetch_single_record(id).await.map(Arc::new);
drop(permit);
record
}),
)
.await
{
Ok(value) => {
let result = value.clone();
self.record_in_flight
.remove_if(id, |_, v| Arc::ptr_eq(v, &entry));
result
}
Err(_) => {
tracing::warn!(id, "OSV record fetch wait exceeded scan deadline");
if entry
.waiters
.fetch_sub(1, std::sync::atomic::Ordering::AcqRel)
== 1
{
self.record_in_flight
.remove_if(id, |_, v| Arc::ptr_eq(v, &entry));
}
None
}
}
}
#[tracing::instrument(skip(self), fields(url = tracing::field::Empty))]
async fn fetch_single_record(&self, id: &str) -> Option<OsvVulnRecord> {
let url = self.vuln_record_url(id);
tracing::Span::current().record(
"url",
tracing::field::display(crate::redact::RedactedUrl::new(&url)),
);
match self.cache.get_transport_only(&url).await {
Ok(bytes) => match crate::parser::parse_json_checked::<OsvVulnRecord>(&bytes) {
Ok(record) => Some(record),
Err(e) => {
tracing::warn!(id, error = %e, "failed to parse OSV vulnerability record");
None
}
},
Err(e) => {
let (status, cause) = e.safe_tracing_summary();
tracing::warn!(
id,
status = ?status,
cause,
"failed to fetch OSV vulnerability record"
);
None
}
}
}
#[tracing::instrument(
skip(self, osv_eco, target),
fields(url = tracing::field::Empty, osv_eco = osv_eco.as_str())
)]
async fn query_single(
&self,
osv_eco: OsvEcosystem,
target: &ScanTarget,
) -> Option<OsvSingleQueryResponse> {
let url = self.single_query_url();
tracing::Span::current().record(
"url",
tracing::field::display(crate::redact::RedactedUrl::new(&url)),
);
let body = OsvQuery::new(target, osv_eco);
let bytes = match self.cache.post_json(&url, &body).await {
Ok(b) => b,
Err(e) => {
let (status, cause) = e.safe_tracing_summary();
tracing::warn!(
dep = %target.key,
status = ?status,
cause,
"OSV single-package requery failed"
);
return None;
}
};
match crate::parser::parse_json_checked::<OsvSingleQueryResponse>(&bytes) {
Ok(resp) => Some(resp),
Err(e) => {
tracing::warn!(dep = %target.key, error = %e, "failed to parse OSV single-package response");
None
}
}
}
fn store_raw_record_cache(&self, record: &Arc<OsvVulnRecord>) {
if self.raw_record_cache.len() >= MAX_CACHE_ENTRIES {
crate::cache_policy::evict_oldest_batch(
&self.raw_record_cache,
MAX_CACHE_ENTRIES,
|e| e.fetched_at,
);
}
self.raw_record_cache.insert(
record.id.clone(),
RawRecordCacheEntry {
record: Arc::clone(record),
fetched_at: Instant::now(),
},
);
}
fn store_query_cache(
&self,
osv_eco: OsvEcosystem,
target: &ScanTarget,
vuln_ids: &[(String, String)],
) {
if self.query_cache.len() >= MAX_CACHE_ENTRIES {
crate::cache_policy::evict_oldest_batch(&self.query_cache, MAX_CACHE_ENTRIES, |e| {
e.fetched_at
});
}
self.query_cache.insert(
QueryCacheKey::for_target(osv_eco, target),
QueryCacheEntry {
vuln_ids: vuln_ids.to_vec(),
fetched_at: Instant::now(),
},
);
}
fn store_record_cache(&self, advisory: &Arc<Advisory>) {
if self.record_cache.len() >= MAX_CACHE_ENTRIES {
crate::cache_policy::evict_oldest_batch(&self.record_cache, MAX_CACHE_ENTRIES, |e| {
e.fetched_at
});
}
self.record_cache.insert(
advisory.id.clone(),
RecordCacheEntry {
advisory: Arc::clone(advisory),
modified: advisory.modified.clone(),
fetched_at: Instant::now(),
},
);
}
}
fn mark_chunk_failed(chunk: &[ScanTarget], outcomes: &mut VulnerabilityMap) {
for t in chunk {
outcomes.insert(t.key.clone(), ScanOutcome::Skipped(SkipReason::QueryFailed));
}
}
fn log_scan_summary(outcomes: &VulnerabilityMap) {
let mut clean = 0usize;
let mut vulnerable = 0usize;
let mut skip_counts: HashMap<&'static str, usize> = HashMap::new();
for outcome in outcomes.values() {
match outcome {
ScanOutcome::Clean => clean += 1,
ScanOutcome::Vulnerable(_) => vulnerable += 1,
ScanOutcome::Skipped(reason) => {
*skip_counts.entry(reason.as_str()).or_insert(0) += 1;
}
}
}
let skipped: usize = skip_counts.values().sum();
let reasons = skip_counts
.iter()
.map(|(reason, count)| format!("{count} {reason}"))
.collect::<Vec<_>>()
.join(", ");
tracing::info!(
"OSV: scanned {}, clean {clean}, vulnerable {vulnerable}, skipped {skipped}{}",
outcomes.len(),
if reasons.is_empty() {
String::new()
} else {
format!(" ({reasons})")
}
);
}
#[cfg(test)]
mod tests {
#[test]
fn osv_query_wire_json_carries_plain_package_name() {
let t = ScanTarget::new(
crate::test_util::vuln_key("apple/swift-nio"),
OsvQueryName::Confirmed(OsvPackageName::new("github.com/apple/swift-nio").unwrap()),
OsvVersion::new("2.0.0"),
ConcreteVersion::new("2.0.0"),
);
let json = serde_json::to_value(OsvQuery::new(&t, OsvEcosystem::SwiftURL)).unwrap();
assert_eq!(json["package"]["name"], "github.com/apple/swift-nio");
assert_eq!(json["version"], "2.0.0");
}
use super::*;
use crate::{ConcreteVersion, EcosystemId};
use std::assert_matches;
use std::sync::atomic::{AtomicUsize, Ordering};
fn client() -> OsvClient {
OsvClient::new(Arc::new(HttpCache::new()))
}
async fn mock_client() -> (mockito::ServerGuard, OsvClient) {
let server = mockito::Server::new_async().await;
let client = OsvClient::with_base_url(Arc::new(HttpCache::new()), server.url());
(server, client)
}
const TEST_TIMEOUT: Duration = Duration::from_secs(30);
fn target(name: &str, version: &str) -> ScanTarget {
ScanTarget::new(
crate::test_util::vuln_key(name),
OsvQueryName::Confirmed(OsvPackageName::new(name).unwrap()),
OsvVersion::new(version),
ConcreteVersion::new(version),
)
}
#[test]
fn compare_version_strings_orders_numerically() {
let mut versions = vec![
"0.2.10".to_string(),
"0.2.2".to_string(),
"0.2.23".to_string(),
"0.2.0".to_string(),
];
versions.sort_by(|a, b| compare_version_strings(a, b));
assert_eq!(versions, vec!["0.2.0", "0.2.2", "0.2.10", "0.2.23"]);
}
#[test]
fn compare_version_strings_prefers_plain_release_over_same_prefix_prerelease() {
assert_eq!(
compare_version_strings("2.2.0", "2.2.0rc0"),
std::cmp::Ordering::Greater
);
assert_eq!(
compare_version_strings("2.2.0rc0", "2.2.0"),
std::cmp::Ordering::Less
);
let mut versions = vec!["2.2.0rc0".to_string(), "2.2.0".to_string()];
versions.sort_by(|a, b| compare_version_strings(a, b));
assert_eq!(versions, vec!["2.2.0rc0", "2.2.0"]);
}
#[test]
fn compare_version_strings_prefers_plain_release_over_dotted_semver_prerelease() {
assert_eq!(
compare_version_strings("2.0.0", "2.0.0-rc.1"),
std::cmp::Ordering::Greater
);
assert_eq!(
compare_version_strings("1.0.0", "1.0.0-beta.2"),
std::cmp::Ordering::Greater
);
let mut versions = vec!["2.0.0-rc.1".to_string(), "2.0.0".to_string()];
versions.sort_by(|a, b| compare_version_strings(a, b));
assert_eq!(versions, vec!["2.0.0-rc.1", "2.0.0"]);
}
#[test]
fn compare_version_strings_prefers_plain_release_over_pep440_and_go_pseudo_suffixes() {
assert_eq!(
compare_version_strings("2.2.0", "2.2.0.dev0"),
std::cmp::Ordering::Greater
);
assert_eq!(
compare_version_strings("2.2.0", "2.2.0rc1.dev0"),
std::cmp::Ordering::Greater
);
assert_eq!(
compare_version_strings("1.2.4", "1.2.4-0.20210101000000-abcdef123456"),
std::cmp::Ordering::Greater
);
}
#[test]
fn compare_version_strings_numeric_ordering_still_wins_over_prerelease_tiebreak() {
assert_eq!(
compare_version_strings("2.2.0", "1.9.0rc1"),
std::cmp::Ordering::Greater
);
}
#[test]
fn compare_version_strings_strips_leading_tag_prefix_before_ranking() {
assert_eq!(
compare_version_strings("v1.20.3", "v1.3.10"),
std::cmp::Ordering::Greater
);
assert_eq!(
compare_version_strings("V1.20.3", "v1.3.10"),
std::cmp::Ordering::Greater
);
let mut versions = vec!["v1.3.10".to_string(), "v1.20.3".to_string()];
versions.sort_by(|a, b| compare_version_strings(a, b));
assert_eq!(versions, vec!["v1.3.10", "v1.20.3"]);
}
#[tokio::test]
async fn scan_empty_input_returns_empty_map() {
let client = client();
let outcomes = client.scan(EcosystemId::Cargo, &[], TEST_TIMEOUT).await;
assert!(outcomes.is_empty());
}
#[tokio::test]
async fn check_candidates_empty_input_returns_empty_map() {
let client = client();
let statuses = client
.check_candidates(EcosystemId::Cargo, &[], TEST_TIMEOUT)
.await;
assert!(statuses.is_empty());
}
#[tokio::test]
async fn scan_all_clean_batch_result() {
let (mut server, client) = mock_client().await;
let _m = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.create_async()
.await;
let targets = vec![target("left-pad", "1.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
assert_eq!(outcomes.len(), 1);
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("left-pad")),
Some(ScanOutcome::Clean)
);
}
#[tokio::test]
async fn scan_batch_http_400_skips_whole_chunk() {
let (mut server, client) = mock_client().await;
let _m = server
.mock("POST", "/v1/querybatch")
.with_status(400)
.with_body(r#"{"code":3,"message":"error in query at index 0"}"#)
.create_async()
.await;
let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
assert_eq!(outcomes.len(), 2);
for key in ["a", "b"] {
assert_matches!(
outcomes.get(&crate::test_util::vuln_key(key)),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
}
}
#[tokio::test]
async fn scan_malformed_batch_json_skips_whole_chunk() {
let (mut server, client) = mock_client().await;
let _m = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body("not json")
.create_async()
.await;
let targets = vec![target("a", "1.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("a")),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
}
#[tokio::test]
async fn scan_deeply_nested_batch_json_skips_whole_chunk() {
let (mut server, client) = mock_client().await;
let deeply_nested = format!(
"{}1{}",
"[".repeat(crate::parser::MAX_JSON_NESTING_DEPTH + 1),
"]".repeat(crate::parser::MAX_JSON_NESTING_DEPTH + 1)
);
let _m = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(format!(
r#"{{"results":[{{"vulns":[{{"id":"ADV-1","modified":"2023-01-01T00:00:00Z","database_specific":{deeply_nested}}}]}}]}}"#
))
.create_async()
.await;
let targets = vec![target("pkg", "1.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("pkg")),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
}
#[tokio::test]
async fn scan_result_count_mismatch_drops_whole_chunk() {
let (mut server, client) = mock_client().await;
let _m = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.create_async()
.await;
let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
for key in ["a", "b"] {
assert_matches!(
outcomes.get(&crate::test_util::vuln_key(key)),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
}
}
#[tokio::test]
async fn scan_over_chunk_size_input_issues_exactly_two_batch_requests() {
let (mut server, client) = mock_client().await;
let n = BATCH_CHUNK_SIZE + 1;
let targets: Vec<ScanTarget> = (0..n)
.map(|i| target(&format!("pkg-{i}"), "1.0.0"))
.collect();
let call_count = Arc::new(AtomicUsize::new(0));
let call_count_clone = Arc::clone(&call_count);
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body_from_request(move |req| {
call_count_clone.fetch_add(1, Ordering::SeqCst);
let body = req.body().expect("request body");
let parsed: serde_json::Value =
serde_json::from_slice(body).expect("valid JSON request body");
let count = parsed["queries"].as_array().map_or(0, Vec::len);
let results = vec!["{}"; count].join(",");
format!(r#"{{"results":[{results}]}}"#).into_bytes()
})
.expect(2)
.create_async()
.await;
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
assert_eq!(outcomes.len(), n);
assert!(outcomes.values().all(|o| matches!(o, ScanOutcome::Clean)));
batch.assert_async().await;
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"a >1000-entry scan must issue exactly two chunked batch requests"
);
}
#[tokio::test]
async fn scan_deadline_exceeded_before_first_chunk_marks_everything_query_failed() {
let (_server, client) = mock_client().await;
let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
let outcomes = client
.scan(EcosystemId::Npm, &targets, Duration::from_secs(0))
.await;
for key in ["a", "b"] {
assert_matches!(
outcomes.get(&crate::test_util::vuln_key(key)),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
}
}
#[tokio::test]
async fn scan_filters_affected_entries_to_the_queried_package() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{"vulns":[{"id":"GHSA-cross-pkg","modified":"2023-01-01T00:00:00Z"}]}]}"#,
)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-cross-pkg")
.with_status(200)
.with_body(
r#"{"id":"GHSA-cross-pkg","modified":"2023-01-01T00:00:00Z",
"affected":[
{"package":{"name":"log4j-api","ecosystem":"Maven"},
"ecosystem_specific":{"severity":"LOW"},
"ranges":[{"type":"ECOSYSTEM","events":[{"fixed":"1.0.0"}]}]},
{"package":{"name":"log4j-core","ecosystem":"Maven"},
"ecosystem_specific":{"severity":"CRITICAL"},
"ranges":[{"type":"ECOSYSTEM","events":[{"fixed":"2.17.1"}]}]}
]}"#,
)
.create_async()
.await;
let targets = vec![target("log4j-core", "2.14.1")];
let outcomes = client
.scan(EcosystemId::Maven, &targets, TEST_TIMEOUT)
.await;
let Some(ScanOutcome::Vulnerable(dv)) =
outcomes.get(&crate::test_util::vuln_key("log4j-core"))
else {
panic!("expected Vulnerable outcome");
};
assert_eq!(dv.advisories.items()[0].severity, VulnSeverity::Critical);
assert_eq!(
dv.advisories.items()[0].fixed_versions,
vec![OsvVersion::new("2.17.1")]
);
}
const GHA_UNVERSIONED_BODY: &str = r#"{"results":[{"vulns":[{"id":"GHSA-cxww-7g56-2vh6","modified":"2025-01-22T17:31:55Z"}]}]}"#;
const GHA_RECORD_BODY: &str = r#"{"id":"GHSA-cxww-7g56-2vh6","modified":"2025-01-22T17:31:55Z",
"affected":[{"package":{"name":"actions/download-artifact","ecosystem":"GitHub Actions"},
"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.0.0"},{"fixed":"4.1.3"}]}]}]}"#;
async fn scan_gha(version: &str) -> ScanOutcome {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.match_request(|req| {
!req.utf8_lossy_body()
.is_ok_and(|b| b.contains("\"version\""))
})
.with_status(200)
.with_body(GHA_UNVERSIONED_BODY)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(200)
.with_body(GHA_RECORD_BODY)
.create_async()
.await;
let targets = vec![target("actions/download-artifact", version)];
let mut outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
outcomes
.remove(&crate::test_util::vuln_key("actions/download-artifact"))
.expect("one outcome per target")
}
#[tokio::test]
async fn gha_unversioned_query_matches_in_range_version_locally() {
let ScanOutcome::Vulnerable(dv) = scan_gha("4.1.2").await else {
panic!("expected Vulnerable");
};
assert_eq!(dv.advisories.items()[0].id, "GHSA-cxww-7g56-2vh6");
}
#[tokio::test]
async fn gha_unversioned_query_is_clean_for_out_of_range_version() {
assert_matches!(scan_gha("4.1.3").await, ScanOutcome::Clean);
}
const CODEQL_STUBS_BODY: &str = r#"{"results":[{"vulns":[{"id":"GHSA-vqf5-2xx6-9wfm","modified":"2025-02-03T00:00:00Z"}]}]}"#;
const CODEQL_RECORD_BODY: &str = r#"{"id":"GHSA-vqf5-2xx6-9wfm","modified":"2025-02-03T00:00:00Z",
"affected":[
{"package":{"name":"github/codeql-action","ecosystem":"GitHub Actions"},
"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.26.11"},{"fixed":"3.28.3"}]}],
"database_specific":{"last_known_affected_version_range":"<= 3.28.2"}},
{"package":{"name":"github/codeql-action","ecosystem":"GitHub Actions"},
"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.26.11"}]}],
"database_specific":{"last_known_affected_version_range":"< 3.0.0"}}]}"#;
#[tokio::test]
async fn gha_open_ended_range_honors_last_known_affected_version_range() {
for (version, vulnerable) in [
("3.28.2", true),
("2.30.0", true),
("3.28.3", false),
("4.38.2", false),
] {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(CODEQL_STUBS_BODY)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-vqf5-2xx6-9wfm")
.with_status(200)
.with_body(CODEQL_RECORD_BODY)
.create_async()
.await;
let targets = vec![target("github/codeql-action", version)];
let mut outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
let outcome = outcomes
.remove(&crate::test_util::vuln_key("github/codeql-action"))
.expect("one outcome per target");
if vulnerable {
assert_matches!(outcome, ScanOutcome::Vulnerable(_), "{version}");
} else {
assert_matches!(outcome, ScanOutcome::Clean, "{version}");
}
}
}
fn provisional_target(name: &str, version: &str) -> ScanTarget {
ScanTarget::new(
crate::test_util::vuln_key(name),
OsvQueryName::Provisional(OsvPackageName::new(name).unwrap()),
OsvVersion::new(version),
ConcreteVersion::new(version),
)
}
async fn scan_gha_provisional(version: &str, batch_body: &str) -> ScanOutcome {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(batch_body)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(200)
.with_body(GHA_RECORD_BODY)
.create_async()
.await;
let targets = vec![provisional_target("actions/download-artifact", version)];
let mut outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
outcomes
.remove(&crate::test_util::vuln_key("actions/download-artifact"))
.expect("one outcome per target")
}
#[tokio::test]
async fn provisional_name_positive_hit_stays_vulnerable() {
assert_matches!(
scan_gha_provisional("4.1.2", GHA_UNVERSIONED_BODY).await,
ScanOutcome::Vulnerable(_)
);
}
#[tokio::test]
async fn provisional_name_clean_result_is_downgraded_to_unconfirmed() {
for body in [GHA_UNVERSIONED_BODY, r#"{"results":[{}]}"#] {
let version = if body == GHA_UNVERSIONED_BODY {
"4.1.3"
} else {
"4.1.2"
};
assert_matches!(
scan_gha_provisional(version, body).await,
ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed),
"body={body}"
);
}
}
#[tokio::test]
async fn provisional_name_query_failure_and_cached_rescan() {
let (mut server, client) = mock_client().await;
let failing = server
.mock("POST", "/v1/querybatch")
.with_status(500)
.expect_at_least(1)
.create_async()
.await;
let targets = [provisional_target("actions/download-artifact", "4.1.2")];
let key = crate::test_util::vuln_key("actions/download-artifact");
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&key),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
failing.remove_async().await;
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.expect(1)
.create_async()
.await;
for _ in 0..2 {
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&key),
Some(ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed))
);
}
batch.assert_async().await;
}
#[tokio::test]
async fn provisional_name_record_for_other_casing_is_skipped() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(GHA_UNVERSIONED_BODY)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(200)
.with_body(
GHA_RECORD_BODY.replace("actions/download-artifact", "Actions/Download-Artifact"),
)
.create_async()
.await;
let targets = [provisional_target("actions/download-artifact", "4.1.2")];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Skipped(SkipReason::UnevaluableAdvisoryRange))
);
}
#[tokio::test]
async fn provisional_name_candidate_check_is_unverified_not_clean() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.create_async()
.await;
let statuses = client
.check_candidates(
EcosystemId::GithubActions,
&[provisional_target("actions/download-artifact", "4.1.2")],
TEST_TIMEOUT,
)
.await;
assert_matches!(
statuses.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(UpgradeStatus::CandidateUnverified {
reason: SkipReason::CanonicalNameUnconfirmed,
..
})
);
}
#[tokio::test]
async fn provisional_downgrade_follows_first_seen_duplicate_key() {
for (first_provisional, expected_clean) in [(false, true), (true, false)] {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.create_async()
.await;
let confirmed = target("actions/download-artifact", "4.1.2");
let provisional = provisional_target("actions/download-artifact", "4.1.2");
let targets = if first_provisional {
[provisional, confirmed]
} else {
[confirmed, provisional]
};
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
let outcome = outcomes.get(&crate::test_util::vuln_key("actions/download-artifact"));
if expected_clean {
assert_matches!(outcome, Some(ScanOutcome::Clean));
} else {
assert_matches!(
outcome,
Some(ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed))
);
}
}
}
#[tokio::test]
async fn gha_non_semver_version_is_skipped_without_querying() {
let (_server, client) = mock_client().await;
for version in ["4", "main", "1234567890abcdef1234567890abcdef12345678"] {
let targets = vec![target("actions/download-artifact", version)];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Skipped(SkipReason::UnmatchableVersion))
);
}
}
#[tokio::test]
async fn gha_unevaluable_range_is_skipped_not_clean() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(GHA_UNVERSIONED_BODY)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(200)
.with_body(
r#"{"id":"GHSA-cxww-7g56-2vh6","modified":"2025-01-22T17:31:55Z",
"affected":[{"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"not-a-version"}]}]}]}"#,
)
.create_async()
.await;
let targets = vec![target("actions/download-artifact", "4.1.2")];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Skipped(SkipReason::UnevaluableAdvisoryRange))
);
}
fn gha_record(id: &str, affected_events: &str) -> String {
format!(
r#"{{"id":"{id}","modified":"2025-01-01T00:00:00Z","affected":[{{"package":{{"name":"actions/download-artifact","ecosystem":"GitHub Actions"}},"ranges":[{{"type":"ECOSYSTEM","events":{affected_events}}}]}}]}}"#
)
}
#[tokio::test]
async fn gha_bare_major_fixed_bound_is_matched() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(GHA_UNVERSIONED_BODY)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(200)
.with_body(gha_record(
"GHSA-cxww-7g56-2vh6",
r#"[{"introduced":"0"},{"fixed":"2"}]"#,
))
.create_async()
.await;
let targets = vec![target("actions/download-artifact", "1.0.0")];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Vulnerable(_))
);
}
#[tokio::test]
async fn gha_mixed_batch_queries_only_matchable_targets() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v1/querybatch")
.match_request(|req| {
req.utf8_lossy_body().is_ok_and(|b| {
b.contains("actions/download-artifact") && !b.contains("floating-pin")
})
})
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.expect(1)
.create_async()
.await;
let targets = vec![
target("floating-pin", "v4"),
target("actions/download-artifact", "4.1.2"),
];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
batch.assert_async().await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("floating-pin")),
Some(ScanOutcome::Skipped(SkipReason::UnmatchableVersion))
);
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Clean)
);
}
struct IdentityNaming;
impl crate::lsp_helpers::OsvNaming for IdentityNaming {}
fn with_sibling_tags(target: ScanTarget, primary: &str, siblings: &[&str]) -> ScanTarget {
let versions = crate::lsp_helpers::InUseVersions::for_test(
ConcreteVersion::new(primary),
siblings.iter().map(|s| ConcreteVersion::new(*s)).collect(),
);
target.with_siblings(&versions, &IdentityNaming)
}
async fn scan_gha_with_siblings(primary: &str, siblings: &[&str], events: &str) -> ScanOutcome {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(GHA_UNVERSIONED_BODY)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(200)
.with_body(gha_record("GHSA-cxww-7g56-2vh6", events))
.create_async()
.await;
let targets = vec![with_sibling_tags(
target("actions/download-artifact", primary),
primary,
siblings,
)];
let mut outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
outcomes
.remove(&crate::test_util::vuln_key("actions/download-artifact"))
.expect("one outcome per target")
}
#[tokio::test]
async fn gha_sibling_scan_fixed_in_sibling_release_matches_primary_only() {
let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
"4.8.0",
&["4.9.0"],
r#"[{"introduced":"0"},{"fixed":"4.9.0"}]"#,
)
.await
else {
panic!("expected Vulnerable");
};
assert!(!dv.has_sibling_matches());
}
#[tokio::test]
async fn gha_sibling_scan_introduced_in_sibling_release_is_flagged_with_the_tag() {
let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
"4.8.0",
&["4.9.0"],
r#"[{"introduced":"4.9.0"},{"fixed":"4.9.1"}]"#,
)
.await
else {
panic!("expected Vulnerable");
};
let tags: Vec<&str> = dv
.sibling_match("GHSA-cxww-7g56-2vh6")
.expect("sibling-only advisory is attributed")
.iter()
.map(ConcreteVersion::as_str)
.collect();
assert_eq!(tags, ["4.9.0"]);
}
#[tokio::test]
async fn gha_sibling_scan_unrelated_range_is_clean() {
assert_matches!(
scan_gha_with_siblings("4.8.0", &["4.9.0"], r#"[{"introduced":"5.0.0"}]"#).await,
ScanOutcome::Clean
);
}
#[tokio::test]
async fn gha_sibling_scan_unparseable_sibling_is_skipped_not_clean() {
assert_matches!(
scan_gha_with_siblings("4.8.0", &["not-a-version"], r#"[{"introduced":"5.0.0"}]"#)
.await,
ScanOutcome::Skipped(SkipReason::UnevaluableAdvisoryRange)
);
}
#[tokio::test]
async fn server_side_target_with_siblings_is_skipped_unmatchable() {
let (_server, client) = mock_client().await;
let targets = vec![with_sibling_tags(
target("serde", "1.0.0"),
"1.0.0",
&["1.0.1"],
)];
let outcomes = client
.scan(EcosystemId::Cargo, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("serde")),
Some(ScanOutcome::Skipped(SkipReason::UnmatchableVersion))
);
}
#[tokio::test]
async fn unmappable_ecosystem_target_with_siblings_stays_unmappable() {
let (_server, client) = mock_client().await;
let targets = vec![with_sibling_tags(
target("group/project", "1.0.0"),
"1.0.0",
&["1.0.1"],
)];
let outcomes = client
.scan(EcosystemId::GitlabCi, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("group/project")),
Some(ScanOutcome::Skipped(SkipReason::UnmappableEcosystem))
);
}
#[tokio::test]
async fn gha_sibling_scan_primary_and_sibling_both_affected_has_no_sibling_entry() {
let ScanOutcome::Vulnerable(dv) =
scan_gha_with_siblings("4.8.0", &["4.9.0"], r#"[{"introduced":"0"}]"#).await
else {
panic!("expected Vulnerable");
};
assert!(!dv.has_sibling_matches());
}
#[tokio::test]
async fn gha_sibling_scan_names_every_matched_sibling_in_order() {
let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
"4.8.0",
&["4.9.0", "4.10.0", "5.0.0"],
r#"[{"introduced":"4.9.0"},{"fixed":"4.11.0"}]"#,
)
.await
else {
panic!("expected Vulnerable");
};
assert_eq!(dv.advisories.total(), 1);
let tags: Vec<&str> = dv
.sibling_match("GHSA-cxww-7g56-2vh6")
.expect("sibling-only advisory")
.iter()
.map(ConcreteVersion::as_str)
.collect();
assert_eq!(tags, ["4.9.0", "4.10.0"]);
}
#[tokio::test]
async fn gha_sibling_scan_lower_sibling_fix_is_not_recommended_as_upgrade() {
let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
"4.9.0",
&["4.8.0"],
r#"[{"introduced":"0"},{"fixed":"4.8.1"}]"#,
)
.await
else {
panic!("expected Vulnerable");
};
assert!(dv.sibling_match("GHSA-cxww-7g56-2vh6").is_some());
assert!(dv.recommended_fix(None).is_none());
let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
"4.9.0",
&["4.8.0"],
r#"[{"introduced":"0"},{"fixed":"5.0.0"}]"#,
)
.await
else {
panic!("expected Vulnerable");
};
assert_eq!(dv.recommended_fix(None).unwrap().version, "5.0.0");
}
#[tokio::test]
async fn gha_confirmed_match_outranks_an_unevaluable_sibling() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{"vulns":[{"id":"GHSA-good","modified":"2025-01-01T00:00:00Z"},{"id":"GHSA-bad","modified":"2025-01-01T00:00:00Z"}]}]}"#,
)
.create_async()
.await;
let _good = server
.mock("GET", "/v1/vulns/GHSA-good")
.with_status(200)
.with_body(gha_record(
"GHSA-good",
r#"[{"introduced":"0"},{"fixed":"5.0.0"}]"#,
))
.create_async()
.await;
let _bad = server
.mock("GET", "/v1/vulns/GHSA-bad")
.with_status(200)
.with_body(gha_record(
"GHSA-bad",
r#"[{"introduced":"0"},{"fixed":"not-a-version"}]"#,
))
.create_async()
.await;
let targets = vec![target("actions/download-artifact", "4.1.2")];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
let Some(ScanOutcome::Vulnerable(dv)) =
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact"))
else {
panic!("expected Vulnerable");
};
assert_eq!(dv.advisories.total(), 1);
}
#[tokio::test]
async fn gha_more_than_the_record_cap_is_truncated_not_clean() {
let (mut server, client) = mock_client().await;
let vulns: Vec<String> = (0..=MAX_ADVISORY_RECORDS)
.map(|i| format!(r#"{{"id":"GHSA-{i}","modified":"2025-01-01T00:00:00Z"}}"#))
.collect();
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(format!(
r#"{{"results":[{{"vulns":[{}]}}]}}"#,
vulns.join(",")
))
.create_async()
.await;
let targets = vec![target("actions/download-artifact", "4.1.2")];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Skipped(SkipReason::Truncated))
);
}
#[tokio::test]
async fn gha_record_fetch_failure_is_query_failed() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(GHA_UNVERSIONED_BODY)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(500)
.create_async()
.await;
let targets = vec![target("actions/download-artifact", "4.1.2")];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
}
#[tokio::test]
async fn gha_truncation_recovery_matches_locally() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{"vulns":[],"next_page_token":"t"},{}]}"#)
.create_async()
.await;
let _single = server
.mock("POST", "/v1/query")
.match_request(|req| {
!req.utf8_lossy_body()
.is_ok_and(|b| b.contains("\"version\""))
})
.with_status(200)
.with_body(format!(
r#"{{"vulns":[{}]}}"#,
gha_record(
"GHSA-cxww-7g56-2vh6",
r#"[{"introduced":"4.0.0"},{"fixed":"4.1.3"}]"#
)
))
.create_async()
.await;
let targets = vec![
target("actions/download-artifact", "4.1.2"),
target("actions/other", "9.9.9"),
];
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Vulnerable(_))
);
}
#[test]
fn version_matching_table_and_wire_json() {
assert_eq!(
OsvEcosystem::CratesIo.version_matching(),
VersionMatching::ServerSide
);
assert_eq!(
OsvEcosystem::GitHubActions.version_matching(),
VersionMatching::LocalUnversioned
);
let t = target("actions/download-artifact", "4.1.2");
let local = serde_json::to_value(OsvQuery::new(&t, OsvEcosystem::GitHubActions)).unwrap();
assert!(local.get("version").is_none());
let server_side = serde_json::to_value(OsvQuery::new(&t, OsvEcosystem::Npm)).unwrap();
assert_eq!(server_side["version"], "4.1.2");
}
#[tokio::test]
#[ignore = "requires network access"]
async fn live_gha_bare_major_bound_is_vulnerable() {
let targets = vec![target("embano1/wip", "1.0.0")];
let outcomes = client()
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("embano1/wip")),
Some(ScanOutcome::Vulnerable(_))
);
}
#[tokio::test]
#[ignore = "requires network access"]
async fn live_gha_in_range_version_is_vulnerable() {
let targets = vec![target("actions/download-artifact", "4.1.2")];
let outcomes = client()
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
Some(ScanOutcome::Vulnerable(_))
);
}
#[tokio::test]
async fn scan_malformed_advisory_id_is_dropped() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{"vulns":[{"id":"evil](javascript:alert(1))","modified":"2023-01-01T00:00:00Z"}]}]}"#,
)
.create_async()
.await;
let targets = vec![target("pkg", "1.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("pkg"))
else {
panic!(
"expected Vulnerable outcome, got {:?}",
outcomes.get(&crate::test_util::vuln_key("pkg"))
);
};
assert_eq!(dv.advisories.total(), 1);
assert!(dv.advisories.items().is_empty());
}
#[tokio::test]
async fn recover_truncated_single_query_that_itself_paginates_is_skipped_truncated() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
.create_async()
.await;
let _requery = server
.mock("POST", "/v1/query")
.with_status(200)
.with_body(
r#"{"vulns":[{"id":"GHSA-1","modified":"2023-01-01T00:00:00Z"}],"next_page_token":"still-more"}"#,
)
.create_async()
.await;
let targets = vec![target("linux", "5.10.1")];
let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("linux")),
Some(ScanOutcome::Skipped(SkipReason::Truncated))
);
}
#[tokio::test]
async fn recover_truncated_permit_wait_bounded_by_deadline_fails_query_failed() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
.create_async()
.await;
let _permits: Vec<_> = futures::future::join_all(
(0..RECORD_FETCH_CONCURRENCY)
.map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
)
.await
.into_iter()
.map(|permit| permit.expect("semaphore is not closed"))
.collect();
let targets = vec![target("linux", "5.10.1")];
let outcomes = tokio::time::timeout(
Duration::from_secs(10),
client.scan(EcosystemId::Go, &targets, Duration::from_secs(1)),
)
.await
.expect("the requery must fail at the deadline, not hang until a permit frees up");
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("linux")),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
}
#[tokio::test]
async fn scan_vulnerable_fetches_advisory_record() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{"vulns":[{"id":"RUSTSEC-2020-0071","modified":"2023-01-01T00:00:00Z"}]}]}"#)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
.with_status(200)
.with_body(
r#"{"id":"RUSTSEC-2020-0071","modified":"2023-01-01T00:00:00Z",
"summary":"Potential segfault","database_specific":{"severity":"HIGH"},
"affected":[
{"package":{"name":"time","ecosystem":"crates.io"},"ranges":[
{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.2.0"},{"fixed":"0.1.44"}]},
{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.2.4"},{"fixed":"0.1.43"}]},
{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.2.2"},{"fixed":"0.2.23"}]},
{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.2.1"},{"fixed":"0.2.3"}]}
]}
]}"#,
)
.create_async()
.await;
let targets = vec![target("time", "0.1.43")];
let outcomes = client
.scan(EcosystemId::Cargo, &targets, TEST_TIMEOUT)
.await;
let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("time"))
else {
panic!(
"expected Vulnerable outcome, got {:?}",
outcomes.get(&crate::test_util::vuln_key("time"))
);
};
assert_eq!(dv.advisories.total(), 1);
assert_eq!(dv.advisories.items().len(), 1);
assert_eq!(dv.advisories.items()[0].id, "RUSTSEC-2020-0071");
assert_eq!(dv.advisories.items()[0].severity, VulnSeverity::High);
assert_eq!(
dv.advisories.items()[0].fixed_versions,
vec![
"0.1.43", "0.1.44", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.23"
]
.into_iter()
.map(OsvVersion::new)
.collect::<Vec<_>>()
);
assert_eq!(
dv.advisories.items()[0].fixed_versions.last(),
Some(&OsvVersion::new("0.2.23"))
);
}
#[tokio::test]
async fn scan_dropped_advisory_record_still_yields_vulnerable_with_fewer_advisories() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{"vulns":[{"id":"MISSING-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/MISSING-1")
.with_status(404)
.create_async()
.await;
let targets = vec![target("pkg", "1.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("pkg"))
else {
panic!(
"expected Vulnerable outcome, got {:?}",
outcomes.get(&crate::test_util::vuln_key("pkg"))
);
};
assert_eq!(dv.advisories.total(), 1);
assert!(dv.advisories.items().is_empty());
}
#[tokio::test]
async fn scan_next_page_token_is_never_rendered_as_clean() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
.create_async()
.await;
let _requery = server
.mock("POST", "/v1/query")
.with_status(200)
.with_body(
r#"{"vulns":[{"id":"GHSA-1","modified":"2023-01-01T00:00:00Z","database_specific":{"severity":"CRITICAL"}}]}"#,
)
.create_async()
.await;
let targets = vec![target("linux", "5.10.1")];
let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
let Some(outcome) = outcomes.get(&crate::test_util::vuln_key("linux")) else {
panic!("dependency missing from outcome map");
};
assert!(
!matches!(outcome, ScanOutcome::Clean),
"a truncated batch result must never render as clean"
);
assert_matches!(outcome, ScanOutcome::Vulnerable(_));
}
#[tokio::test]
async fn scan_advisory_fetch_is_capped_but_total_known_reflects_full_count() {
let (mut server, client) = mock_client().await;
let vuln_count = MAX_ADVISORY_RECORDS + 10;
let vulns_json: String = (0..vuln_count)
.map(|i| format!(r#"{{"id":"ADV-{i}","modified":"2023-01-01T00:00:00Z"}}"#))
.collect::<Vec<_>>()
.join(",");
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(format!(r#"{{"results":[{{"vulns":[{vulns_json}]}}]}}"#))
.create_async()
.await;
let record = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/vulns/ADV-\d+$".into()),
)
.with_status(200)
.with_body(r#"{"id":"ADV-x","modified":"2023-01-01T00:00:00Z"}"#)
.expect(MAX_ADVISORY_RECORDS)
.create_async()
.await;
let targets = vec![target("rack", "2.0.5")];
let outcomes = client
.scan(EcosystemId::Bundler, &targets, TEST_TIMEOUT)
.await;
let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("rack"))
else {
panic!("expected Vulnerable outcome");
};
assert_eq!(dv.advisories.total(), vuln_count);
assert_eq!(dv.advisories.items().len(), MAX_ADVISORY_RECORDS);
assert_eq!(
dv.advisories_for_display().items().len(),
ADVISORY_DISPLAY_CAP
);
assert_eq!(dv.advisories_for_display().total(), vuln_count);
record.assert_async().await;
}
#[tokio::test]
async fn scan_recommended_fix_considers_advisories_beyond_display_cap() {
let (mut server, client) = mock_client().await;
let advisory_count = 18;
let vulns_json: String = (0..advisory_count)
.map(|i| format!(r#"{{"id":"ADV-{i}","modified":"2023-01-01T00:00:00Z"}}"#))
.collect::<Vec<_>>()
.join(",");
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(format!(r#"{{"results":[{{"vulns":[{vulns_json}]}}]}}"#))
.create_async()
.await;
for i in 0..advisory_count {
let fixed = if i == advisory_count - 1 {
"0.56.0"
} else {
"0.20.0"
};
let _record = server
.mock("GET", format!("/v1/vulns/ADV-{i}").as_str())
.with_status(200)
.with_body(format!(
r#"{{"id":"ADV-{i}","modified":"2023-01-01T00:00:00Z",
"database_specific":{{"severity":"HIGH"}},
"affected":[{{"package":{{"name":"golang.org/x/net","ecosystem":"Go"}},
"ranges":[{{"type":"SEMVER","events":[{{"introduced":"0"}},{{"fixed":"{fixed}"}}]}}]}}]}}"#
))
.create_async()
.await;
}
let targets = vec![target("golang.org/x/net", "0.17.0")];
let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
let Some(ScanOutcome::Vulnerable(dv)) =
outcomes.get(&crate::test_util::vuln_key("golang.org/x/net"))
else {
panic!("expected Vulnerable outcome");
};
assert_eq!(dv.advisories.total(), advisory_count);
assert_eq!(
dv.advisories.items().len(),
advisory_count,
"every advisory must be fetched: advisory_count is well under MAX_ADVISORY_RECORDS"
);
let fix = dv.recommended_fix(None).expect("a fix must be recommended");
assert_eq!(
fix.version, "0.56.0",
"recommended_fix must consider the advisory beyond ADVISORY_DISPLAY_CAP"
);
let display = dv.advisories_for_display();
assert_eq!(
display.items().len(),
ADVISORY_DISPLAY_CAP,
"rendering must still truncate to ADVISORY_DISPLAY_CAP"
);
assert_eq!(display.total(), advisory_count);
}
#[tokio::test]
async fn scan_second_call_within_ttl_issues_zero_requests() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.expect(1)
.create_async()
.await;
let targets = vec![target("pkg", "1.0.0")];
client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
batch.assert_async().await;
}
#[tokio::test]
async fn check_candidates_maps_clean_and_vulnerable() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{},{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/ADV-1")
.with_status(200)
.with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
.create_async()
.await;
let candidates = vec![target("clean-pkg", "2.0.0"), target("bad-pkg", "2.0.0")];
let statuses = client
.check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
.await;
assert_matches!(
statuses.get(&crate::test_util::vuln_key("clean-pkg")),
Some(UpgradeStatus::CandidateClean { version }) if version == "2.0.0"
);
assert_matches!(
statuses.get(&crate::test_util::vuln_key("bad-pkg")),
Some(UpgradeStatus::CandidateVulnerable { version, advisory_ids, .. })
if version == "2.0.0"
&& advisory_ids.items() == ["ADV-1".to_string()]
&& advisory_ids.total() == 1
);
}
#[tokio::test]
async fn check_candidates_duplicate_keys_share_the_real_outcome() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.expect(1)
.create_async()
.await;
let candidates = vec![target("dup", "2.0.0"), target("dup", "2.0.0")];
let statuses = client
.check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
.await;
batch.assert_async().await;
assert_eq!(statuses.len(), 1);
assert_matches!(
statuses.get(&crate::test_util::vuln_key("dup")),
Some(UpgradeStatus::CandidateClean { version }) if version == "2.0.0"
);
}
#[tokio::test]
async fn check_candidates_three_duplicates_share_one_query_and_outcome() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.expect(1)
.create_async()
.await;
let candidates = vec![
target("dup", "2.0.0"),
target("dup", "2.0.0"),
target("dup", "2.0.0"),
];
let statuses = client
.check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
.await;
batch.assert_async().await;
assert_eq!(statuses.len(), 1);
assert_matches!(
statuses.get(&crate::test_util::vuln_key("dup")),
Some(UpgradeStatus::CandidateClean { .. })
);
}
#[tokio::test]
async fn check_candidates_mixed_duplicate_and_unique_keys_keep_their_own_outcomes() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{},{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
)
.expect(1)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/ADV-1")
.with_status(200)
.with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
.create_async()
.await;
let candidates = vec![
target("dup", "2.0.0"),
target("other", "3.0.0"),
target("dup", "2.0.0"),
];
let statuses = client
.check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
.await;
assert_eq!(statuses.len(), 2);
assert_matches!(
statuses.get(&crate::test_util::vuln_key("dup")),
Some(UpgradeStatus::CandidateClean { version }) if version == "2.0.0"
);
assert_matches!(
statuses.get(&crate::test_util::vuln_key("other")),
Some(UpgradeStatus::CandidateVulnerable { version, advisory_ids, .. })
if version == "3.0.0" && advisory_ids.items() == ["ADV-1".to_string()]
);
}
#[tokio::test]
async fn check_candidates_vulnerable_duplicates_carry_advisories() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
)
.expect(1)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/ADV-1")
.with_status(200)
.with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
.create_async()
.await;
let candidates = vec![target("dup", "2.0.0"), target("dup", "2.0.0")];
let statuses = client
.check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
.await;
assert_matches!(
statuses.get(&crate::test_util::vuln_key("dup")),
Some(UpgradeStatus::CandidateVulnerable { advisory_ids, .. })
if advisory_ids.items() == ["ADV-1".to_string()] && advisory_ids.total() == 1
);
}
#[tokio::test]
async fn scan_duplicate_keys_are_queried_once_and_keep_their_own_outcomes() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{},{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
)
.expect(1)
.create_async()
.await;
let _record = server
.mock("GET", "/v1/vulns/ADV-1")
.with_status(200)
.with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
.create_async()
.await;
let targets = vec![
target("dup", "2.0.0"),
target("other", "3.0.0"),
target("dup", "2.0.0"),
];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
batch.assert_async().await;
assert_eq!(outcomes.len(), 2);
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("dup")),
Some(ScanOutcome::Clean)
);
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("other")),
Some(ScanOutcome::Vulnerable(_))
);
}
#[tokio::test]
async fn resolve_same_key_different_version_queries_only_the_first() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.expect(1)
.create_async()
.await;
let targets = vec![target("dup", "1.0.0"), target("dup", "2.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
batch.assert_async().await;
assert_eq!(outcomes.len(), 1);
assert_matches!(
outcomes.get(&crate::test_util::vuln_key("dup")),
Some(ScanOutcome::Clean)
);
}
#[tokio::test]
async fn check_candidates_duplicate_keys_all_unverified_when_query_fails() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(500)
.create_async()
.await;
let candidates = vec![target("dup", "2.0.0"), target("dup", "2.0.0")];
let statuses = client
.check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
.await;
assert_matches!(
statuses.get(&crate::test_util::vuln_key("dup")),
Some(UpgradeStatus::CandidateUnverified {
reason: SkipReason::QueryFailed,
..
})
);
}
#[tokio::test]
async fn check_candidates_maps_unmappable_ecosystem_to_candidate_unverified() {
let client = client();
let candidates = vec![target("pkg", "2.0.0")];
let statuses = client
.check_candidates(EcosystemId::GitlabCi, &candidates, TEST_TIMEOUT)
.await;
assert_matches!(
statuses.get(&crate::test_util::vuln_key("pkg")),
Some(UpgradeStatus::CandidateUnverified {
version,
reason: SkipReason::UnmappableEcosystem
}) if version == "2.0.0"
);
}
#[tokio::test]
async fn check_candidates_forces_none_severity_when_a_record_failed_to_fetch() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{"vulns":[
{"id":"ADV-OK","modified":"2023-01-01T00:00:00Z"},
{"id":"ADV-MISSING","modified":"2023-01-01T00:00:00Z"}
]}]}"#,
)
.create_async()
.await;
let _fetched = server
.mock("GET", "/v1/vulns/ADV-OK")
.with_status(200)
.with_body(r#"{"id":"ADV-OK","modified":"2023-01-01T00:00:00Z"}"#)
.create_async()
.await;
let _missing = server
.mock("GET", "/v1/vulns/ADV-MISSING")
.with_status(500)
.create_async()
.await;
let candidates = vec![target("bad-pkg", "2.0.0")];
let statuses = client
.check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
.await;
let Some(UpgradeStatus::CandidateVulnerable {
advisory_ids,
worst_severity,
..
}) = statuses.get(&crate::test_util::vuln_key("bad-pkg"))
else {
panic!(
"expected CandidateVulnerable, got {:?}",
statuses.get(&crate::test_util::vuln_key("bad-pkg"))
);
};
assert_eq!(advisory_ids.items(), ["ADV-OK".to_string()]);
assert_eq!(
advisory_ids.total(),
2,
"the unfetched record still counts toward total"
);
assert_eq!(
*worst_severity, None,
"an incomplete advisory set must never report a severity — the caller's \
`latest_verdict` treats only `Some(Informational)` as non-blocking, and `None` \
must stay blocking (Flagged), never silently pass as clean"
);
}
#[tokio::test]
async fn check_candidates_deadline_exceeded_yields_candidate_unverified() {
let client = client();
let candidates = vec![target("pkg", "2.0.0")];
let statuses = client
.check_candidates(EcosystemId::Npm, &candidates, Duration::ZERO)
.await;
assert_matches!(
statuses.get(&crate::test_util::vuln_key("pkg")),
Some(UpgradeStatus::CandidateUnverified {
version,
reason: SkipReason::QueryFailed,
}) if version == "2.0.0"
);
}
#[tokio::test]
async fn check_candidates_uses_display_version_not_wire_version() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.create_async()
.await;
let candidate = ScanTarget::new(
crate::test_util::vuln_key("golang.org/x/text"),
OsvQueryName::Confirmed(OsvPackageName::new("golang.org/x/text").unwrap()),
OsvVersion::new("0.4.0"),
ConcreteVersion::new("v0.4.0"),
);
let statuses = client
.check_candidates(EcosystemId::Go, &[candidate], TEST_TIMEOUT)
.await;
assert_matches!(
statuses.get(&crate::test_util::vuln_key("golang.org/x/text")),
Some(UpgradeStatus::CandidateClean { version }) if version == "v0.4.0"
);
}
#[test]
fn query_cache_evicts_oldest_when_max_entries_reached() {
let client = client();
for i in 0..MAX_CACHE_ENTRIES {
client.store_query_cache(
OsvEcosystem::Npm,
&target(&format!("pkg-{i}"), "1.0.0"),
&[],
);
}
assert_eq!(client.query_cache.len(), MAX_CACHE_ENTRIES);
client.store_query_cache(OsvEcosystem::Npm, &target("overflow", "1.0.0"), &[]);
assert!(
client.query_cache.len() <= MAX_CACHE_ENTRIES,
"query_cache must stay bounded at MAX_CACHE_ENTRIES, got {}",
client.query_cache.len()
);
assert!(client.query_cache.len() < MAX_CACHE_ENTRIES + 1);
}
#[test]
fn record_cache_evicts_oldest_when_max_entries_reached() {
let client = client();
for i in 0..MAX_CACHE_ENTRIES {
let advisory = Arc::new(
Advisory::new(
format!("ADV-{i}"),
"2023-01-01T00:00:00Z".to_string(),
VulnSeverity::Unknown,
)
.expect("valid osv id"),
);
client.store_record_cache(&advisory);
}
assert_eq!(client.record_cache.len(), MAX_CACHE_ENTRIES);
let overflow = Arc::new(
Advisory::new(
"ADV-overflow".to_string(),
"2023-01-01T00:00:00Z".to_string(),
VulnSeverity::Unknown,
)
.expect("valid osv id"),
);
client.store_record_cache(&overflow);
assert!(
client.record_cache.len() <= MAX_CACHE_ENTRIES,
"record_cache must stay bounded at MAX_CACHE_ENTRIES, got {}",
client.record_cache.len()
);
}
#[tokio::test]
async fn query_cache_ttl_expiry_forces_requery() {
let (mut server, client) = mock_client().await;
let t = target("pkg", "1.0.0");
client.query_cache.insert(
QueryCacheKey::for_target(OsvEcosystem::Npm, &t),
QueryCacheEntry {
vuln_ids: vec![],
fetched_at: Instant::now()
.checked_sub(QUERY_CACHE_TTL + Duration::from_secs(1))
.expect("test clock has more than QUERY_CACHE_TTL of headroom"),
},
);
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.expect(1)
.create_async()
.await;
client.scan(EcosystemId::Npm, &[t], TEST_TIMEOUT).await;
batch.assert_async().await;
}
#[tokio::test]
async fn record_cache_newer_modified_invalidates_and_refetches() {
let (mut server, client) = mock_client().await;
client.record_cache.insert(
"ADV-1".to_string(),
RecordCacheEntry {
advisory: Arc::new(
Advisory::new(
"ADV-1".to_string(),
"2020-01-01T00:00:00Z".to_string(),
VulnSeverity::Unknown,
)
.expect("valid osv id")
.with_summary("stale summary".to_string()),
),
modified: "2020-01-01T00:00:00Z".to_string(),
fetched_at: Instant::now(),
},
);
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(
r#"{"results":[{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
)
.create_async()
.await;
let record = server
.mock("GET", "/v1/vulns/ADV-1")
.with_status(200)
.with_body(
r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z","summary":"updated summary"}"#,
)
.expect(1)
.create_async()
.await;
let targets = vec![target("pkg", "1.0.0")];
let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("pkg"))
else {
panic!("expected Vulnerable outcome");
};
assert_eq!(
dv.advisories.items()[0].summary.as_deref(),
Some("updated summary")
);
record.assert_async().await;
}
#[tokio::test]
async fn gha_unversioned_records_are_fetched_once_across_versions() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(GHA_UNVERSIONED_BODY)
.expect(2)
.create_async()
.await;
let record = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(200)
.with_body(GHA_RECORD_BODY)
.expect(1)
.create_async()
.await;
let key = crate::test_util::vuln_key("actions/download-artifact");
let scan = |version: &'static str| {
let client = &client;
let key = key.clone();
async move {
client
.scan(
EcosystemId::GithubActions,
&[target("actions/download-artifact", version)],
TEST_TIMEOUT,
)
.await
.remove(&key)
.expect("one outcome per target")
}
};
assert_matches!(scan("4.1.2").await, ScanOutcome::Vulnerable(_));
assert_matches!(scan("4.1.3").await, ScanOutcome::Clean);
client.query_cache.clear();
assert_matches!(scan("4.0.5").await, ScanOutcome::Vulnerable(_));
batch.assert_async().await;
record.assert_async().await;
}
#[tokio::test]
async fn gha_failed_record_fetch_is_not_cached() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v1/querybatch")
.with_status(200)
.with_body(GHA_UNVERSIONED_BODY)
.create_async()
.await;
let failing = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(500)
.expect_at_least(1)
.create_async()
.await;
let targets = [target("actions/download-artifact", "4.1.2")];
let key = crate::test_util::vuln_key("actions/download-artifact");
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(
outcomes.get(&key),
Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
);
failing.remove_async().await;
let _ok = server
.mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
.with_status(200)
.with_body(GHA_RECORD_BODY)
.expect(1)
.create_async()
.await;
let outcomes = client
.scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
.await;
assert_matches!(outcomes.get(&key), Some(ScanOutcome::Vulnerable(_)));
}
#[tokio::test]
async fn resolve_local_stubs_fetches_records_across_dependencies_concurrently() {
let (addr, _in_flight, watermark, total) =
spawn_mock_vuln_server(Duration::from_millis(100)).await;
let client = OsvClient::with_base_url(Arc::new(HttpCache::new()), format!("http://{addr}"));
let pending: Vec<_> = (0..25)
.map(|i| {
(
target(&format!("actions/a{i}"), "1.0.0"),
vec![(format!("ADV-{i}"), "2023-01-01T00:00:00Z".to_string())],
)
})
.collect();
let mut outcomes = VulnerabilityMap::new();
client
.resolve_local_stubs(
OsvEcosystem::GitHubActions,
pending,
&mut outcomes,
Instant::now() + TEST_TIMEOUT,
)
.await;
assert_eq!(outcomes.len(), 25);
assert_eq!(total.load(Ordering::SeqCst), 25);
let peak = watermark.load(Ordering::SeqCst);
assert!(
peak > 1,
"records of separate dependencies must be in flight together"
);
assert!(
peak <= RECORD_FETCH_CONCURRENCY,
"peak in-flight ({peak}) must stay within the client-wide bound"
);
}
#[tokio::test]
async fn raw_record_cache_refetches_when_stub_modified_changes() {
let (mut server, client) = mock_client().await;
let record = server
.mock("GET", "/v1/vulns/ADV-1")
.with_status(200)
.with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
.expect(2)
.create_async()
.await;
let deadline = Instant::now() + TEST_TIMEOUT;
for modified in [
"2023-01-01T00:00:00Z",
"2023-01-01T00:00:00Z",
"2024-01-01T00:00:00Z",
] {
assert!(
client
.fetch_raw_record("ADV-1", modified, deadline)
.await
.is_some()
);
}
record.assert_async().await;
}
#[test]
fn raw_record_cache_evicts_oldest_when_max_entries_reached() {
let client = OsvClient::new(Arc::new(HttpCache::new()));
for i in 0..=MAX_CACHE_ENTRIES {
client.store_raw_record_cache(&Arc::new(OsvVulnRecord {
id: format!("ADV-{i}"),
..OsvVulnRecord::default()
}));
}
assert!(
client.raw_record_cache.len() <= MAX_CACHE_ENTRIES,
"raw_record_cache must stay bounded, got {}",
client.raw_record_cache.len()
);
}
async fn spawn_mock_vuln_server(
delay: Duration,
) -> (
std::net::SocketAddr,
Arc<AtomicUsize>,
Arc<AtomicUsize>,
Arc<AtomicUsize>,
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("listener has a local addr");
let in_flight = Arc::new(AtomicUsize::new(0));
let watermark = Arc::new(AtomicUsize::new(0));
let total = Arc::new(AtomicUsize::new(0));
let (in_flight2, watermark2, total2) = (
Arc::clone(&in_flight),
Arc::clone(&watermark),
Arc::clone(&total),
);
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let (in_flight, watermark, total) = (
Arc::clone(&in_flight2),
Arc::clone(&watermark2),
Arc::clone(&total2),
);
tokio::spawn(async move {
let mut buf = [0_u8; 4096];
let n = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
let id = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|path| path.rsplit('/').next())
.unwrap_or("unknown")
.to_string();
let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
watermark.fetch_max(now, Ordering::SeqCst);
total.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(delay).await;
let body = format!(r#"{{"id":"{id}","modified":"2023-01-01T00:00:00Z"}}"#);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
in_flight.fetch_sub(1, Ordering::SeqCst);
});
}
});
(addr, in_flight, watermark, total)
}
#[tokio::test]
async fn record_fetch_semaphore_bounds_client_wide_concurrent_fetches() {
let (addr, _in_flight, watermark, total) =
spawn_mock_vuln_server(Duration::from_millis(50)).await;
let client = Arc::new(OsvClient::with_base_url(
Arc::new(HttpCache::new()),
format!("http://{addr}"),
));
let deadline = Instant::now() + Duration::from_secs(30);
let handles: Vec<_> = (0..20)
.map(|i| {
let client = Arc::clone(&client);
tokio::spawn(async move {
client
.fetch_record_single_flight(&format!("ADVISORY-{i}"), deadline)
.await
})
})
.collect();
for handle in handles {
assert!(handle.await.expect("task did not panic").is_some());
}
let peak = watermark.load(Ordering::SeqCst);
assert!(
peak <= RECORD_FETCH_CONCURRENCY,
"peak concurrent in-flight fetches ({peak}) exceeded the client-wide bound \
({RECORD_FETCH_CONCURRENCY})"
);
assert_eq!(
total.load(Ordering::SeqCst),
20,
"all 20 distinct-id fetches must still land, just not all at once"
);
}
#[tokio::test]
async fn fetch_record_single_flight_coalesces_concurrent_same_id_requests() {
let (addr, _in_flight, _watermark, total) =
spawn_mock_vuln_server(Duration::from_millis(50)).await;
let client = Arc::new(OsvClient::with_base_url(
Arc::new(HttpCache::new()),
format!("http://{addr}"),
));
let deadline = Instant::now() + Duration::from_secs(30);
let handles: Vec<_> = (0..20)
.map(|_| {
let client = Arc::clone(&client);
tokio::spawn(
async move { client.fetch_record_single_flight("SAME-ID", deadline).await },
)
})
.collect();
for handle in handles {
let record = handle.await.expect("task did not panic");
assert_eq!(
record.map(|r| r.id.clone()),
Some("SAME-ID".to_string()),
"every follower must observe the leader's real fetched value"
);
}
assert_eq!(
total.load(Ordering::SeqCst),
1,
"20 concurrent fetches of the same id must land exactly one HTTP request"
);
}
#[tokio::test]
async fn fetch_record_single_flight_fails_fast_when_permit_wait_exceeds_deadline() {
let client = client();
let _permits: Vec<_> = futures::future::join_all(
(0..RECORD_FETCH_CONCURRENCY)
.map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
)
.await
.into_iter()
.map(|permit| permit.expect("semaphore is not closed"))
.collect();
let deadline = Instant::now() + Duration::from_millis(50);
let result = tokio::time::timeout(
Duration::from_secs(5),
client.fetch_record_single_flight("UNREACHABLE-ID", deadline),
)
.await
.expect("the wait must fail at the deadline, not hang until a permit frees up");
assert!(
result.is_none(),
"a permit wait that outlives the deadline must fail closed, not eventually succeed"
);
}
#[tokio::test]
async fn fetch_record_single_flight_solo_caller_timeout_does_not_leak_in_flight_entry() {
let client = client();
let _permits: Vec<_> = futures::future::join_all(
(0..RECORD_FETCH_CONCURRENCY)
.map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
)
.await
.into_iter()
.map(|permit| permit.expect("semaphore is not closed"))
.collect();
let deadline = Instant::now() + Duration::from_millis(50);
let result = tokio::time::timeout(
Duration::from_secs(5),
client.fetch_record_single_flight("SOLO-ID", deadline),
)
.await
.expect("a solo caller must fail at its own deadline, not hang");
assert!(
result.is_none(),
"a solo caller's own timeout must fail closed"
);
assert!(
!client.record_in_flight.contains_key("SOLO-ID"),
"a solo caller's timeout must evict its own record_in_flight entry, not leak it \
for the rest of the client's lifetime"
);
}
#[tokio::test]
async fn fetch_record_single_flight_bounds_each_caller_by_its_own_deadline() {
let (addr, _in_flight, _watermark, _total) =
spawn_mock_vuln_server(Duration::from_millis(10)).await;
let client = Arc::new(OsvClient::with_base_url(
Arc::new(HttpCache::new()),
format!("http://{addr}"),
));
let permits: Vec<_> = futures::future::join_all(
(0..RECORD_FETCH_CONCURRENCY)
.map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
)
.await
.into_iter()
.map(|permit| permit.expect("semaphore is not closed"))
.collect();
let short_deadline = Instant::now() + Duration::from_millis(50);
let long_deadline = Instant::now() + Duration::from_secs(10);
let a = tokio::spawn({
let client = Arc::clone(&client);
async move {
client
.fetch_record_single_flight("SHARED-ID", short_deadline)
.await
}
});
tokio::time::sleep(Duration::from_millis(5)).await;
let b = tokio::spawn({
let client = Arc::clone(&client);
async move {
client
.fetch_record_single_flight("SHARED-ID", long_deadline)
.await
}
});
tokio::time::sleep(Duration::from_millis(100)).await;
drop(permits);
let a_result = tokio::time::timeout(Duration::from_secs(5), a)
.await
.expect("A must fail at its own deadline, not hang")
.expect("task did not panic");
let b_result = tokio::time::timeout(Duration::from_secs(5), b)
.await
.expect("B must not be bounded by A's already-expired deadline")
.expect("task did not panic");
assert!(
a_result.is_none(),
"A's own short deadline must fail its wait closed"
);
assert!(
b_result.is_some(),
"B must not be poisoned by A's timeout — it has to run its own initialization \
attempt with its own, still-live deadline"
);
}
#[tokio::test]
async fn fetch_record_single_flight_third_caller_reuses_cell_after_first_callers_timeout() {
let (addr, _in_flight, _watermark, total) =
spawn_mock_vuln_server(Duration::from_millis(10)).await;
let client = Arc::new(OsvClient::with_base_url(
Arc::new(HttpCache::new()),
format!("http://{addr}"),
));
let permits: Vec<_> = futures::future::join_all(
(0..RECORD_FETCH_CONCURRENCY)
.map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
)
.await
.into_iter()
.map(|permit| permit.expect("semaphore is not closed"))
.collect();
let short_deadline = Instant::now() + Duration::from_millis(50);
let long_deadline = Instant::now() + Duration::from_secs(10);
let a = tokio::spawn({
let client = Arc::clone(&client);
async move {
client
.fetch_record_single_flight("SHARED-ID", short_deadline)
.await
}
});
tokio::time::sleep(Duration::from_millis(5)).await;
let b = tokio::spawn({
let client = Arc::clone(&client);
async move {
client
.fetch_record_single_flight("SHARED-ID", long_deadline)
.await
}
});
tokio::time::sleep(Duration::from_millis(60)).await;
let c = tokio::spawn({
let client = Arc::clone(&client);
async move {
client
.fetch_record_single_flight("SHARED-ID", long_deadline)
.await
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
drop(permits);
let a_result = tokio::time::timeout(Duration::from_secs(5), a)
.await
.expect("A must fail at its own deadline, not hang")
.expect("task did not panic");
let b_result = tokio::time::timeout(Duration::from_secs(5), b)
.await
.expect("B must not hang")
.expect("task did not panic");
let c_result = tokio::time::timeout(Duration::from_secs(5), c)
.await
.expect("C must not hang")
.expect("task did not panic");
assert!(a_result.is_none(), "A's own short deadline must fail");
assert!(
b_result.is_some(),
"B must complete the fetch it is driving"
);
assert!(
c_result.is_some(),
"C must observe B's result by joining the same cell, not fail or hang"
);
assert_eq!(
total.load(Ordering::SeqCst),
1,
"C must reuse B's in-flight cell rather than issuing a second, redundant fetch"
);
}
}