Skip to main content

deps_core/osv/
mod.rs

1//! OSV.dev vulnerability scanning.
2//!
3//! [`OsvClient`] batches dependency versions against the [OSV.dev](https://osv.dev)
4//! API (`POST /v1/querybatch`) and resolves matching advisories
5//! (`GET /v1/vulns/{id}`), with a semantic cache of its own — not
6//! [`crate::cache::HttpCache`]'s entry map, since OSV sends no ETag/Last-Modified
7//! validators and the batch endpoint is a POST with a request-body-dependent
8//! response. See `architecture.md` §5 for why this is a deliberate deviation
9//! from reusing `HttpCache` wholesale, and §8 for the four correctness
10//! invariants this module exists to uphold (positional batch results,
11//! pagination truncation, scan observability, and bounded record fan-out).
12//!
13//! [`OsvClient::scan`] never fails: every dependency passed in gets exactly
14//! one [`ScanOutcome`] back, so an OSV outage degrades to an empty-ish map
15//! rather than propagating an error into the LSP response (FR-007).
16
17mod local_match;
18mod severity;
19mod types;
20
21use std::collections::{HashMap, HashSet};
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24
25use dashmap::DashMap;
26
27use local_match::{LocalVersion, Verdict, match_affected};
28pub use severity::to_diagnostic_severity as diagnostic_severity_for;
29use types::worst_severity;
30pub use types::{
31    Advisory, CandidateStatusMap, CandidateStatuses, Capped, DependencyVulnerabilities,
32    EmptyOsvPackageName, FixRecommendation, LatestStatusMap, MatchedTags, OsvEcosystem,
33    OsvPackageName, OsvQueryName, OsvVersion, ScanOutcome, ScanTarget, ScanVersion, SiblingMatches,
34    SkipReason, StructuralSkipReason, UpgradeStatus, VersionMatching, VulnKey, VulnKeys,
35    VulnSeverity, VulnerabilityMap, is_valid_osv_id, validated_osv_url, vuln_key_for,
36    vulnerability_keys,
37};
38use types::{OsvBatchRequest, OsvBatchResponse, OsvQuery, OsvSingleQueryResponse, OsvVulnRecord};
39
40use crate::ConcreteVersion;
41use crate::cache::HttpCache;
42
43/// Advisories rendered (§7) per dependency in hover/diagnostics/`deps-cli` output.
44///
45/// Paired with a trailing "+N more advisories" entry when [`Capped::total`] exceeds this.
46/// Deliberately smaller than [`MAX_ADVISORY_RECORDS`] (#1422): the *fetch* bound and the
47/// *render* bound used to be the same constant, which silently capped
48/// [`DependencyVulnerabilities::recommended_fix`]'s input to whatever fit in the hover panel —
49/// see [`DependencyVulnerabilities::advisories_for_display`] for the render-time truncation
50/// this cap now governs on its own.
51pub const ADVISORY_DISPLAY_CAP: usize = 5;
52
53/// Bound on full advisory records fetched (invariant 3) per dependency.
54///
55/// The input budget for [`DependencyVulnerabilities::recommended_fix`]/`fix_target_is_verified`,
56/// independent of [`ADVISORY_DISPLAY_CAP`] (#1422). Larger than the display cap because a
57/// dependency's highest `fixed` version is not guaranteed to appear among the first
58/// [`ADVISORY_DISPLAY_CAP`] advisories OSV returns — a fix recommendation computed from only
59/// those would claim to resolve everything while leaving a later-indexed advisory's
60/// vulnerability open. Still a cap, not "fetch everything": a dependency with more than this
61/// many advisories is the accepted, documented under-report case
62/// ([`DependencyVulnerabilities::recommended_fix`]'s "Limitations" section), traded off against
63/// bounding this client's per-scan fan-out cost.
64pub const MAX_ADVISORY_RECORDS: usize = 50;
65
66/// Query cache TTL (approved Q6).
67const QUERY_CACHE_TTL: Duration = Duration::from_hours(6);
68
69/// `/v1/querybatch` chunk size (FR-009).
70const BATCH_CHUNK_SIZE: usize = 1000;
71
72/// Bound on individually-requeried truncated entries per [`OsvClient::scan`]/
73/// [`OsvClient::check_candidates`] call (§8 invariant 2).
74const MAX_TRUNCATED_REQUERY_BUDGET: usize = 20;
75
76/// Bounded concurrency for the `/v1/vulns/{id}` and `/v1/query` (truncation recovery) fan-out
77/// (§8 invariant 3), mirroring the registry fetch fan-out's `buffer_unordered` usage.
78///
79/// Enforced by [`OsvClient::record_fetch_semaphore`], shared across every call on the client —
80/// not by `buffer_unordered` alone, which only bounds concurrency *within* a single call. Issue
81/// #1535: `deps-lsp`'s round-based candidate check fires up to `MAX_CANDIDATE_CHECK_VERSIONS`
82/// concurrent [`OsvClient::check_candidates`] calls, each of which used to get its own fresh
83/// `buffer_unordered(RECORD_FETCH_CONCURRENCY)` window — multiplying the real fan-out to OSV.dev
84/// well past this constant's intended bound.
85const RECORD_FETCH_CONCURRENCY: usize = 10;
86
87/// Entry-count bound shared by `query_cache`, `record_cache` and `raw_record_cache`.
88const MAX_CACHE_ENTRIES: usize = 10_000;
89
90const OSV_API_BASE: &str = "https://api.osv.dev";
91
92/// Compares two version-like strings by their leading numeric-and-dot release prefix (the
93/// longest leading run of `[0-9.]` after stripping a single leading `v`/`V` tag-prefix marker,
94/// trailing dot trimmed — `"2.0.0-rc.1"` and `"2.2.0.dev0"` both yield the release prefix
95/// `"2.0.0"`/`"2.2.0"`, and `"v1.20.3"` yields `"1.20.3"`), preferring a bare release over any
96/// string carrying a non-numeric suffix past that prefix once the two release prefixes tie — a
97/// pre-release marker in every scheme this workspace scans (SemVer's `-rc.1`, PEP 440's
98/// `rc1`/`.dev0`, Go's pseudo-version `-0.20210101000000-abcdef123456`, Maven's `-SNAPSHOT`) —
99/// then falling back to a lexicographic compare of any remaining tie.
100///
101/// Used only to order [`Advisory::fixed_versions`] ascending and to pick
102/// [`DependencyVulnerabilities::recommended_fix`]'s target across advisories — not a general
103/// semver/PEP 440/Maven comparator, and deliberately **not** applied to `GIT`-range `fixed`
104/// events (40-hex commit SHAs), which [`OsvVulnRecord::into_advisory`] excludes from
105/// `fixed_versions` entirely before this ever runs (issue #1482) — this comparator has no
106/// meaningful way to rank a SHA against a real version, and a caller with an ecosystem-native
107/// parser at hand (`semver`, `pep440_rs`, ...) should prefer that over this heuristic when one
108/// is available.
109///
110/// The plain-release tie-break is a deliberate, documented over-simplification for a suffix
111/// this comparator cannot classify further: it also ranks a *post*-release (PEP 440's
112/// `1.0.0.post1`, Maven's `1.2.3-1` build number) below its own base release, which is
113/// backwards for those two schemes specifically — a real regression only if an OSV record's
114/// `fixed_versions` for one advisory ever mixes a bare release with that same release's
115/// post-release/build-number spelling, which is rarer than the pre-release case this fixes
116/// (PyPI/OSV normalize most post-releases to the `.postN` form regardless, and no live-verified
117/// OSV record in this project's test fixtures has hit it).
118fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
119    /// The longest leading run of `[0-9.]` in `s`, trailing dot trimmed — the "release" part of
120    /// a version string, stopping before any pre-release/build/metadata suffix regardless of
121    /// whether that suffix starts with a dot (`.dev0`), a hyphen (`-rc.1`), or no separator at
122    /// all (`rc0`). Callers must strip a leading `v`/`V` tag-prefix marker before calling this —
123    /// see [`compare_version_strings`]'s own `normalize_tag` call — otherwise the marker itself
124    /// is the first non-digit-non-dot character and the whole release prefix collapses to
125    /// empty.
126    fn release_prefix(s: &str) -> &str {
127        let end = s
128            .char_indices()
129            .find(|(_, c)| !(c.is_ascii_digit() || *c == '.'))
130            .map_or(s.len(), |(i, _)| i);
131        // `end` always lands on a char boundary (`char_indices` guarantees it), but
132        // `clippy::string_slice` can't prove that statically — `get` sidesteps the lint
133        // without changing behavior, since the slice can never actually fail.
134        s.get(..end).unwrap_or(s).trim_end_matches('.')
135    }
136
137    fn segments(s: &str) -> Vec<u64> {
138        release_prefix(s)
139            .split('.')
140            .map(|part| part.parse().unwrap_or(0))
141            .collect()
142    }
143
144    /// Whether `s` is entirely its own release prefix (no pre-release/build/metadata suffix).
145    fn is_plain_release(s: &str) -> bool {
146        release_prefix(s).len() == s.len()
147    }
148
149    // Code-review regression (#1482): a leading `v`/`V` tag-prefix marker (a git release tag an
150    // OSV record can echo verbatim) is itself the first non-digit-non-dot character, so without
151    // stripping it first `release_prefix` collapsed to empty for both operands — "v1.20.3" and
152    // "v1.3.10" then tied on `segments`/`is_plain_release` and fell all the way to the
153    // lexicographic fallback, which compares character-by-character and ranks "v1.20.3" *below*
154    // "v1.3.10" (wrong: 20 > 3). `normalize_tag` is the same single-`v`/`V`-prefix strip
155    // [`crate::lsp_helpers::OsvNaming::osv_version`]/`osv_version_to_native` already apply to
156    // this exact class of value, so the rest of the release-prefix logic runs on real numeric
157    // content instead of stopping at the marker.
158    let a = crate::github::normalize_tag(a);
159    let b = crate::github::normalize_tag(b);
160
161    let (sa, sb) = (segments(a), segments(b));
162    sa.cmp(&sb)
163        .then_with(|| is_plain_release(a).cmp(&is_plain_release(b)))
164        .then_with(|| a.cmp(b))
165}
166
167/// Key of [`OsvClient::query_cache`]: only [`VersionMatching::ServerSide`] queries carry a
168/// version, so an unversioned result set is shared by every version of the same package.
169#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
170enum QueryCacheKey {
171    Versioned(OsvEcosystem, OsvPackageName, OsvVersion),
172    Unversioned(OsvEcosystem, OsvPackageName),
173}
174
175impl QueryCacheKey {
176    fn for_target(osv_eco: OsvEcosystem, target: &ScanTarget) -> Self {
177        match osv_eco.version_matching() {
178            VersionMatching::ServerSide => Self::Versioned(
179                osv_eco,
180                target.osv_name.name().clone(),
181                target.version.clone(),
182            ),
183            VersionMatching::LocalUnversioned => {
184                Self::Unversioned(osv_eco, target.osv_name.name().clone())
185            }
186        }
187    }
188}
189
190struct QueryCacheEntry {
191    vuln_ids: Vec<(String, String)>,
192    fetched_at: Instant,
193}
194
195struct RecordCacheEntry {
196    advisory: Arc<Advisory>,
197    modified: String,
198    fetched_at: Instant,
199}
200
201struct RawRecordCacheEntry {
202    record: Arc<OsvVulnRecord>,
203    fetched_at: Instant,
204}
205
206/// A single-flight fetch slot for one advisory id, tracking how many concurrent callers are
207/// currently waiting on it (issue #1539 review finding: solo-caller timeout leak).
208///
209/// [`OsvClient::fetch_record_single_flight`] evicts a completed entry from `record_in_flight`
210/// unconditionally, but a caller that times out via its own per-caller deadline must only evict
211/// when it was the *last* caller still waiting on this id — otherwise a caller whose timeout
212/// fires while a different caller is still driving `cell` to completion would rip the shared
213/// slot out from under it (the bug finding #1 fixed). `waiters` makes that "was I last"
214/// determination race-free: incremented once per caller under the `DashMap` entry's own lock
215/// before that caller starts waiting, decremented via `fetch_sub` (whose return value is the
216/// pre-decrement count) when that caller stops waiting for any reason. Exactly one concurrent
217/// decrementer ever observes the count reaching zero, even if multiple solo timeouts race.
218struct InFlightRecord {
219    cell: tokio::sync::OnceCell<Option<Arc<OsvVulnRecord>>>,
220    waiters: std::sync::atomic::AtomicUsize,
221}
222
223impl InFlightRecord {
224    fn new() -> Self {
225        Self {
226            cell: tokio::sync::OnceCell::new(),
227            waiters: std::sync::atomic::AtomicUsize::new(0),
228        }
229    }
230}
231
232/// Batches dependency versions against OSV.dev and resolves matching
233/// advisories, with its own semantic cache layered on top of
234/// [`HttpCache`]'s transport (`post_json`/`get_cached`).
235///
236/// One instance is shared server-lifetime on `ServerState` in `deps-lsp`, so
237/// every open document's scan benefits from the same query/record cache.
238pub struct OsvClient {
239    cache: Arc<HttpCache>,
240    query_cache: DashMap<QueryCacheKey, QueryCacheEntry>,
241    record_cache: DashMap<String, RecordCacheEntry>,
242    /// Raw records keyed by advisory id, validated by `modified` — lets a
243    /// [`VersionMatching::LocalUnversioned`] scan of another version of the same package match
244    /// locally without refetching.
245    raw_record_cache: DashMap<String, RawRecordCacheEntry>,
246    /// Client-wide bound (permits = [`RECORD_FETCH_CONCURRENCY`]) on concurrent
247    /// `/v1/vulns/{id}`/`/v1/query` requests — see [`RECORD_FETCH_CONCURRENCY`]'s doc for why
248    /// this must be a field shared via `Arc` rather than a per-call `buffer_unordered` bound
249    /// (issue #1535).
250    record_fetch_semaphore: Arc<tokio::sync::Semaphore>,
251    /// Coalesces concurrent fetches of the *same advisory id* — across every call sharing this
252    /// client, not just one [`Self::fetch_records`] invocation — into a single in-flight `GET
253    /// /v1/vulns/{id}` (issue #1535). Keyed by advisory id alone, not `(id, osv_name, osv_eco)`:
254    /// only the raw [`OsvVulnRecord`] fetch is shared; each caller still derives its own
255    /// [`Advisory`] via `into_advisory` for the package it actually queried, since one record
256    /// can describe several unrelated packages (see the `scan_filters_affected_entries_to_the_queried_package`
257    /// test). See [`InFlightRecord`] for how eviction avoids both stranding a live waiter and
258    /// leaking an entry no caller is left to clean up (issue #1539).
259    record_in_flight: DashMap<String, Arc<InFlightRecord>>,
260    /// Overridable in test builds only, so `mockito` can stand in for
261    /// `https://api.osv.dev` — mirrors [`crate::cache::ensure_https`]'s existing
262    /// `#[cfg(test)]` relaxation for the same reason, and [`crate::deps_dev::DepsDevClient`]'s
263    /// own `base_url` field.
264    #[cfg(any(test, feature = "test-util"))]
265    base_url: String,
266}
267
268impl OsvClient {
269    /// Creates a client that reuses `cache`'s HTTP transport (`Client`,
270    /// HTTPS enforcement, size cap, timeout) for both the batch POST and the
271    /// per-advisory GET.
272    #[must_use]
273    pub fn new(cache: Arc<HttpCache>) -> Self {
274        Self {
275            cache,
276            query_cache: DashMap::new(),
277            record_cache: DashMap::new(),
278            raw_record_cache: DashMap::new(),
279            record_fetch_semaphore: Arc::new(tokio::sync::Semaphore::new(RECORD_FETCH_CONCURRENCY)),
280            record_in_flight: DashMap::new(),
281            #[cfg(any(test, feature = "test-util"))]
282            base_url: OSV_API_BASE.to_string(),
283        }
284    }
285
286    /// Creates a client pointed at `base_url` instead of the real OSV.dev API, for
287    /// `mockito`-backed tests — issue #1517 critique S6, so a downstream crate (e.g.
288    /// `deps-lsp`, via its `test-util`-featured dev-dependency on this crate) can exercise
289    /// [`Self::scan`]/[`Self::check_candidates`] against a mock server the same way
290    /// [`crate::deps_dev::DepsDevClient::for_test`] already lets it do for deps.dev calls.
291    #[cfg(any(test, feature = "test-util"))]
292    #[must_use]
293    pub fn for_test(cache: Arc<HttpCache>, base_url: impl Into<String>) -> Self {
294        Self::with_base_url(cache, base_url.into())
295    }
296
297    #[cfg(any(test, feature = "test-util"))]
298    fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
299        Self {
300            cache,
301            query_cache: DashMap::new(),
302            record_cache: DashMap::new(),
303            raw_record_cache: DashMap::new(),
304            record_fetch_semaphore: Arc::new(tokio::sync::Semaphore::new(RECORD_FETCH_CONCURRENCY)),
305            record_in_flight: DashMap::new(),
306            base_url,
307        }
308    }
309
310    #[cfg(any(test, feature = "test-util"))]
311    fn api_base(&self) -> &str {
312        &self.base_url
313    }
314
315    #[cfg(not(any(test, feature = "test-util")))]
316    const fn api_base(&self) -> &str {
317        OSV_API_BASE
318    }
319
320    fn batch_url(&self) -> String {
321        format!("{}/v1/querybatch", self.api_base())
322    }
323
324    fn single_query_url(&self) -> String {
325        format!("{}/v1/query", self.api_base())
326    }
327
328    fn vuln_record_url(&self, id: &str) -> String {
329        format!("{}/v1/vulns/{id}", self.api_base())
330    }
331
332    /// Phase A: scans `deps` and returns the map consumed by the rendering
333    /// helpers.
334    ///
335    /// `timeout` bounds the *entire* scan (all chunks and any truncation
336    /// recovery), not any single request — the underlying `reqwest` client
337    /// already caps each individual request at 30s. The deadline is checked
338    /// between chunks/recovery items, not mid-request, so already-completed
339    /// work is never discarded on timeout: only whatever had not yet started
340    /// degrades to [`SkipReason::QueryFailed`]/[`SkipReason::Truncated`]
341    /// (critique S5).
342    ///
343    /// Never returns an error: every failure degrades to a
344    /// [`ScanOutcome::Skipped`] entry, never an absent one. Logs a
345    /// per-scan summary at `info` (§8 invariant 0).
346    pub async fn scan(
347        &self,
348        ecosystem: crate::EcosystemId,
349        deps: &[ScanTarget],
350        timeout: Duration,
351    ) -> VulnerabilityMap {
352        if deps.is_empty() {
353            return VulnerabilityMap::new();
354        }
355        let outcomes = self.resolve(ecosystem, deps, timeout).await;
356        log_scan_summary(&outcomes);
357        outcomes
358    }
359
360    /// Phase B: checks whether the versions about to be recommended (e.g.
361    /// "latest" from the registry) are themselves affected.
362    ///
363    /// Callers may build `candidates` from any subset of dependencies with a registry-cached
364    /// candidate to check — issue #1517 removed the earlier restriction to only dependencies
365    /// phase A already flagged [`ScanOutcome::Vulnerable`], since a cleanly-pinned dependency's
366    /// "latest" must be checked too (a phase-A-only gate silently let a malicious/vulnerable
367    /// "latest" through for every dependency that was clean at its *pinned* version). `timeout`
368    /// has the same meaning as in [`Self::scan`].
369    ///
370    /// Every `candidates` entry gets exactly one result back (never silently dropped): a
371    /// [`ScanOutcome::Skipped`] outcome, or a target this call never resolved a result for at
372    /// all, becomes [`UpgradeStatus::CandidateUnverified`] rather than an absent map entry — a
373    /// caller must fail closed on that, never treat "absent" as "safe" (issue #1517 AC4).
374    pub async fn check_candidates(
375        &self,
376        ecosystem: crate::EcosystemId,
377        candidates: &[ScanTarget],
378        timeout: Duration,
379    ) -> LatestStatusMap {
380        if candidates.is_empty() {
381            return HashMap::new();
382        }
383
384        let outcomes = self.resolve(ecosystem, candidates, timeout).await;
385
386        candidates
387            .iter()
388            .map(|candidate| {
389                let key = candidate.key.clone();
390                // `display_version`, not `version`: the latter is OSV's wire spelling (e.g.
391                // Go's `v`-prefix stripped), but `UpgradeStatus` must surface the
392                // ecosystem-native one.
393                let version = candidate.display_version.clone();
394                let status = match outcomes.get(&key) {
395                    Some(ScanOutcome::Clean) => UpgradeStatus::CandidateClean { version },
396                    Some(ScanOutcome::Vulnerable(dv)) => {
397                        // Issue #1517 critique S2: `dv.advisories.items()` only holds the
398                        // records that were actually fetched and passed validation — a record
399                        // dropped by `fetch_records` (network failure, parse failure) or
400                        // truncated by `MAX_ADVISORY_RECORDS` is silently absent from `items()`
401                        // but still counted in `total()`. Computing `worst_severity` over an
402                        // incomplete `items()` can under-report (an Informational record fetched
403                        // alongside a higher-severity one that failed to fetch would otherwise
404                        // read as `Some(Informational)` -> `Verified`, fail-open). `None` here
405                        // is the same "could not be determined" signal `worst_severity` already
406                        // returns for an empty slice, and `CandidateVulnerable::worst_severity`'s
407                        // own contract already treats `None` as blocking.
408                        let worst_severity = dv
409                            .advisories
410                            .is_complete()
411                            .then(|| worst_severity(dv.advisories.items()))
412                            .flatten();
413                        UpgradeStatus::CandidateVulnerable {
414                            version,
415                            advisory_ids: Capped::new(
416                                dv.advisories.items().iter().map(|a| a.id.clone()).collect(),
417                                dv.advisories.total(),
418                            ),
419                            worst_severity,
420                        }
421                    }
422                    Some(ScanOutcome::Skipped(reason)) => UpgradeStatus::CandidateUnverified {
423                        version,
424                        reason: *reason,
425                    },
426                    // `resolve` is documented to always produce one outcome per target, but a
427                    // caller must still fail closed here rather than assume it, per this
428                    // method's own contract above (issue #1517 AC4).
429                    None => UpgradeStatus::CandidateUnverified {
430                        version,
431                        reason: SkipReason::QueryFailed,
432                    },
433                };
434                (key, status)
435            })
436            .collect()
437    }
438
439    /// Shared resolution logic for [`Self::scan`] and [`Self::check_candidates`]:
440    /// cache lookup, chunked batch query (invariant 1), truncation recovery
441    /// (invariant 2), and bounded record fetch (invariant 3).
442    ///
443    /// `timeout` is enforced as a wall-clock deadline checked before each
444    /// chunk and before truncation recovery begins — never by wrapping the
445    /// whole future in `tokio::time::timeout`, which would drop
446    /// already-accumulated `outcomes` along with whatever was still running
447    /// (critique S5).
448    ///
449    /// A clean answer for an [`OsvQueryName::Provisional`] target is downgraded to
450    /// [`SkipReason::CanonicalNameUnconfirmed`]: only a hit under an unconfirmed name is
451    /// authoritative.
452    async fn resolve(
453        &self,
454        ecosystem: crate::EcosystemId,
455        targets: &[ScanTarget],
456        timeout: Duration,
457    ) -> VulnerabilityMap {
458        let mut outcomes = self.resolve_by_name(ecosystem, targets, timeout).await;
459        let mut seen: HashSet<&VulnKey> = HashSet::with_capacity(targets.len());
460        for t in targets {
461            if !seen.insert(&t.key) {
462                continue;
463            }
464            match &t.osv_name {
465                OsvQueryName::Confirmed(_) => {}
466                OsvQueryName::Provisional(_) => {
467                    if let Some(outcome @ ScanOutcome::Clean) = outcomes.get_mut(&t.key) {
468                        *outcome = ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed);
469                    }
470                }
471            }
472        }
473        outcomes
474    }
475
476    async fn resolve_by_name(
477        &self,
478        ecosystem: crate::EcosystemId,
479        targets: &[ScanTarget],
480        timeout: Duration,
481    ) -> VulnerabilityMap {
482        let deadline = Instant::now() + timeout;
483        let mut outcomes = HashMap::with_capacity(targets.len());
484
485        let Some(osv_eco) = ecosystem.osv_ecosystem() else {
486            for t in targets {
487                outcomes.insert(
488                    t.key.clone(),
489                    ScanOutcome::Skipped(SkipReason::UnmappableEcosystem),
490                );
491            }
492            return outcomes;
493        };
494
495        let mut to_query: Vec<ScanTarget> = Vec::new();
496        let mut cached_stubs: Vec<(ScanTarget, Vec<(String, String)>)> = Vec::new();
497        let mut seen: HashSet<&VulnKey> = HashSet::with_capacity(targets.len());
498        for t in targets {
499            // Duplicate keys share one result; querying them twice would only waste requests.
500            if !seen.insert(&t.key) {
501                continue;
502            }
503            if osv_eco.version_matching() == VersionMatching::ServerSide && !t.siblings().is_empty()
504            {
505                tracing::warn!(
506                    dep = %t.key,
507                    "sibling release tags cannot be matched server-side, skipping"
508                );
509                outcomes.insert(
510                    t.key.clone(),
511                    ScanOutcome::Skipped(SkipReason::UnmatchableVersion),
512                );
513                continue;
514            }
515            if osv_eco.version_matching() == VersionMatching::LocalUnversioned
516                && LocalVersion::parse(&t.version).is_none()
517            {
518                outcomes.insert(
519                    t.key.clone(),
520                    ScanOutcome::Skipped(SkipReason::UnmatchableVersion),
521                );
522                continue;
523            }
524            let cached_ids = self
525                .query_cache
526                .get(&QueryCacheKey::for_target(osv_eco, t))
527                .and_then(|entry| {
528                    (entry.fetched_at.elapsed() < QUERY_CACHE_TTL).then(|| entry.vuln_ids.clone())
529                });
530            match (cached_ids, osv_eco.version_matching()) {
531                (Some(vuln_ids), VersionMatching::ServerSide) => {
532                    outcomes.insert(
533                        t.key.clone(),
534                        self.build_outcome(osv_eco, t.osv_name.name(), &vuln_ids, deadline)
535                            .await,
536                    );
537                }
538                (Some(vuln_ids), VersionMatching::LocalUnversioned) => {
539                    cached_stubs.push((t.clone(), vuln_ids));
540                }
541                (None, _) => to_query.push(t.clone()),
542            }
543        }
544
545        self.resolve_local_stubs(osv_eco, cached_stubs, &mut outcomes, deadline)
546            .await;
547
548        if to_query.is_empty() {
549            return outcomes;
550        }
551
552        let mut truncated: Vec<ScanTarget> = Vec::new();
553        let mut chunks = to_query.chunks(BATCH_CHUNK_SIZE);
554
555        while let Some(chunk) = chunks.next() {
556            if Instant::now() >= deadline {
557                tracing::warn!(
558                    remaining = chunk.len(),
559                    "OSV scan deadline exceeded, marking remaining chunks as query-failed"
560                );
561                mark_chunk_failed(chunk, &mut outcomes);
562                for remaining in chunks {
563                    mark_chunk_failed(remaining, &mut outcomes);
564                }
565                return outcomes;
566            }
567            self.resolve_chunk(osv_eco, chunk, &mut outcomes, &mut truncated, deadline)
568                .await;
569        }
570
571        if Instant::now() >= deadline {
572            tracing::warn!(
573                count = truncated.len(),
574                "OSV scan deadline exceeded before truncation recovery"
575            );
576            for target in &truncated {
577                outcomes.insert(
578                    target.key.clone(),
579                    ScanOutcome::Skipped(SkipReason::Truncated),
580                );
581            }
582            return outcomes;
583        }
584
585        self.recover_truncated(osv_eco, &truncated, &mut outcomes, deadline)
586            .await;
587
588        outcomes
589    }
590
591    /// Queries one batch chunk and populates `outcomes`/`truncated`.
592    ///
593    /// Owns `chunk` end-to-end and zips results only against it (never the
594    /// full document dependency list) — §8 invariant 1. On any failure
595    /// (network error, non-2xx, malformed JSON, or a result-count mismatch)
596    /// the *entire* chunk degrades to [`SkipReason::QueryFailed`] rather than
597    /// risking misattributing an advisory to the wrong dependency.
598    #[tracing::instrument(
599        skip(self, osv_eco, chunk, outcomes, truncated),
600        fields(url = tracing::field::Empty, osv_eco = osv_eco.as_str())
601    )]
602    async fn resolve_chunk(
603        &self,
604        osv_eco: OsvEcosystem,
605        chunk: &[ScanTarget],
606        outcomes: &mut VulnerabilityMap,
607        truncated: &mut Vec<ScanTarget>,
608        deadline: Instant,
609    ) {
610        let url = self.batch_url();
611        tracing::Span::current().record(
612            "url",
613            tracing::field::display(crate::redact::RedactedUrl::new(&url)),
614        );
615
616        let queries: Vec<OsvQuery> = chunk.iter().map(|t| OsvQuery::new(t, osv_eco)).collect();
617
618        let body = OsvBatchRequest { queries };
619        let response_bytes = match self.cache.post_json(&url, &body).await {
620            Ok(b) => b,
621            Err(e) => {
622                // #756: never interpolate `e`'s `Display` — it can embed the raw request
623                // URL (`DepsError::HttpStatus`/`RegistryError`; see
624                // `DepsError::safe_tracing_summary`'s docs).
625                let (status, cause) = e.safe_tracing_summary();
626                tracing::warn!(status = ?status, cause, "OSV batch query failed");
627                mark_chunk_failed(chunk, outcomes);
628                return;
629            }
630        };
631
632        let parsed: OsvBatchResponse = match crate::parser::parse_json_checked(&response_bytes) {
633            Ok(p) => p,
634            Err(e) => {
635                tracing::warn!(error = %e, "failed to parse OSV batch response");
636                mark_chunk_failed(chunk, outcomes);
637                return;
638            }
639        };
640
641        if parsed.results.len() != chunk.len() {
642            tracing::warn!(
643                expected = chunk.len(),
644                got = parsed.results.len(),
645                "OSV batch result count mismatch, dropping chunk"
646            );
647            mark_chunk_failed(chunk, outcomes);
648            return;
649        }
650
651        let mut local_stubs: Vec<(ScanTarget, Vec<(String, String)>)> = Vec::new();
652        for (target, result) in chunk.iter().zip(parsed.results) {
653            if result.next_page_token.is_some() {
654                truncated.push(target.clone());
655                continue;
656            }
657            let vuln_ids: Vec<(String, String)> = result
658                .vulns
659                .into_iter()
660                .map(|v| (v.id, v.modified))
661                .collect();
662            self.store_query_cache(osv_eco, target, &vuln_ids);
663            match osv_eco.version_matching() {
664                VersionMatching::ServerSide => {
665                    let outcome = self
666                        .build_outcome(osv_eco, target.osv_name.name(), &vuln_ids, deadline)
667                        .await;
668                    outcomes.insert(target.key.clone(), outcome);
669                }
670                VersionMatching::LocalUnversioned => local_stubs.push((target.clone(), vuln_ids)),
671            }
672        }
673        self.resolve_local_stubs(osv_eco, local_stubs, outcomes, deadline)
674            .await;
675    }
676
677    /// Resolves every `(target, stubs)` pair of a [`VersionMatching::LocalUnversioned`]
678    /// ecosystem concurrently, bounded by [`RECORD_FETCH_CONCURRENCY`].
679    async fn resolve_local_stubs(
680        &self,
681        osv_eco: OsvEcosystem,
682        pending: Vec<(ScanTarget, Vec<(String, String)>)>,
683        outcomes: &mut VulnerabilityMap,
684        deadline: Instant,
685    ) {
686        use futures::stream::{self, StreamExt};
687
688        let resolved: Vec<(VulnKey, ScanOutcome)> = stream::iter(pending)
689            .map(|(target, stubs)| async move {
690                let outcome = self
691                    .outcome_from_local_stubs(osv_eco, &target, &stubs, deadline)
692                    .await;
693                (target.key, outcome)
694            })
695            .buffer_unordered(RECORD_FETCH_CONCURRENCY)
696            .collect()
697            .await;
698        outcomes.extend(resolved);
699    }
700
701    /// Resolves an unversioned result set for a [`VersionMatching::LocalUnversioned`] target:
702    /// fetches every record and matches its ranges against the in-use version locally.
703    ///
704    /// Fails closed: more stubs than [`MAX_ADVISORY_RECORDS`] is [`SkipReason::Truncated`] (a
705    /// cap applied before matching could drop the one record that applies), and any record that
706    /// fails to fetch is [`SkipReason::QueryFailed`].
707    async fn outcome_from_local_stubs(
708        &self,
709        osv_eco: OsvEcosystem,
710        target: &ScanTarget,
711        stubs: &[(String, String)],
712        deadline: Instant,
713    ) -> ScanOutcome {
714        use futures::stream::{self, StreamExt};
715
716        if stubs.is_empty() {
717            return ScanOutcome::Clean;
718        }
719        if stubs.len() > MAX_ADVISORY_RECORDS {
720            return ScanOutcome::Skipped(SkipReason::Truncated);
721        }
722
723        let fetched: Vec<Option<Arc<OsvVulnRecord>>> =
724            stream::iter(stubs.iter().cloned())
725                .map(|(id, modified)| async move {
726                    self.fetch_raw_record(&id, &modified, deadline).await
727                })
728                .buffer_unordered(RECORD_FETCH_CONCURRENCY)
729                .collect()
730                .await;
731        let Some(records) = fetched
732            .into_iter()
733            .map(|r| r.map(|r| (*r).clone()))
734            .collect::<Option<Vec<_>>>()
735        else {
736            return ScanOutcome::Skipped(SkipReason::QueryFailed);
737        };
738        self.outcome_from_local_records(osv_eco, target, records)
739    }
740
741    /// Filters full `records` to those whose ranges apply to `target`'s version, then resolves
742    /// them like [`Self::outcome_from_full_records`]. A record whose ranges cannot be evaluated
743    /// never counts as clean: with no confirmed match the outcome is
744    /// [`SkipReason::UnevaluableAdvisoryRange`].
745    fn outcome_from_local_records(
746        &self,
747        osv_eco: OsvEcosystem,
748        target: &ScanTarget,
749        records: Vec<OsvVulnRecord>,
750    ) -> ScanOutcome {
751        let Some(version) = LocalVersion::parse(&target.version) else {
752            return ScanOutcome::Skipped(SkipReason::UnmatchableVersion);
753        };
754        let siblings: Vec<(Option<LocalVersion>, &ConcreteVersion)> = target
755            .siblings()
756            .iter()
757            .map(|s| (LocalVersion::parse(s.version()), s.display_version()))
758            .collect();
759        let mut affected = Vec::new();
760        let mut sibling_only: HashMap<String, MatchedTags> = HashMap::new();
761        let mut undeterminable = 0usize;
762        for record in records {
763            let ranges = record.affected_for(target.osv_name.name(), osv_eco);
764            let primary_verdict = match_affected(&ranges, &version);
765            let mut matched_siblings = Vec::new();
766            let mut sibling_undeterminable = false;
767            for (sibling, display) in &siblings {
768                match sibling
769                    .as_ref()
770                    .map_or(Verdict::Undeterminable, |v| match_affected(&ranges, v))
771                {
772                    Verdict::Affected => matched_siblings.push((*display).clone()),
773                    Verdict::NotAffected => {}
774                    Verdict::Undeterminable => sibling_undeterminable = true,
775                }
776            }
777            let is_affected = primary_verdict == Verdict::Affected || !matched_siblings.is_empty();
778            if is_affected {
779                if primary_verdict != Verdict::Affected
780                    && let Some(tags) = MatchedTags::from_tags(matched_siblings)
781                {
782                    sibling_only.insert(record.id.clone(), tags);
783                }
784                affected.push(record);
785            } else if primary_verdict == Verdict::Undeterminable || sibling_undeterminable {
786                tracing::warn!(id = %record.id, "OSV record ranges could not be matched locally");
787                undeterminable += 1;
788            }
789        }
790        // A confirmed match outranks unevaluable siblings: the dependency is already flagged.
791        if affected.is_empty() && undeterminable > 0 {
792            return ScanOutcome::Skipped(SkipReason::UnevaluableAdvisoryRange);
793        }
794        match self.outcome_from_full_records(osv_eco, target, affected) {
795            ScanOutcome::Vulnerable(dv) => {
796                let mut matches = SiblingMatches::new(target.version.clone());
797                for advisory in dv.advisories.items() {
798                    if let Some(tags) = sibling_only.remove(&advisory.id) {
799                        matches.insert(advisory.id.clone(), tags);
800                    }
801                }
802                ScanOutcome::Vulnerable(if matches.is_empty() {
803                    dv
804                } else {
805                    dv.with_sibling_matches(matches)
806                })
807            }
808            ScanOutcome::Clean => ScanOutcome::Clean,
809            ScanOutcome::Skipped(reason) => ScanOutcome::Skipped(reason),
810        }
811    }
812
813    /// Recovers batch-truncated entries via individual `POST /v1/query`
814    /// calls (§8 invariant 2), bounded by [`MAX_TRUNCATED_REQUERY_BUDGET`]
815    /// and run concurrently (mirroring the registry fan-out, critique M3).
816    /// Entries beyond the budget become [`SkipReason::Truncated`] rather than
817    /// ever rendering as zero advisories.
818    async fn recover_truncated(
819        &self,
820        osv_eco: OsvEcosystem,
821        truncated: &[ScanTarget],
822        outcomes: &mut VulnerabilityMap,
823        deadline: Instant,
824    ) {
825        use futures::stream::{self, StreamExt};
826
827        let budget = MAX_TRUNCATED_REQUERY_BUDGET.min(truncated.len());
828        let (to_recover, exhausted) = truncated.split_at(budget);
829
830        // Cloned, not borrowed: a closure borrowing both `self` and a `truncated` slice
831        // element triggers a higher-ranked-lifetime inference failure once nested inside an
832        // outer `tokio::spawn` — `fetch_records` hit the same issue and fixed it with `.cloned()`.
833        let recovered: Vec<(VulnKey, ScanOutcome)> = stream::iter(to_recover.iter().cloned())
834            .map(|target| async move {
835                // Same client-wide OSV request budget as `fetch_record_single_flight` (issue
836                // #1535) — a `/v1/query` requery is as expensive as a `/v1/vulns/{id}` fetch,
837                // so it draws from the same semaphore rather than its own separate window. The
838                // wait itself is bounded by `deadline` (issue #1539): a scan must not overrun its
839                // timeout window sitting in the semaphore queue, so an expired wait fails this
840                // item closed exactly like an `Err` from `acquire_owned` already did.
841                let _permit = match tokio::time::timeout_at(
842                    tokio::time::Instant::from_std(deadline),
843                    Arc::clone(&self.record_fetch_semaphore).acquire_owned(),
844                )
845                .await
846                {
847                    Ok(Ok(permit)) => permit,
848                    Ok(Err(_)) => {
849                        return (target.key.clone(), ScanOutcome::Skipped(SkipReason::QueryFailed));
850                    }
851                    Err(_) => {
852                        tracing::warn!(
853                            dep = %target.key,
854                            "OSV truncation-requery permit wait exceeded scan deadline"
855                        );
856                        return (target.key.clone(), ScanOutcome::Skipped(SkipReason::QueryFailed));
857                    }
858                };
859                let outcome = match self.query_single(osv_eco, &target).await {
860                    // `/v1/query` can itself paginate — never trust its
861                    // `vulns.len()` as complete when it says there is more
862                    // (critique S4).
863                    Some(resp) if resp.next_page_token.is_some() => {
864                        tracing::warn!(
865                            dep = %target.key,
866                            "OSV single-package requery itself paginated; treating as still truncated"
867                        );
868                        ScanOutcome::Skipped(SkipReason::Truncated)
869                    }
870                    Some(resp) => match osv_eco.version_matching() {
871                        VersionMatching::ServerSide => {
872                            self.outcome_from_full_records(osv_eco, &target, resp.vulns)
873                        }
874                        VersionMatching::LocalUnversioned => {
875                            self.outcome_from_local_records(osv_eco, &target, resp.vulns)
876                        }
877                    },
878                    None => ScanOutcome::Skipped(SkipReason::QueryFailed),
879                };
880                (target.key.clone(), outcome)
881            })
882            .buffer_unordered(RECORD_FETCH_CONCURRENCY)
883            .collect()
884            .await;
885
886        for (key, outcome) in recovered {
887            outcomes.insert(key, outcome);
888        }
889
890        for target in exhausted {
891            outcomes.insert(
892                target.key.clone(),
893                ScanOutcome::Skipped(SkipReason::Truncated),
894            );
895        }
896    }
897
898    /// Converts full advisory records recovered via `/v1/query` directly
899    /// into a [`ScanOutcome`], populating the record cache along the way —
900    /// no follow-up `GET /v1/vulns/{id}` is needed for these (§8 invariant 2).
901    /// A record whose id fails [`types::OsvVulnRecord::into_advisory`]'s
902    /// validation is dropped, not counted toward `advisories`/the cache, but
903    /// [`Capped::total`] still reflects OSV's reported count (critique M1).
904    fn outcome_from_full_records(
905        &self,
906        osv_eco: OsvEcosystem,
907        target: &ScanTarget,
908        records: Vec<OsvVulnRecord>,
909    ) -> ScanOutcome {
910        let total = records.len();
911        let mut advisories = Vec::with_capacity(total.min(MAX_ADVISORY_RECORDS));
912        let mut vuln_ids = Vec::with_capacity(total);
913
914        for record in records {
915            let Some(advisory) = record.into_advisory(target.osv_name.name(), osv_eco) else {
916                continue;
917            };
918            let advisory = Arc::new(advisory);
919            vuln_ids.push((advisory.id.clone(), advisory.modified.clone()));
920            self.store_record_cache(&advisory);
921            if advisories.len() < MAX_ADVISORY_RECORDS {
922                advisories.push(advisory);
923            }
924        }
925
926        // An affected-only subset must never stand in for an unversioned package's full stub list.
927        match osv_eco.version_matching() {
928            VersionMatching::ServerSide => self.store_query_cache(osv_eco, target, &vuln_ids),
929            VersionMatching::LocalUnversioned => {}
930        }
931
932        if total == 0 {
933            ScanOutcome::Clean
934        } else {
935            ScanOutcome::Vulnerable(DependencyVulnerabilities {
936                sibling_matches: None,
937                advisories: Capped::new(advisories, total),
938                fix_target_status: UpgradeStatus::NotChecked,
939            })
940        }
941    }
942
943    /// Builds a [`ScanOutcome`] from a list of `(id, modified)` stubs,
944    /// fetching up to [`MAX_ADVISORY_RECORDS`] full records.
945    async fn build_outcome(
946        &self,
947        osv_eco: OsvEcosystem,
948        osv_name: &OsvPackageName,
949        vuln_ids: &[(String, String)],
950        deadline: Instant,
951    ) -> ScanOutcome {
952        if vuln_ids.is_empty() {
953            return ScanOutcome::Clean;
954        }
955
956        #[expect(
957            clippy::indexing_slicing,
958            reason = "the slice upper bound is min(vuln_ids.len(), MAX_ADVISORY_RECORDS), \
959                      always <= vuln_ids.len()"
960        )]
961        let to_fetch = &vuln_ids[..vuln_ids.len().min(MAX_ADVISORY_RECORDS)];
962        let advisories = self
963            .fetch_records(osv_eco, osv_name, to_fetch, deadline)
964            .await;
965
966        ScanOutcome::Vulnerable(DependencyVulnerabilities {
967            sibling_matches: None,
968            advisories: Capped::new(advisories, vuln_ids.len()),
969            fix_target_status: UpgradeStatus::NotChecked,
970        })
971    }
972
973    /// Fetches full advisory records for `ids`, checking the record cache
974    /// first and bounding fetch concurrency (§8 invariant 3). A record that
975    /// fails to fetch, parse, or validate (malformed id, or no matching
976    /// `affected[].package` — critique S3/M1) is dropped, not substituted
977    /// with a half-populated placeholder.
978    async fn fetch_records(
979        &self,
980        osv_eco: OsvEcosystem,
981        osv_name: &OsvPackageName,
982        ids: &[(String, String)],
983        deadline: Instant,
984    ) -> Vec<Arc<Advisory>> {
985        use futures::stream::{self, StreamExt};
986
987        stream::iter(ids.iter().cloned())
988            .map(|(id, modified)| async move {
989                let cached = self.record_cache.get(&id).and_then(|entry| {
990                    (entry.modified == modified).then(|| Arc::clone(&entry.advisory))
991                });
992                if let Some(advisory) = cached {
993                    return Some(advisory);
994                }
995
996                let record = self.fetch_record_single_flight(&id, deadline).await?;
997                let advisory = Arc::new((*record).clone().into_advisory(osv_name, osv_eco)?);
998                self.store_record_cache(&advisory);
999                Some(advisory)
1000            })
1001            .buffer_unordered(RECORD_FETCH_CONCURRENCY)
1002            .collect::<Vec<_>>()
1003            .await
1004            .into_iter()
1005            .flatten()
1006            .collect()
1007    }
1008
1009    /// Returns the raw record for `id`, from `raw_record_cache` when its `modified` matches the
1010    /// stub's, else via [`Self::fetch_record_single_flight`]. Failed fetches are never cached.
1011    async fn fetch_raw_record(
1012        &self,
1013        id: &str,
1014        modified: &str,
1015        deadline: Instant,
1016    ) -> Option<Arc<OsvVulnRecord>> {
1017        let cached = self.raw_record_cache.get(id).and_then(|entry| {
1018            (entry.record.modified == modified).then(|| Arc::clone(&entry.record))
1019        });
1020        if cached.is_some() {
1021            return cached;
1022        }
1023        let record = self.fetch_record_single_flight(id, deadline).await?;
1024        self.store_raw_record_cache(&record);
1025        Some(record)
1026    }
1027
1028    /// Coalesces concurrent fetches of the same advisory id — across every call sharing this
1029    /// client, not just one [`Self::fetch_records`] invocation — into a single in-flight `GET
1030    /// /v1/vulns/{id}`, and gates the underlying HTTP fetch through `record_fetch_semaphore` so
1031    /// [`RECORD_FETCH_CONCURRENCY`] bounds fetch concurrency client-wide (issue #1535) rather
1032    /// than resetting to a fresh window on every call.
1033    ///
1034    /// The wait (permit acquire + HTTP fetch) is bounded by each *caller's own* `deadline`
1035    /// (issue #1539): wrapping the whole [`tokio::sync::OnceCell::get_or_init`] call in an outer
1036    /// `timeout_at`, rather than only the permit acquire inside the initializer closure, matters
1037    /// because `record_in_flight` is shared client-wide — a caller that joins a cell some other
1038    /// caller is already initializing must not be bound by *that* caller's deadline instead of
1039    /// its own (issue #1539 critique S1). Two failure modes that would otherwise follow:
1040    /// overrunning a short-lived caller's own deadline while piggybacking on a longer-lived
1041    /// initializer, or a near-expired initializer poisoning the cell with `None` for a
1042    /// long-lived caller that still had budget left. `OnceCell::get_or_init` is documented
1043    /// cancel-safe: dropping the initializing future on this caller's own timeout releases the
1044    /// cell's internal lock so a still-waiting caller starts its own initialization attempt with
1045    /// its own deadline, rather than inheriting a poisoned result.
1046    async fn fetch_record_single_flight(
1047        &self,
1048        id: &str,
1049        deadline: Instant,
1050    ) -> Option<Arc<OsvVulnRecord>> {
1051        let entry = Arc::clone(
1052            self.record_in_flight
1053                .entry(id.to_string())
1054                .or_insert_with(|| Arc::new(InFlightRecord::new()))
1055                .value(),
1056        );
1057        entry
1058            .waiters
1059            .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1060
1061        match tokio::time::timeout_at(
1062            tokio::time::Instant::from_std(deadline),
1063            entry.cell.get_or_init(|| async {
1064                let permit = Arc::clone(&self.record_fetch_semaphore)
1065                    .acquire_owned()
1066                    .await
1067                    .ok()?;
1068                let record = self.fetch_single_record(id).await.map(Arc::new);
1069                drop(permit);
1070                record
1071            }),
1072        )
1073        .await
1074        {
1075            Ok(value) => {
1076                let result = value.clone();
1077
1078                // Only a dedup window for concurrently-overlapping requests, not a persistent
1079                // cache (`record_cache` already serves that role) — drop the entry once resolved
1080                // so a later, non-overlapping fetch of the same id re-queries rather than growing
1081                // this map forever over a server-lifetime client. Eviction is unconditional here:
1082                // this caller's own `get_or_init` actually completed, so `entry.cell` is
1083                // guaranteed fully initialized regardless of how many other callers are still
1084                // waiting on it (they'll each observe the same completed value via their own
1085                // `get_or_init` call, independent of the map entry).
1086                self.record_in_flight
1087                    .remove_if(id, |_, v| Arc::ptr_eq(v, &entry));
1088
1089                result
1090            }
1091            Err(_) => {
1092                tracing::warn!(id, "OSV record fetch wait exceeded scan deadline");
1093
1094                // Evicting unconditionally on this caller's own timeout would remove the entry
1095                // out from under a *different* caller still legitimately driving the same cell to
1096                // completion (issue #1539 critique finding #1): a third caller joining in that
1097                // window would then create a brand-new cell and issue a redundant `GET
1098                // /v1/vulns/{id}`, defeating single-flight coalescing (#1535). But never evicting
1099                // on timeout leaks the entry forever when this caller was the *only* one waiting
1100                // — nobody else is left to ever drive `entry.cell` to completion and evict it
1101                // (issue #1539 review finding). `waiters.fetch_sub` returns the pre-decrement
1102                // count, so exactly one concurrent caller — whichever one's decrement observes
1103                // `1` — is guaranteed to be the last, even if multiple solo timeouts race.
1104                if entry
1105                    .waiters
1106                    .fetch_sub(1, std::sync::atomic::Ordering::AcqRel)
1107                    == 1
1108                {
1109                    self.record_in_flight
1110                        .remove_if(id, |_, v| Arc::ptr_eq(v, &entry));
1111                }
1112
1113                None
1114            }
1115        }
1116    }
1117
1118    /// Fetches a single advisory record. Uses [`HttpCache::get_transport_only`]
1119    /// rather than [`HttpCache::get_cached`] deliberately: this client's own
1120    /// `record_cache` (validated by `modified`, not `ETag`) is the real
1121    /// cache for these bodies, so also caching them in `HttpCache`'s
1122    /// entry map would double-cache every fetched record there, competing
1123    /// with registry responses for its byte budget for no benefit (critique
1124    /// M2 — nothing ever reads that copy back).
1125    #[tracing::instrument(skip(self), fields(url = tracing::field::Empty))]
1126    async fn fetch_single_record(&self, id: &str) -> Option<OsvVulnRecord> {
1127        let url = self.vuln_record_url(id);
1128        tracing::Span::current().record(
1129            "url",
1130            tracing::field::display(crate::redact::RedactedUrl::new(&url)),
1131        );
1132        match self.cache.get_transport_only(&url).await {
1133            Ok(bytes) => match crate::parser::parse_json_checked::<OsvVulnRecord>(&bytes) {
1134                Ok(record) => Some(record),
1135                Err(e) => {
1136                    tracing::warn!(id, error = %e, "failed to parse OSV vulnerability record");
1137                    None
1138                }
1139            },
1140            // #756: never interpolate `e`'s `Display` — see `DepsError::safe_tracing_summary`.
1141            Err(e) => {
1142                let (status, cause) = e.safe_tracing_summary();
1143                tracing::warn!(
1144                    id,
1145                    status = ?status,
1146                    cause,
1147                    "failed to fetch OSV vulnerability record"
1148                );
1149                None
1150            }
1151        }
1152    }
1153
1154    #[tracing::instrument(
1155        skip(self, osv_eco, target),
1156        fields(url = tracing::field::Empty, osv_eco = osv_eco.as_str())
1157    )]
1158    async fn query_single(
1159        &self,
1160        osv_eco: OsvEcosystem,
1161        target: &ScanTarget,
1162    ) -> Option<OsvSingleQueryResponse> {
1163        let url = self.single_query_url();
1164        tracing::Span::current().record(
1165            "url",
1166            tracing::field::display(crate::redact::RedactedUrl::new(&url)),
1167        );
1168
1169        let body = OsvQuery::new(target, osv_eco);
1170
1171        let bytes = match self.cache.post_json(&url, &body).await {
1172            Ok(b) => b,
1173            // #756: never interpolate `e`'s `Display` — see `DepsError::safe_tracing_summary`.
1174            Err(e) => {
1175                let (status, cause) = e.safe_tracing_summary();
1176                tracing::warn!(
1177                    dep = %target.key,
1178                    status = ?status,
1179                    cause,
1180                    "OSV single-package requery failed"
1181                );
1182                return None;
1183            }
1184        };
1185
1186        match crate::parser::parse_json_checked::<OsvSingleQueryResponse>(&bytes) {
1187            Ok(resp) => Some(resp),
1188            Err(e) => {
1189                tracing::warn!(dep = %target.key, error = %e, "failed to parse OSV single-package response");
1190                None
1191            }
1192        }
1193    }
1194
1195    fn store_raw_record_cache(&self, record: &Arc<OsvVulnRecord>) {
1196        if self.raw_record_cache.len() >= MAX_CACHE_ENTRIES {
1197            crate::cache_policy::evict_oldest_batch(
1198                &self.raw_record_cache,
1199                MAX_CACHE_ENTRIES,
1200                |e| e.fetched_at,
1201            );
1202        }
1203        self.raw_record_cache.insert(
1204            record.id.clone(),
1205            RawRecordCacheEntry {
1206                record: Arc::clone(record),
1207                fetched_at: Instant::now(),
1208            },
1209        );
1210    }
1211
1212    fn store_query_cache(
1213        &self,
1214        osv_eco: OsvEcosystem,
1215        target: &ScanTarget,
1216        vuln_ids: &[(String, String)],
1217    ) {
1218        if self.query_cache.len() >= MAX_CACHE_ENTRIES {
1219            crate::cache_policy::evict_oldest_batch(&self.query_cache, MAX_CACHE_ENTRIES, |e| {
1220                e.fetched_at
1221            });
1222        }
1223        self.query_cache.insert(
1224            QueryCacheKey::for_target(osv_eco, target),
1225            QueryCacheEntry {
1226                vuln_ids: vuln_ids.to_vec(),
1227                fetched_at: Instant::now(),
1228            },
1229        );
1230    }
1231
1232    fn store_record_cache(&self, advisory: &Arc<Advisory>) {
1233        if self.record_cache.len() >= MAX_CACHE_ENTRIES {
1234            crate::cache_policy::evict_oldest_batch(&self.record_cache, MAX_CACHE_ENTRIES, |e| {
1235                e.fetched_at
1236            });
1237        }
1238        self.record_cache.insert(
1239            advisory.id.clone(),
1240            RecordCacheEntry {
1241                advisory: Arc::clone(advisory),
1242                modified: advisory.modified.clone(),
1243                fetched_at: Instant::now(),
1244            },
1245        );
1246    }
1247}
1248
1249/// Marks every dependency in a failed chunk as [`SkipReason::QueryFailed`].
1250fn mark_chunk_failed(chunk: &[ScanTarget], outcomes: &mut VulnerabilityMap) {
1251    for t in chunk {
1252        outcomes.insert(t.key.clone(), ScanOutcome::Skipped(SkipReason::QueryFailed));
1253    }
1254}
1255
1256/// Logs the `info`-level scan summary mandated by §8 invariant 0.
1257fn log_scan_summary(outcomes: &VulnerabilityMap) {
1258    let mut clean = 0usize;
1259    let mut vulnerable = 0usize;
1260    let mut skip_counts: HashMap<&'static str, usize> = HashMap::new();
1261
1262    for outcome in outcomes.values() {
1263        match outcome {
1264            ScanOutcome::Clean => clean += 1,
1265            ScanOutcome::Vulnerable(_) => vulnerable += 1,
1266            ScanOutcome::Skipped(reason) => {
1267                *skip_counts.entry(reason.as_str()).or_insert(0) += 1;
1268            }
1269        }
1270    }
1271
1272    let skipped: usize = skip_counts.values().sum();
1273    let reasons = skip_counts
1274        .iter()
1275        .map(|(reason, count)| format!("{count} {reason}"))
1276        .collect::<Vec<_>>()
1277        .join(", ");
1278
1279    tracing::info!(
1280        "OSV: scanned {}, clean {clean}, vulnerable {vulnerable}, skipped {skipped}{}",
1281        outcomes.len(),
1282        if reasons.is_empty() {
1283            String::new()
1284        } else {
1285            format!(" ({reasons})")
1286        }
1287    );
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    #[test]
1293    fn osv_query_wire_json_carries_plain_package_name() {
1294        let t = ScanTarget::new(
1295            crate::test_util::vuln_key("apple/swift-nio"),
1296            OsvQueryName::Confirmed(OsvPackageName::new("github.com/apple/swift-nio").unwrap()),
1297            OsvVersion::new("2.0.0"),
1298            ConcreteVersion::new("2.0.0"),
1299        );
1300        let json = serde_json::to_value(OsvQuery::new(&t, OsvEcosystem::SwiftURL)).unwrap();
1301        assert_eq!(json["package"]["name"], "github.com/apple/swift-nio");
1302        assert_eq!(json["version"], "2.0.0");
1303    }
1304
1305    use super::*;
1306    use crate::{ConcreteVersion, EcosystemId};
1307    use std::assert_matches;
1308    use std::sync::atomic::{AtomicUsize, Ordering};
1309
1310    fn client() -> OsvClient {
1311        OsvClient::new(Arc::new(HttpCache::new()))
1312    }
1313
1314    async fn mock_client() -> (mockito::ServerGuard, OsvClient) {
1315        let server = mockito::Server::new_async().await;
1316        let client = OsvClient::with_base_url(Arc::new(HttpCache::new()), server.url());
1317        (server, client)
1318    }
1319
1320    const TEST_TIMEOUT: Duration = Duration::from_secs(30);
1321
1322    fn target(name: &str, version: &str) -> ScanTarget {
1323        ScanTarget::new(
1324            crate::test_util::vuln_key(name),
1325            OsvQueryName::Confirmed(OsvPackageName::new(name).unwrap()),
1326            OsvVersion::new(version),
1327            ConcreteVersion::new(version),
1328        )
1329    }
1330
1331    #[test]
1332    fn compare_version_strings_orders_numerically() {
1333        let mut versions = vec![
1334            "0.2.10".to_string(),
1335            "0.2.2".to_string(),
1336            "0.2.23".to_string(),
1337            "0.2.0".to_string(),
1338        ];
1339        versions.sort_by(|a, b| compare_version_strings(a, b));
1340        assert_eq!(versions, vec!["0.2.0", "0.2.2", "0.2.10", "0.2.23"]);
1341    }
1342
1343    /// A PEP 440 release candidate must never outrank its own release when the leading
1344    /// numeric dot-segments tie — a bare lexicographic fallback puts `2.2.0rc0` after
1345    /// `2.2.0` (longer string, same prefix), which would let a pre-release "win" as
1346    /// `recommended_fix`'s target.
1347    #[test]
1348    fn compare_version_strings_prefers_plain_release_over_same_prefix_prerelease() {
1349        assert_eq!(
1350            compare_version_strings("2.2.0", "2.2.0rc0"),
1351            std::cmp::Ordering::Greater
1352        );
1353        assert_eq!(
1354            compare_version_strings("2.2.0rc0", "2.2.0"),
1355            std::cmp::Ordering::Less
1356        );
1357
1358        let mut versions = vec!["2.2.0rc0".to_string(), "2.2.0".to_string()];
1359        versions.sort_by(|a, b| compare_version_strings(a, b));
1360        assert_eq!(versions, vec!["2.2.0rc0", "2.2.0"]);
1361    }
1362
1363    /// impl-critic S1: the undotted-suffix case above (`2.2.0rc0`) is not representative —
1364    /// SemVer's own spec (and Cargo/npm/NuGet, which follow it) always separates a pre-release
1365    /// identifier with a `-`, usually followed by further dot segments. A fix must not only
1366    /// tie-break on the exact leading digit segments matching; it must recognize that
1367    /// `"2.0.0-rc.1"`'s *release* is `2.0.0`, the same as bare `"2.0.0"`.
1368    #[test]
1369    fn compare_version_strings_prefers_plain_release_over_dotted_semver_prerelease() {
1370        assert_eq!(
1371            compare_version_strings("2.0.0", "2.0.0-rc.1"),
1372            std::cmp::Ordering::Greater
1373        );
1374        assert_eq!(
1375            compare_version_strings("1.0.0", "1.0.0-beta.2"),
1376            std::cmp::Ordering::Greater
1377        );
1378
1379        let mut versions = vec!["2.0.0-rc.1".to_string(), "2.0.0".to_string()];
1380        versions.sort_by(|a, b| compare_version_strings(a, b));
1381        assert_eq!(versions, vec!["2.0.0-rc.1", "2.0.0"]);
1382    }
1383
1384    /// impl-critic S1: PEP 440's `.dev0`/`rc1.dev0` and Go's pseudo-version suffixes are also
1385    /// dotted or hyphen-separated past the release segments — the same bug class as the SemVer
1386    /// case above, for different ecosystems.
1387    #[test]
1388    fn compare_version_strings_prefers_plain_release_over_pep440_and_go_pseudo_suffixes() {
1389        assert_eq!(
1390            compare_version_strings("2.2.0", "2.2.0.dev0"),
1391            std::cmp::Ordering::Greater
1392        );
1393        assert_eq!(
1394            compare_version_strings("2.2.0", "2.2.0rc1.dev0"),
1395            std::cmp::Ordering::Greater
1396        );
1397        assert_eq!(
1398            compare_version_strings("1.2.4", "1.2.4-0.20210101000000-abcdef123456"),
1399            std::cmp::Ordering::Greater
1400        );
1401    }
1402
1403    /// Tester gap: an unrelated, lower pre-release must still lose to a higher plain release
1404    /// purely on the leading numeric segments — the plain-release tie-break must only kick in
1405    /// once the release segments already tie, never override a genuine version difference.
1406    #[test]
1407    fn compare_version_strings_numeric_ordering_still_wins_over_prerelease_tiebreak() {
1408        assert_eq!(
1409            compare_version_strings("2.2.0", "1.9.0rc1"),
1410            std::cmp::Ordering::Greater
1411        );
1412    }
1413
1414    /// Code-review regression: without stripping a leading `v`/`V` tag-prefix marker first,
1415    /// the marker itself was the first non-digit-non-dot character in the whole string, so
1416    /// `release_prefix` collapsed to empty for both operands and the comparison fell through to
1417    /// a purely lexicographic (character-by-character) compare — wrongly ranking `v1.20.3`
1418    /// below `v1.3.10` (`'2' < '3'` at the second character) even though `20 > 3`.
1419    #[test]
1420    fn compare_version_strings_strips_leading_tag_prefix_before_ranking() {
1421        assert_eq!(
1422            compare_version_strings("v1.20.3", "v1.3.10"),
1423            std::cmp::Ordering::Greater
1424        );
1425        assert_eq!(
1426            compare_version_strings("V1.20.3", "v1.3.10"),
1427            std::cmp::Ordering::Greater
1428        );
1429
1430        let mut versions = vec!["v1.3.10".to_string(), "v1.20.3".to_string()];
1431        versions.sort_by(|a, b| compare_version_strings(a, b));
1432        assert_eq!(versions, vec!["v1.3.10", "v1.20.3"]);
1433    }
1434
1435    #[tokio::test]
1436    async fn scan_empty_input_returns_empty_map() {
1437        let client = client();
1438        let outcomes = client.scan(EcosystemId::Cargo, &[], TEST_TIMEOUT).await;
1439        assert!(outcomes.is_empty());
1440    }
1441
1442    #[tokio::test]
1443    async fn check_candidates_empty_input_returns_empty_map() {
1444        let client = client();
1445        let statuses = client
1446            .check_candidates(EcosystemId::Cargo, &[], TEST_TIMEOUT)
1447            .await;
1448        assert!(statuses.is_empty());
1449    }
1450
1451    #[tokio::test]
1452    async fn scan_all_clean_batch_result() {
1453        let (mut server, client) = mock_client().await;
1454        let _m = server
1455            .mock("POST", "/v1/querybatch")
1456            .with_status(200)
1457            .with_body(r#"{"results":[{}]}"#)
1458            .create_async()
1459            .await;
1460
1461        let targets = vec![target("left-pad", "1.0.0")];
1462        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1463
1464        assert_eq!(outcomes.len(), 1);
1465        assert_matches!(
1466            outcomes.get(&crate::test_util::vuln_key("left-pad")),
1467            Some(ScanOutcome::Clean)
1468        );
1469    }
1470
1471    #[tokio::test]
1472    async fn scan_batch_http_400_skips_whole_chunk() {
1473        let (mut server, client) = mock_client().await;
1474        let _m = server
1475            .mock("POST", "/v1/querybatch")
1476            .with_status(400)
1477            .with_body(r#"{"code":3,"message":"error in query at index 0"}"#)
1478            .create_async()
1479            .await;
1480
1481        let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
1482        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1483
1484        assert_eq!(outcomes.len(), 2);
1485        for key in ["a", "b"] {
1486            assert_matches!(
1487                outcomes.get(&crate::test_util::vuln_key(key)),
1488                Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
1489            );
1490        }
1491    }
1492
1493    #[tokio::test]
1494    async fn scan_malformed_batch_json_skips_whole_chunk() {
1495        let (mut server, client) = mock_client().await;
1496        let _m = server
1497            .mock("POST", "/v1/querybatch")
1498            .with_status(200)
1499            .with_body("not json")
1500            .create_async()
1501            .await;
1502
1503        let targets = vec![target("a", "1.0.0")];
1504        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1505
1506        assert_matches!(
1507            outcomes.get(&crate::test_util::vuln_key("a")),
1508            Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
1509        );
1510    }
1511
1512    #[tokio::test]
1513    async fn scan_deeply_nested_batch_json_skips_whole_chunk() {
1514        // #430: a deeply nested array must be rejected by the depth guard before
1515        // `serde_json::from_slice` sees it — cheaper than relying on serde_json's own limit.
1516        let (mut server, client) = mock_client().await;
1517        let deeply_nested = format!(
1518            "{}1{}",
1519            "[".repeat(crate::parser::MAX_JSON_NESTING_DEPTH + 1),
1520            "]".repeat(crate::parser::MAX_JSON_NESTING_DEPTH + 1)
1521        );
1522        let _m = server
1523            .mock("POST", "/v1/querybatch")
1524            .with_status(200)
1525            .with_body(format!(
1526                r#"{{"results":[{{"vulns":[{{"id":"ADV-1","modified":"2023-01-01T00:00:00Z","database_specific":{deeply_nested}}}]}}]}}"#
1527            ))
1528            .create_async()
1529            .await;
1530
1531        let targets = vec![target("pkg", "1.0.0")];
1532        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1533
1534        assert_matches!(
1535            outcomes.get(&crate::test_util::vuln_key("pkg")),
1536            Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
1537        );
1538    }
1539
1540    #[tokio::test]
1541    async fn scan_result_count_mismatch_drops_whole_chunk() {
1542        let (mut server, client) = mock_client().await;
1543        // Two queries sent, only one result returned.
1544        let _m = server
1545            .mock("POST", "/v1/querybatch")
1546            .with_status(200)
1547            .with_body(r#"{"results":[{}]}"#)
1548            .create_async()
1549            .await;
1550
1551        let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
1552        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1553
1554        for key in ["a", "b"] {
1555            assert_matches!(
1556                outcomes.get(&crate::test_util::vuln_key(key)),
1557                Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
1558            );
1559        }
1560    }
1561
1562    #[tokio::test]
1563    async fn scan_over_chunk_size_input_issues_exactly_two_batch_requests() {
1564        let (mut server, client) = mock_client().await;
1565
1566        let n = BATCH_CHUNK_SIZE + 1;
1567        let targets: Vec<ScanTarget> = (0..n)
1568            .map(|i| target(&format!("pkg-{i}"), "1.0.0"))
1569            .collect();
1570
1571        let call_count = Arc::new(AtomicUsize::new(0));
1572        let call_count_clone = Arc::clone(&call_count);
1573
1574        let batch = server
1575            .mock("POST", "/v1/querybatch")
1576            .with_status(200)
1577            .with_body_from_request(move |req| {
1578                call_count_clone.fetch_add(1, Ordering::SeqCst);
1579                let body = req.body().expect("request body");
1580                let parsed: serde_json::Value =
1581                    serde_json::from_slice(body).expect("valid JSON request body");
1582                let count = parsed["queries"].as_array().map_or(0, Vec::len);
1583                let results = vec!["{}"; count].join(",");
1584                format!(r#"{{"results":[{results}]}}"#).into_bytes()
1585            })
1586            .expect(2)
1587            .create_async()
1588            .await;
1589
1590        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1591
1592        assert_eq!(outcomes.len(), n);
1593        assert!(outcomes.values().all(|o| matches!(o, ScanOutcome::Clean)));
1594        batch.assert_async().await;
1595        assert_eq!(
1596            call_count.load(Ordering::SeqCst),
1597            2,
1598            "a >1000-entry scan must issue exactly two chunked batch requests"
1599        );
1600    }
1601
1602    #[tokio::test]
1603    async fn scan_deadline_exceeded_before_first_chunk_marks_everything_query_failed() {
1604        let (_server, client) = mock_client().await;
1605        // No mock registered at all: `client` still points at a live mockito
1606        // server with no matching route, so a would-be request 404s — but
1607        // the zero-duration deadline must make `resolve` bail before ever
1608        // sending it, proving the deadline check runs before network I/O.
1609        let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
1610        let outcomes = client
1611            .scan(EcosystemId::Npm, &targets, Duration::from_secs(0))
1612            .await;
1613
1614        for key in ["a", "b"] {
1615            assert_matches!(
1616                outcomes.get(&crate::test_util::vuln_key(key)),
1617                Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
1618            );
1619        }
1620    }
1621
1622    #[tokio::test]
1623    async fn scan_filters_affected_entries_to_the_queried_package() {
1624        // Critique S3: a record can cover several unrelated packages sharing
1625        // one advisory id (e.g. log4j-core/log4j-api). Only the entry whose
1626        // `package` matches the queried package must contribute
1627        // fixed_versions/severity.
1628        let (mut server, client) = mock_client().await;
1629        let _batch = server
1630            .mock("POST", "/v1/querybatch")
1631            .with_status(200)
1632            .with_body(
1633                r#"{"results":[{"vulns":[{"id":"GHSA-cross-pkg","modified":"2023-01-01T00:00:00Z"}]}]}"#,
1634            )
1635            .create_async()
1636            .await;
1637        let _record = server
1638            .mock("GET", "/v1/vulns/GHSA-cross-pkg")
1639            .with_status(200)
1640            .with_body(
1641                r#"{"id":"GHSA-cross-pkg","modified":"2023-01-01T00:00:00Z",
1642                   "affected":[
1643                     {"package":{"name":"log4j-api","ecosystem":"Maven"},
1644                      "ecosystem_specific":{"severity":"LOW"},
1645                      "ranges":[{"type":"ECOSYSTEM","events":[{"fixed":"1.0.0"}]}]},
1646                     {"package":{"name":"log4j-core","ecosystem":"Maven"},
1647                      "ecosystem_specific":{"severity":"CRITICAL"},
1648                      "ranges":[{"type":"ECOSYSTEM","events":[{"fixed":"2.17.1"}]}]}
1649                   ]}"#,
1650            )
1651            .create_async()
1652            .await;
1653
1654        let targets = vec![target("log4j-core", "2.14.1")];
1655        let outcomes = client
1656            .scan(EcosystemId::Maven, &targets, TEST_TIMEOUT)
1657            .await;
1658
1659        let Some(ScanOutcome::Vulnerable(dv)) =
1660            outcomes.get(&crate::test_util::vuln_key("log4j-core"))
1661        else {
1662            panic!("expected Vulnerable outcome");
1663        };
1664        // Must pick up log4j-core's own severity/fix, not log4j-api's.
1665        assert_eq!(dv.advisories.items()[0].severity, VulnSeverity::Critical);
1666        assert_eq!(
1667            dv.advisories.items()[0].fixed_versions,
1668            vec![OsvVersion::new("2.17.1")]
1669        );
1670    }
1671
1672    const GHA_UNVERSIONED_BODY: &str = r#"{"results":[{"vulns":[{"id":"GHSA-cxww-7g56-2vh6","modified":"2025-01-22T17:31:55Z"}]}]}"#;
1673    const GHA_RECORD_BODY: &str = r#"{"id":"GHSA-cxww-7g56-2vh6","modified":"2025-01-22T17:31:55Z",
1674        "affected":[{"package":{"name":"actions/download-artifact","ecosystem":"GitHub Actions"},
1675        "ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.0.0"},{"fixed":"4.1.3"}]}]}]}"#;
1676
1677    async fn scan_gha(version: &str) -> ScanOutcome {
1678        let (mut server, client) = mock_client().await;
1679        let _batch = server
1680            .mock("POST", "/v1/querybatch")
1681            .match_request(|req| {
1682                !req.utf8_lossy_body()
1683                    .is_ok_and(|b| b.contains("\"version\""))
1684            })
1685            .with_status(200)
1686            .with_body(GHA_UNVERSIONED_BODY)
1687            .create_async()
1688            .await;
1689        let _record = server
1690            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
1691            .with_status(200)
1692            .with_body(GHA_RECORD_BODY)
1693            .create_async()
1694            .await;
1695        let targets = vec![target("actions/download-artifact", version)];
1696        let mut outcomes = client
1697            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1698            .await;
1699        outcomes
1700            .remove(&crate::test_util::vuln_key("actions/download-artifact"))
1701            .expect("one outcome per target")
1702    }
1703
1704    #[tokio::test]
1705    async fn gha_unversioned_query_matches_in_range_version_locally() {
1706        let ScanOutcome::Vulnerable(dv) = scan_gha("4.1.2").await else {
1707            panic!("expected Vulnerable");
1708        };
1709        assert_eq!(dv.advisories.items()[0].id, "GHSA-cxww-7g56-2vh6");
1710    }
1711
1712    #[tokio::test]
1713    async fn gha_unversioned_query_is_clean_for_out_of_range_version() {
1714        assert_matches!(scan_gha("4.1.3").await, ScanOutcome::Clean);
1715    }
1716
1717    const CODEQL_STUBS_BODY: &str = r#"{"results":[{"vulns":[{"id":"GHSA-vqf5-2xx6-9wfm","modified":"2025-02-03T00:00:00Z"}]}]}"#;
1718    const CODEQL_RECORD_BODY: &str = r#"{"id":"GHSA-vqf5-2xx6-9wfm","modified":"2025-02-03T00:00:00Z",
1719        "affected":[
1720        {"package":{"name":"github/codeql-action","ecosystem":"GitHub Actions"},
1721         "ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.26.11"},{"fixed":"3.28.3"}]}],
1722         "database_specific":{"last_known_affected_version_range":"<= 3.28.2"}},
1723        {"package":{"name":"github/codeql-action","ecosystem":"GitHub Actions"},
1724         "ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.26.11"}]}],
1725         "database_specific":{"last_known_affected_version_range":"< 3.0.0"}}]}"#;
1726
1727    /// #1707: the open-ended `introduced: 2.26.11` entry is capped by its
1728    /// `last_known_affected_version_range`, so neither the fix nor v4 is flagged.
1729    #[tokio::test]
1730    async fn gha_open_ended_range_honors_last_known_affected_version_range() {
1731        for (version, vulnerable) in [
1732            ("3.28.2", true),
1733            ("2.30.0", true),
1734            ("3.28.3", false),
1735            ("4.38.2", false),
1736        ] {
1737            let (mut server, client) = mock_client().await;
1738            let _batch = server
1739                .mock("POST", "/v1/querybatch")
1740                .with_status(200)
1741                .with_body(CODEQL_STUBS_BODY)
1742                .create_async()
1743                .await;
1744            let _record = server
1745                .mock("GET", "/v1/vulns/GHSA-vqf5-2xx6-9wfm")
1746                .with_status(200)
1747                .with_body(CODEQL_RECORD_BODY)
1748                .create_async()
1749                .await;
1750            let targets = vec![target("github/codeql-action", version)];
1751            let mut outcomes = client
1752                .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1753                .await;
1754            let outcome = outcomes
1755                .remove(&crate::test_util::vuln_key("github/codeql-action"))
1756                .expect("one outcome per target");
1757            if vulnerable {
1758                assert_matches!(outcome, ScanOutcome::Vulnerable(_), "{version}");
1759            } else {
1760                assert_matches!(outcome, ScanOutcome::Clean, "{version}");
1761            }
1762        }
1763    }
1764
1765    fn provisional_target(name: &str, version: &str) -> ScanTarget {
1766        ScanTarget::new(
1767            crate::test_util::vuln_key(name),
1768            OsvQueryName::Provisional(OsvPackageName::new(name).unwrap()),
1769            OsvVersion::new(version),
1770            ConcreteVersion::new(version),
1771        )
1772    }
1773
1774    async fn scan_gha_provisional(version: &str, batch_body: &str) -> ScanOutcome {
1775        let (mut server, client) = mock_client().await;
1776        let _batch = server
1777            .mock("POST", "/v1/querybatch")
1778            .with_status(200)
1779            .with_body(batch_body)
1780            .create_async()
1781            .await;
1782        let _record = server
1783            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
1784            .with_status(200)
1785            .with_body(GHA_RECORD_BODY)
1786            .create_async()
1787            .await;
1788        let targets = vec![provisional_target("actions/download-artifact", version)];
1789        let mut outcomes = client
1790            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1791            .await;
1792        outcomes
1793            .remove(&crate::test_util::vuln_key("actions/download-artifact"))
1794            .expect("one outcome per target")
1795    }
1796
1797    /// #1694: a hit under an unconfirmed name is authoritative.
1798    #[tokio::test]
1799    async fn provisional_name_positive_hit_stays_vulnerable() {
1800        assert_matches!(
1801            scan_gha_provisional("4.1.2", GHA_UNVERSIONED_BODY).await,
1802            ScanOutcome::Vulnerable(_)
1803        );
1804    }
1805
1806    /// #1694: an empty answer, or one whose records do not affect the version, is never clean.
1807    #[tokio::test]
1808    async fn provisional_name_clean_result_is_downgraded_to_unconfirmed() {
1809        for body in [GHA_UNVERSIONED_BODY, r#"{"results":[{}]}"#] {
1810            let version = if body == GHA_UNVERSIONED_BODY {
1811                "4.1.3"
1812            } else {
1813                "4.1.2"
1814            };
1815            assert_matches!(
1816                scan_gha_provisional(version, body).await,
1817                ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed),
1818                "body={body}"
1819            );
1820        }
1821    }
1822
1823    /// #1694: a failed query stays `QueryFailed`, and the cached second scan downgrades the
1824    /// same way as the first.
1825    #[tokio::test]
1826    async fn provisional_name_query_failure_and_cached_rescan() {
1827        let (mut server, client) = mock_client().await;
1828        let failing = server
1829            .mock("POST", "/v1/querybatch")
1830            .with_status(500)
1831            .expect_at_least(1)
1832            .create_async()
1833            .await;
1834        let targets = [provisional_target("actions/download-artifact", "4.1.2")];
1835        let key = crate::test_util::vuln_key("actions/download-artifact");
1836        let outcomes = client
1837            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1838            .await;
1839        assert_matches!(
1840            outcomes.get(&key),
1841            Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
1842        );
1843        failing.remove_async().await;
1844
1845        let batch = server
1846            .mock("POST", "/v1/querybatch")
1847            .with_status(200)
1848            .with_body(r#"{"results":[{}]}"#)
1849            .expect(1)
1850            .create_async()
1851            .await;
1852        for _ in 0..2 {
1853            let outcomes = client
1854                .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1855                .await;
1856            assert_matches!(
1857                outcomes.get(&key),
1858                Some(ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed))
1859            );
1860        }
1861        batch.assert_async().await;
1862    }
1863
1864    /// #1694: a record filed under a different casing than the provisional written name does
1865    /// not describe it, so the answer is unconfirmed, not vulnerable and not clean.
1866    #[tokio::test]
1867    async fn provisional_name_record_for_other_casing_is_skipped() {
1868        let (mut server, client) = mock_client().await;
1869        let _batch = server
1870            .mock("POST", "/v1/querybatch")
1871            .with_status(200)
1872            .with_body(GHA_UNVERSIONED_BODY)
1873            .create_async()
1874            .await;
1875        let _record = server
1876            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
1877            .with_status(200)
1878            .with_body(
1879                GHA_RECORD_BODY.replace("actions/download-artifact", "Actions/Download-Artifact"),
1880            )
1881            .create_async()
1882            .await;
1883        let targets = [provisional_target("actions/download-artifact", "4.1.2")];
1884        let outcomes = client
1885            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1886            .await;
1887        assert_matches!(
1888            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
1889            Some(ScanOutcome::Skipped(SkipReason::UnevaluableAdvisoryRange))
1890        );
1891    }
1892
1893    /// #1694: `check_candidates` shares `resolve`, so a provisional candidate is never
1894    /// reported clean either.
1895    #[tokio::test]
1896    async fn provisional_name_candidate_check_is_unverified_not_clean() {
1897        let (mut server, client) = mock_client().await;
1898        let _batch = server
1899            .mock("POST", "/v1/querybatch")
1900            .with_status(200)
1901            .with_body(r#"{"results":[{}]}"#)
1902            .create_async()
1903            .await;
1904        let statuses = client
1905            .check_candidates(
1906                EcosystemId::GithubActions,
1907                &[provisional_target("actions/download-artifact", "4.1.2")],
1908                TEST_TIMEOUT,
1909            )
1910            .await;
1911        assert_matches!(
1912            statuses.get(&crate::test_util::vuln_key("actions/download-artifact")),
1913            Some(UpgradeStatus::CandidateUnverified {
1914                reason: SkipReason::CanonicalNameUnconfirmed,
1915                ..
1916            })
1917        );
1918    }
1919
1920    /// #1694: with duplicate keys the first-seen target decides, matching which one is queried.
1921    #[tokio::test]
1922    async fn provisional_downgrade_follows_first_seen_duplicate_key() {
1923        for (first_provisional, expected_clean) in [(false, true), (true, false)] {
1924            let (mut server, client) = mock_client().await;
1925            let _batch = server
1926                .mock("POST", "/v1/querybatch")
1927                .with_status(200)
1928                .with_body(r#"{"results":[{}]}"#)
1929                .create_async()
1930                .await;
1931            let confirmed = target("actions/download-artifact", "4.1.2");
1932            let provisional = provisional_target("actions/download-artifact", "4.1.2");
1933            let targets = if first_provisional {
1934                [provisional, confirmed]
1935            } else {
1936                [confirmed, provisional]
1937            };
1938            let outcomes = client
1939                .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1940                .await;
1941            let outcome = outcomes.get(&crate::test_util::vuln_key("actions/download-artifact"));
1942            if expected_clean {
1943                assert_matches!(outcome, Some(ScanOutcome::Clean));
1944            } else {
1945                assert_matches!(
1946                    outcome,
1947                    Some(ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed))
1948                );
1949            }
1950        }
1951    }
1952
1953    #[tokio::test]
1954    async fn gha_non_semver_version_is_skipped_without_querying() {
1955        let (_server, client) = mock_client().await;
1956        for version in ["4", "main", "1234567890abcdef1234567890abcdef12345678"] {
1957            let targets = vec![target("actions/download-artifact", version)];
1958            let outcomes = client
1959                .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1960                .await;
1961            assert_matches!(
1962                outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
1963                Some(ScanOutcome::Skipped(SkipReason::UnmatchableVersion))
1964            );
1965        }
1966    }
1967
1968    #[tokio::test]
1969    async fn gha_unevaluable_range_is_skipped_not_clean() {
1970        let (mut server, client) = mock_client().await;
1971        let _batch = server
1972            .mock("POST", "/v1/querybatch")
1973            .with_status(200)
1974            .with_body(GHA_UNVERSIONED_BODY)
1975            .create_async()
1976            .await;
1977        let _record = server
1978            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
1979            .with_status(200)
1980            .with_body(
1981                r#"{"id":"GHSA-cxww-7g56-2vh6","modified":"2025-01-22T17:31:55Z",
1982                "affected":[{"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"not-a-version"}]}]}]}"#,
1983            )
1984            .create_async()
1985            .await;
1986        let targets = vec![target("actions/download-artifact", "4.1.2")];
1987        let outcomes = client
1988            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
1989            .await;
1990        assert_matches!(
1991            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
1992            Some(ScanOutcome::Skipped(SkipReason::UnevaluableAdvisoryRange))
1993        );
1994    }
1995
1996    fn gha_record(id: &str, affected_events: &str) -> String {
1997        format!(
1998            r#"{{"id":"{id}","modified":"2025-01-01T00:00:00Z","affected":[{{"package":{{"name":"actions/download-artifact","ecosystem":"GitHub Actions"}},"ranges":[{{"type":"ECOSYSTEM","events":{affected_events}}}]}}]}}"#
1999        )
2000    }
2001
2002    #[tokio::test]
2003    async fn gha_bare_major_fixed_bound_is_matched() {
2004        let (mut server, client) = mock_client().await;
2005        let _batch = server
2006            .mock("POST", "/v1/querybatch")
2007            .with_status(200)
2008            .with_body(GHA_UNVERSIONED_BODY)
2009            .create_async()
2010            .await;
2011        let _record = server
2012            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
2013            .with_status(200)
2014            .with_body(gha_record(
2015                "GHSA-cxww-7g56-2vh6",
2016                r#"[{"introduced":"0"},{"fixed":"2"}]"#,
2017            ))
2018            .create_async()
2019            .await;
2020        let targets = vec![target("actions/download-artifact", "1.0.0")];
2021        let outcomes = client
2022            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2023            .await;
2024        assert_matches!(
2025            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
2026            Some(ScanOutcome::Vulnerable(_))
2027        );
2028    }
2029
2030    #[tokio::test]
2031    async fn gha_mixed_batch_queries_only_matchable_targets() {
2032        let (mut server, client) = mock_client().await;
2033        let batch = server
2034            .mock("POST", "/v1/querybatch")
2035            .match_request(|req| {
2036                req.utf8_lossy_body().is_ok_and(|b| {
2037                    b.contains("actions/download-artifact") && !b.contains("floating-pin")
2038                })
2039            })
2040            .with_status(200)
2041            .with_body(r#"{"results":[{}]}"#)
2042            .expect(1)
2043            .create_async()
2044            .await;
2045        let targets = vec![
2046            target("floating-pin", "v4"),
2047            target("actions/download-artifact", "4.1.2"),
2048        ];
2049        let outcomes = client
2050            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2051            .await;
2052        batch.assert_async().await;
2053        assert_matches!(
2054            outcomes.get(&crate::test_util::vuln_key("floating-pin")),
2055            Some(ScanOutcome::Skipped(SkipReason::UnmatchableVersion))
2056        );
2057        assert_matches!(
2058            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
2059            Some(ScanOutcome::Clean)
2060        );
2061    }
2062
2063    struct IdentityNaming;
2064    impl crate::lsp_helpers::OsvNaming for IdentityNaming {}
2065
2066    fn with_sibling_tags(target: ScanTarget, primary: &str, siblings: &[&str]) -> ScanTarget {
2067        let versions = crate::lsp_helpers::InUseVersions::for_test(
2068            ConcreteVersion::new(primary),
2069            siblings.iter().map(|s| ConcreteVersion::new(*s)).collect(),
2070        );
2071        target.with_siblings(&versions, &IdentityNaming)
2072    }
2073
2074    async fn scan_gha_with_siblings(primary: &str, siblings: &[&str], events: &str) -> ScanOutcome {
2075        let (mut server, client) = mock_client().await;
2076        let _batch = server
2077            .mock("POST", "/v1/querybatch")
2078            .with_status(200)
2079            .with_body(GHA_UNVERSIONED_BODY)
2080            .create_async()
2081            .await;
2082        let _record = server
2083            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
2084            .with_status(200)
2085            .with_body(gha_record("GHSA-cxww-7g56-2vh6", events))
2086            .create_async()
2087            .await;
2088        let targets = vec![with_sibling_tags(
2089            target("actions/download-artifact", primary),
2090            primary,
2091            siblings,
2092        )];
2093        let mut outcomes = client
2094            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2095            .await;
2096        outcomes
2097            .remove(&crate::test_util::vuln_key("actions/download-artifact"))
2098            .expect("one outcome per target")
2099    }
2100
2101    /// #1709: an advisory fixed in the sibling's release still affects the primary; nothing is
2102    /// attributed to the sibling.
2103    #[tokio::test]
2104    async fn gha_sibling_scan_fixed_in_sibling_release_matches_primary_only() {
2105        let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
2106            "4.8.0",
2107            &["4.9.0"],
2108            r#"[{"introduced":"0"},{"fixed":"4.9.0"}]"#,
2109        )
2110        .await
2111        else {
2112            panic!("expected Vulnerable");
2113        };
2114        assert!(!dv.has_sibling_matches());
2115    }
2116
2117    /// #1709: an advisory introduced in the sibling's release used to be missed (Clean) and is
2118    /// now reported with the sibling named.
2119    #[tokio::test]
2120    async fn gha_sibling_scan_introduced_in_sibling_release_is_flagged_with_the_tag() {
2121        let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
2122            "4.8.0",
2123            &["4.9.0"],
2124            r#"[{"introduced":"4.9.0"},{"fixed":"4.9.1"}]"#,
2125        )
2126        .await
2127        else {
2128            panic!("expected Vulnerable");
2129        };
2130        let tags: Vec<&str> = dv
2131            .sibling_match("GHSA-cxww-7g56-2vh6")
2132            .expect("sibling-only advisory is attributed")
2133            .iter()
2134            .map(ConcreteVersion::as_str)
2135            .collect();
2136        assert_eq!(tags, ["4.9.0"]);
2137    }
2138
2139    #[tokio::test]
2140    async fn gha_sibling_scan_unrelated_range_is_clean() {
2141        assert_matches!(
2142            scan_gha_with_siblings("4.8.0", &["4.9.0"], r#"[{"introduced":"5.0.0"}]"#).await,
2143            ScanOutcome::Clean
2144        );
2145    }
2146
2147    /// #1709: a sibling that cannot be evaluated never counts as clean.
2148    #[tokio::test]
2149    async fn gha_sibling_scan_unparseable_sibling_is_skipped_not_clean() {
2150        assert_matches!(
2151            scan_gha_with_siblings("4.8.0", &["not-a-version"], r#"[{"introduced":"5.0.0"}]"#)
2152                .await,
2153            ScanOutcome::Skipped(SkipReason::UnevaluableAdvisoryRange)
2154        );
2155    }
2156
2157    /// #1709: server-side matched ecosystems cannot evaluate siblings, so the target fails closed.
2158    #[tokio::test]
2159    async fn server_side_target_with_siblings_is_skipped_unmatchable() {
2160        let (_server, client) = mock_client().await;
2161        let targets = vec![with_sibling_tags(
2162            target("serde", "1.0.0"),
2163            "1.0.0",
2164            &["1.0.1"],
2165        )];
2166        let outcomes = client
2167            .scan(EcosystemId::Cargo, &targets, TEST_TIMEOUT)
2168            .await;
2169        assert_matches!(
2170            outcomes.get(&crate::test_util::vuln_key("serde")),
2171            Some(ScanOutcome::Skipped(SkipReason::UnmatchableVersion))
2172        );
2173    }
2174
2175    /// #1709: GitLab CI has no OSV ecosystem, so siblings never change its outcome.
2176    #[tokio::test]
2177    async fn unmappable_ecosystem_target_with_siblings_stays_unmappable() {
2178        let (_server, client) = mock_client().await;
2179        let targets = vec![with_sibling_tags(
2180            target("group/project", "1.0.0"),
2181            "1.0.0",
2182            &["1.0.1"],
2183        )];
2184        let outcomes = client
2185            .scan(EcosystemId::GitlabCi, &targets, TEST_TIMEOUT)
2186            .await;
2187        assert_matches!(
2188            outcomes.get(&crate::test_util::vuln_key("group/project")),
2189            Some(ScanOutcome::Skipped(SkipReason::UnmappableEcosystem))
2190        );
2191    }
2192
2193    /// #1709 (G1): an advisory that affects the primary is not attributed to a sibling that
2194    /// it also affects.
2195    #[tokio::test]
2196    async fn gha_sibling_scan_primary_and_sibling_both_affected_has_no_sibling_entry() {
2197        let ScanOutcome::Vulnerable(dv) =
2198            scan_gha_with_siblings("4.8.0", &["4.9.0"], r#"[{"introduced":"0"}]"#).await
2199        else {
2200            panic!("expected Vulnerable");
2201        };
2202        assert!(!dv.has_sibling_matches());
2203    }
2204
2205    /// #1709 (G2): every matched sibling is named, lowest first, under a single advisory.
2206    #[tokio::test]
2207    async fn gha_sibling_scan_names_every_matched_sibling_in_order() {
2208        let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
2209            "4.8.0",
2210            &["4.9.0", "4.10.0", "5.0.0"],
2211            r#"[{"introduced":"4.9.0"},{"fixed":"4.11.0"}]"#,
2212        )
2213        .await
2214        else {
2215            panic!("expected Vulnerable");
2216        };
2217        assert_eq!(dv.advisories.total(), 1);
2218        let tags: Vec<&str> = dv
2219            .sibling_match("GHSA-cxww-7g56-2vh6")
2220            .expect("sibling-only advisory")
2221            .iter()
2222            .map(ConcreteVersion::as_str)
2223            .collect();
2224        assert_eq!(tags, ["4.9.0", "4.10.0"]);
2225    }
2226
2227    /// #1709 (M2): a sibling-only advisory fixed at or below the primary is not an upgrade
2228    /// target; one fixed above the primary still is.
2229    #[tokio::test]
2230    async fn gha_sibling_scan_lower_sibling_fix_is_not_recommended_as_upgrade() {
2231        let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
2232            "4.9.0",
2233            &["4.8.0"],
2234            r#"[{"introduced":"0"},{"fixed":"4.8.1"}]"#,
2235        )
2236        .await
2237        else {
2238            panic!("expected Vulnerable");
2239        };
2240        assert!(dv.sibling_match("GHSA-cxww-7g56-2vh6").is_some());
2241        assert!(dv.recommended_fix(None).is_none());
2242
2243        let ScanOutcome::Vulnerable(dv) = scan_gha_with_siblings(
2244            "4.9.0",
2245            &["4.8.0"],
2246            r#"[{"introduced":"0"},{"fixed":"5.0.0"}]"#,
2247        )
2248        .await
2249        else {
2250            panic!("expected Vulnerable");
2251        };
2252        assert_eq!(dv.recommended_fix(None).unwrap().version, "5.0.0");
2253    }
2254
2255    #[tokio::test]
2256    async fn gha_confirmed_match_outranks_an_unevaluable_sibling() {
2257        let (mut server, client) = mock_client().await;
2258        let _batch = server
2259            .mock("POST", "/v1/querybatch")
2260            .with_status(200)
2261            .with_body(
2262                r#"{"results":[{"vulns":[{"id":"GHSA-good","modified":"2025-01-01T00:00:00Z"},{"id":"GHSA-bad","modified":"2025-01-01T00:00:00Z"}]}]}"#,
2263            )
2264            .create_async()
2265            .await;
2266        let _good = server
2267            .mock("GET", "/v1/vulns/GHSA-good")
2268            .with_status(200)
2269            .with_body(gha_record(
2270                "GHSA-good",
2271                r#"[{"introduced":"0"},{"fixed":"5.0.0"}]"#,
2272            ))
2273            .create_async()
2274            .await;
2275        let _bad = server
2276            .mock("GET", "/v1/vulns/GHSA-bad")
2277            .with_status(200)
2278            .with_body(gha_record(
2279                "GHSA-bad",
2280                r#"[{"introduced":"0"},{"fixed":"not-a-version"}]"#,
2281            ))
2282            .create_async()
2283            .await;
2284        let targets = vec![target("actions/download-artifact", "4.1.2")];
2285        let outcomes = client
2286            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2287            .await;
2288        let Some(ScanOutcome::Vulnerable(dv)) =
2289            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact"))
2290        else {
2291            panic!("expected Vulnerable");
2292        };
2293        assert_eq!(dv.advisories.total(), 1);
2294    }
2295
2296    #[tokio::test]
2297    async fn gha_more_than_the_record_cap_is_truncated_not_clean() {
2298        let (mut server, client) = mock_client().await;
2299        let vulns: Vec<String> = (0..=MAX_ADVISORY_RECORDS)
2300            .map(|i| format!(r#"{{"id":"GHSA-{i}","modified":"2025-01-01T00:00:00Z"}}"#))
2301            .collect();
2302        let _batch = server
2303            .mock("POST", "/v1/querybatch")
2304            .with_status(200)
2305            .with_body(format!(
2306                r#"{{"results":[{{"vulns":[{}]}}]}}"#,
2307                vulns.join(",")
2308            ))
2309            .create_async()
2310            .await;
2311        let targets = vec![target("actions/download-artifact", "4.1.2")];
2312        let outcomes = client
2313            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2314            .await;
2315        assert_matches!(
2316            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
2317            Some(ScanOutcome::Skipped(SkipReason::Truncated))
2318        );
2319    }
2320
2321    #[tokio::test]
2322    async fn gha_record_fetch_failure_is_query_failed() {
2323        let (mut server, client) = mock_client().await;
2324        let _batch = server
2325            .mock("POST", "/v1/querybatch")
2326            .with_status(200)
2327            .with_body(GHA_UNVERSIONED_BODY)
2328            .create_async()
2329            .await;
2330        let _record = server
2331            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
2332            .with_status(500)
2333            .create_async()
2334            .await;
2335        let targets = vec![target("actions/download-artifact", "4.1.2")];
2336        let outcomes = client
2337            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2338            .await;
2339        assert_matches!(
2340            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
2341            Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
2342        );
2343    }
2344
2345    #[tokio::test]
2346    async fn gha_truncation_recovery_matches_locally() {
2347        let (mut server, client) = mock_client().await;
2348        let _batch = server
2349            .mock("POST", "/v1/querybatch")
2350            .with_status(200)
2351            .with_body(r#"{"results":[{"vulns":[],"next_page_token":"t"},{}]}"#)
2352            .create_async()
2353            .await;
2354        let _single = server
2355            .mock("POST", "/v1/query")
2356            .match_request(|req| {
2357                !req.utf8_lossy_body()
2358                    .is_ok_and(|b| b.contains("\"version\""))
2359            })
2360            .with_status(200)
2361            .with_body(format!(
2362                r#"{{"vulns":[{}]}}"#,
2363                gha_record(
2364                    "GHSA-cxww-7g56-2vh6",
2365                    r#"[{"introduced":"4.0.0"},{"fixed":"4.1.3"}]"#
2366                )
2367            ))
2368            .create_async()
2369            .await;
2370        let targets = vec![
2371            target("actions/download-artifact", "4.1.2"),
2372            target("actions/other", "9.9.9"),
2373        ];
2374        let outcomes = client
2375            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2376            .await;
2377        assert_matches!(
2378            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
2379            Some(ScanOutcome::Vulnerable(_))
2380        );
2381    }
2382
2383    #[test]
2384    fn version_matching_table_and_wire_json() {
2385        assert_eq!(
2386            OsvEcosystem::CratesIo.version_matching(),
2387            VersionMatching::ServerSide
2388        );
2389        assert_eq!(
2390            OsvEcosystem::GitHubActions.version_matching(),
2391            VersionMatching::LocalUnversioned
2392        );
2393        let t = target("actions/download-artifact", "4.1.2");
2394        let local = serde_json::to_value(OsvQuery::new(&t, OsvEcosystem::GitHubActions)).unwrap();
2395        assert!(local.get("version").is_none());
2396        let server_side = serde_json::to_value(OsvQuery::new(&t, OsvEcosystem::Npm)).unwrap();
2397        assert_eq!(server_side["version"], "4.1.2");
2398    }
2399
2400    /// Live gate for GHA advisories with bare-major bounds (`fixed: "2"`).
2401    #[tokio::test]
2402    #[ignore = "requires network access"]
2403    async fn live_gha_bare_major_bound_is_vulnerable() {
2404        let targets = vec![target("embano1/wip", "1.0.0")];
2405        let outcomes = client()
2406            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2407            .await;
2408        assert_matches!(
2409            outcomes.get(&crate::test_util::vuln_key("embano1/wip")),
2410            Some(ScanOutcome::Vulnerable(_))
2411        );
2412    }
2413
2414    /// Live registry gate for issue #1675: run on demand, never in CI.
2415    #[tokio::test]
2416    #[ignore = "requires network access"]
2417    async fn live_gha_in_range_version_is_vulnerable() {
2418        let targets = vec![target("actions/download-artifact", "4.1.2")];
2419        let outcomes = client()
2420            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
2421            .await;
2422        assert_matches!(
2423            outcomes.get(&crate::test_util::vuln_key("actions/download-artifact")),
2424            Some(ScanOutcome::Vulnerable(_))
2425        );
2426    }
2427
2428    #[tokio::test]
2429    async fn scan_malformed_advisory_id_is_dropped() {
2430        // Critique M1: `id` is echoed into a markdown link destination and a
2431        // Diagnostic.code; a malformed id must never survive into an
2432        // `Advisory`.
2433        let (mut server, client) = mock_client().await;
2434        let _batch = server
2435            .mock("POST", "/v1/querybatch")
2436            .with_status(200)
2437            .with_body(
2438                r#"{"results":[{"vulns":[{"id":"evil](javascript:alert(1))","modified":"2023-01-01T00:00:00Z"}]}]}"#,
2439            )
2440            .create_async()
2441            .await;
2442
2443        let targets = vec![target("pkg", "1.0.0")];
2444        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
2445
2446        // total() still counts the batch stub; the malformed-id record
2447        // is dropped rather than rendered with an unsafe id.
2448        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("pkg"))
2449        else {
2450            panic!(
2451                "expected Vulnerable outcome, got {:?}",
2452                outcomes.get(&crate::test_util::vuln_key("pkg"))
2453            );
2454        };
2455        assert_eq!(dv.advisories.total(), 1);
2456        assert!(dv.advisories.items().is_empty());
2457    }
2458
2459    #[tokio::test]
2460    async fn recover_truncated_single_query_that_itself_paginates_is_skipped_truncated() {
2461        // Critique S4: `/v1/query` can itself paginate; a `next_page_token`
2462        // on that response must never be trusted as a complete `vulns` list.
2463        let (mut server, client) = mock_client().await;
2464        let _batch = server
2465            .mock("POST", "/v1/querybatch")
2466            .with_status(200)
2467            .with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
2468            .create_async()
2469            .await;
2470        let _requery = server
2471            .mock("POST", "/v1/query")
2472            .with_status(200)
2473            .with_body(
2474                r#"{"vulns":[{"id":"GHSA-1","modified":"2023-01-01T00:00:00Z"}],"next_page_token":"still-more"}"#,
2475            )
2476            .create_async()
2477            .await;
2478
2479        let targets = vec![target("linux", "5.10.1")];
2480        let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
2481
2482        assert_matches!(
2483            outcomes.get(&crate::test_util::vuln_key("linux")),
2484            Some(ScanOutcome::Skipped(SkipReason::Truncated))
2485        );
2486    }
2487
2488    /// Issue #1539: `recover_truncated`'s permit wait must also fail closed at the scan
2489    /// deadline — the same bound as `fetch_record_single_flight`, exercised end-to-end through
2490    /// `scan` with the client-wide semaphore held externally so the requery can never acquire a
2491    /// permit before the deadline elapses. Budget widened to 1s (critique M3) to comfortably
2492    /// cover the mock-server round trip on a slow CI runner without risking the pre-recovery
2493    /// deadline check (mod.rs `resolve`) firing first and yielding `Truncated` instead of the
2494    /// `QueryFailed` this test targets; the whole call is wrapped in an outer test timeout
2495    /// (critique M2) so a regression fails this test rather than hanging CI.
2496    #[tokio::test]
2497    async fn recover_truncated_permit_wait_bounded_by_deadline_fails_query_failed() {
2498        let (mut server, client) = mock_client().await;
2499        let _batch = server
2500            .mock("POST", "/v1/querybatch")
2501            .with_status(200)
2502            .with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
2503            .create_async()
2504            .await;
2505
2506        let _permits: Vec<_> = futures::future::join_all(
2507            (0..RECORD_FETCH_CONCURRENCY)
2508                .map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
2509        )
2510        .await
2511        .into_iter()
2512        .map(|permit| permit.expect("semaphore is not closed"))
2513        .collect();
2514
2515        let targets = vec![target("linux", "5.10.1")];
2516        let outcomes = tokio::time::timeout(
2517            Duration::from_secs(10),
2518            client.scan(EcosystemId::Go, &targets, Duration::from_secs(1)),
2519        )
2520        .await
2521        .expect("the requery must fail at the deadline, not hang until a permit frees up");
2522
2523        assert_matches!(
2524            outcomes.get(&crate::test_util::vuln_key("linux")),
2525            Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
2526        );
2527    }
2528
2529    #[tokio::test]
2530    async fn scan_vulnerable_fetches_advisory_record() {
2531        let (mut server, client) = mock_client().await;
2532        let _batch = server
2533            .mock("POST", "/v1/querybatch")
2534            .with_status(200)
2535            .with_body(r#"{"results":[{"vulns":[{"id":"RUSTSEC-2020-0071","modified":"2023-01-01T00:00:00Z"}]}]}"#)
2536            .create_async()
2537            .await;
2538        // Real shape of RUSTSEC-2020-0071's `affected[].ranges` (architecture.md §6): 8
2539        // `fixed` events across several ranges, deliberately out of order so a "take the
2540        // last event, no sort" bug wouldn't be caught by a trivially-ordered fixture. First
2541        // `fixed` in document order is `0.2.0`; the highest (real guidance) is `0.2.23`.
2542        let _record = server
2543            .mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
2544            .with_status(200)
2545            .with_body(
2546                r#"{"id":"RUSTSEC-2020-0071","modified":"2023-01-01T00:00:00Z",
2547                   "summary":"Potential segfault","database_specific":{"severity":"HIGH"},
2548                   "affected":[
2549                     {"package":{"name":"time","ecosystem":"crates.io"},"ranges":[
2550                       {"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.2.0"},{"fixed":"0.1.44"}]},
2551                       {"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.2.4"},{"fixed":"0.1.43"}]},
2552                       {"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.2.2"},{"fixed":"0.2.23"}]},
2553                       {"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.2.1"},{"fixed":"0.2.3"}]}
2554                     ]}
2555                   ]}"#,
2556            )
2557            .create_async()
2558            .await;
2559
2560        let targets = vec![target("time", "0.1.43")];
2561        let outcomes = client
2562            .scan(EcosystemId::Cargo, &targets, TEST_TIMEOUT)
2563            .await;
2564
2565        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("time"))
2566        else {
2567            panic!(
2568                "expected Vulnerable outcome, got {:?}",
2569                outcomes.get(&crate::test_util::vuln_key("time"))
2570            );
2571        };
2572        assert_eq!(dv.advisories.total(), 1);
2573        assert_eq!(dv.advisories.items().len(), 1);
2574        assert_eq!(dv.advisories.items()[0].id, "RUSTSEC-2020-0071");
2575        assert_eq!(dv.advisories.items()[0].severity, VulnSeverity::High);
2576        assert_eq!(
2577            dv.advisories.items()[0].fixed_versions,
2578            vec![
2579                "0.1.43", "0.1.44", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.23"
2580            ]
2581            .into_iter()
2582            .map(OsvVersion::new)
2583            .collect::<Vec<_>>()
2584        );
2585        // The highest fixed version, not the first in document order.
2586        assert_eq!(
2587            dv.advisories.items()[0].fixed_versions.last(),
2588            Some(&OsvVersion::new("0.2.23"))
2589        );
2590    }
2591
2592    #[tokio::test]
2593    async fn scan_dropped_advisory_record_still_yields_vulnerable_with_fewer_advisories() {
2594        let (mut server, client) = mock_client().await;
2595        let _batch = server
2596            .mock("POST", "/v1/querybatch")
2597            .with_status(200)
2598            .with_body(
2599                r#"{"results":[{"vulns":[{"id":"MISSING-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
2600            )
2601            .create_async()
2602            .await;
2603        let _record = server
2604            .mock("GET", "/v1/vulns/MISSING-1")
2605            .with_status(404)
2606            .create_async()
2607            .await;
2608
2609        let targets = vec![target("pkg", "1.0.0")];
2610        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
2611
2612        // total() still reflects the batch stub count; the failed fetch
2613        // is dropped rather than rendered half-populated.
2614        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("pkg"))
2615        else {
2616            panic!(
2617                "expected Vulnerable outcome, got {:?}",
2618                outcomes.get(&crate::test_util::vuln_key("pkg"))
2619            );
2620        };
2621        assert_eq!(dv.advisories.total(), 1);
2622        assert!(dv.advisories.items().is_empty());
2623    }
2624
2625    #[tokio::test]
2626    async fn scan_next_page_token_is_never_rendered_as_clean() {
2627        let (mut server, client) = mock_client().await;
2628        // No `vulns` key at all — only `next_page_token` — per the live-verified
2629        // truncation shape in architecture.md §8 invariant 2.
2630        let _batch = server
2631            .mock("POST", "/v1/querybatch")
2632            .with_status(200)
2633            .with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
2634            .create_async()
2635            .await;
2636        let _requery = server
2637            .mock("POST", "/v1/query")
2638            .with_status(200)
2639            .with_body(
2640                r#"{"vulns":[{"id":"GHSA-1","modified":"2023-01-01T00:00:00Z","database_specific":{"severity":"CRITICAL"}}]}"#,
2641            )
2642            .create_async()
2643            .await;
2644
2645        let targets = vec![target("linux", "5.10.1")];
2646        let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
2647
2648        let Some(outcome) = outcomes.get(&crate::test_util::vuln_key("linux")) else {
2649            panic!("dependency missing from outcome map");
2650        };
2651        assert!(
2652            !matches!(outcome, ScanOutcome::Clean),
2653            "a truncated batch result must never render as clean"
2654        );
2655        assert_matches!(outcome, ScanOutcome::Vulnerable(_));
2656    }
2657
2658    #[tokio::test]
2659    async fn scan_advisory_fetch_is_capped_but_total_known_reflects_full_count() {
2660        let (mut server, client) = mock_client().await;
2661
2662        let vuln_count = MAX_ADVISORY_RECORDS + 10;
2663        let vulns_json: String = (0..vuln_count)
2664            .map(|i| format!(r#"{{"id":"ADV-{i}","modified":"2023-01-01T00:00:00Z"}}"#))
2665            .collect::<Vec<_>>()
2666            .join(",");
2667        let _batch = server
2668            .mock("POST", "/v1/querybatch")
2669            .with_status(200)
2670            .with_body(format!(r#"{{"results":[{{"vulns":[{vulns_json}]}}]}}"#))
2671            .create_async()
2672            .await;
2673
2674        // Only expect fetches for however many the cap allows.
2675        let record = server
2676            .mock(
2677                "GET",
2678                mockito::Matcher::Regex(r"^/v1/vulns/ADV-\d+$".into()),
2679            )
2680            .with_status(200)
2681            .with_body(r#"{"id":"ADV-x","modified":"2023-01-01T00:00:00Z"}"#)
2682            .expect(MAX_ADVISORY_RECORDS)
2683            .create_async()
2684            .await;
2685
2686        let targets = vec![target("rack", "2.0.5")];
2687        let outcomes = client
2688            .scan(EcosystemId::Bundler, &targets, TEST_TIMEOUT)
2689            .await;
2690
2691        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("rack"))
2692        else {
2693            panic!("expected Vulnerable outcome");
2694        };
2695        assert_eq!(dv.advisories.total(), vuln_count);
2696        // The fix-computation set is capped at MAX_ADVISORY_RECORDS, well beyond what
2697        // ADVISORY_DISPLAY_CAP alone would allow (#1422) ...
2698        assert_eq!(dv.advisories.items().len(), MAX_ADVISORY_RECORDS);
2699        // ... but rendering still truncates to ADVISORY_DISPLAY_CAP.
2700        assert_eq!(
2701            dv.advisories_for_display().items().len(),
2702            ADVISORY_DISPLAY_CAP
2703        );
2704        assert_eq!(dv.advisories_for_display().total(), vuln_count);
2705        record.assert_async().await;
2706    }
2707
2708    #[tokio::test]
2709    async fn scan_recommended_fix_considers_advisories_beyond_display_cap() {
2710        // #1422: reproduces the golang.org/x/net repro (18 advisories; the true highest
2711        // `fixed` version sits on an advisory OSV returns after the first ADVISORY_DISPLAY_CAP
2712        // records). `recommended_fix()` must not silently pick a lower version just because
2713        // the display cap used to also bound the fetch.
2714        let (mut server, client) = mock_client().await;
2715
2716        let advisory_count = 18;
2717        let vulns_json: String = (0..advisory_count)
2718            .map(|i| format!(r#"{{"id":"ADV-{i}","modified":"2023-01-01T00:00:00Z"}}"#))
2719            .collect::<Vec<_>>()
2720            .join(",");
2721        let _batch = server
2722            .mock("POST", "/v1/querybatch")
2723            .with_status(200)
2724            .with_body(format!(r#"{{"results":[{{"vulns":[{vulns_json}]}}]}}"#))
2725            .create_async()
2726            .await;
2727
2728        for i in 0..advisory_count {
2729            // Only the last advisory (well beyond ADVISORY_DISPLAY_CAP=5) carries the true
2730            // highest fix — every other one fixes at a lower version.
2731            let fixed = if i == advisory_count - 1 {
2732                "0.56.0"
2733            } else {
2734                "0.20.0"
2735            };
2736            let _record = server
2737                .mock("GET", format!("/v1/vulns/ADV-{i}").as_str())
2738                .with_status(200)
2739                .with_body(format!(
2740                    r#"{{"id":"ADV-{i}","modified":"2023-01-01T00:00:00Z",
2741                       "database_specific":{{"severity":"HIGH"}},
2742                       "affected":[{{"package":{{"name":"golang.org/x/net","ecosystem":"Go"}},
2743                         "ranges":[{{"type":"SEMVER","events":[{{"introduced":"0"}},{{"fixed":"{fixed}"}}]}}]}}]}}"#
2744                ))
2745                .create_async()
2746                .await;
2747        }
2748
2749        let targets = vec![target("golang.org/x/net", "0.17.0")];
2750        let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
2751
2752        let Some(ScanOutcome::Vulnerable(dv)) =
2753            outcomes.get(&crate::test_util::vuln_key("golang.org/x/net"))
2754        else {
2755            panic!("expected Vulnerable outcome");
2756        };
2757
2758        assert_eq!(dv.advisories.total(), advisory_count);
2759        assert_eq!(
2760            dv.advisories.items().len(),
2761            advisory_count,
2762            "every advisory must be fetched: advisory_count is well under MAX_ADVISORY_RECORDS"
2763        );
2764
2765        let fix = dv.recommended_fix(None).expect("a fix must be recommended");
2766        assert_eq!(
2767            fix.version, "0.56.0",
2768            "recommended_fix must consider the advisory beyond ADVISORY_DISPLAY_CAP"
2769        );
2770
2771        let display = dv.advisories_for_display();
2772        assert_eq!(
2773            display.items().len(),
2774            ADVISORY_DISPLAY_CAP,
2775            "rendering must still truncate to ADVISORY_DISPLAY_CAP"
2776        );
2777        assert_eq!(display.total(), advisory_count);
2778    }
2779
2780    #[tokio::test]
2781    async fn scan_second_call_within_ttl_issues_zero_requests() {
2782        let (mut server, client) = mock_client().await;
2783        let batch = server
2784            .mock("POST", "/v1/querybatch")
2785            .with_status(200)
2786            .with_body(r#"{"results":[{}]}"#)
2787            .expect(1)
2788            .create_async()
2789            .await;
2790
2791        let targets = vec![target("pkg", "1.0.0")];
2792        client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
2793        client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
2794
2795        batch.assert_async().await;
2796    }
2797
2798    #[tokio::test]
2799    async fn check_candidates_maps_clean_and_vulnerable() {
2800        let (mut server, client) = mock_client().await;
2801        let _batch = server
2802            .mock("POST", "/v1/querybatch")
2803            .with_status(200)
2804            .with_body(
2805                r#"{"results":[{},{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
2806            )
2807            .create_async()
2808            .await;
2809        let _record = server
2810            .mock("GET", "/v1/vulns/ADV-1")
2811            .with_status(200)
2812            .with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
2813            .create_async()
2814            .await;
2815
2816        let candidates = vec![target("clean-pkg", "2.0.0"), target("bad-pkg", "2.0.0")];
2817        let statuses = client
2818            .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
2819            .await;
2820
2821        assert_matches!(
2822            statuses.get(&crate::test_util::vuln_key("clean-pkg")),
2823            Some(UpgradeStatus::CandidateClean { version }) if version == "2.0.0"
2824        );
2825        assert_matches!(
2826            statuses.get(&crate::test_util::vuln_key("bad-pkg")),
2827            Some(UpgradeStatus::CandidateVulnerable { version, advisory_ids, .. })
2828                if version == "2.0.0"
2829                    && advisory_ids.items() == ["ADV-1".to_string()]
2830                    && advisory_ids.total() == 1
2831        );
2832    }
2833
2834    /// Issue #1655: candidates sharing a `VulnKey` (one dependency declared in several
2835    /// sections) must all reflect the one real OSV outcome, not a spurious `QueryFailed`.
2836    #[tokio::test]
2837    async fn check_candidates_duplicate_keys_share_the_real_outcome() {
2838        let (mut server, client) = mock_client().await;
2839        let batch = server
2840            .mock("POST", "/v1/querybatch")
2841            .with_status(200)
2842            .with_body(r#"{"results":[{}]}"#)
2843            .expect(1)
2844            .create_async()
2845            .await;
2846
2847        let candidates = vec![target("dup", "2.0.0"), target("dup", "2.0.0")];
2848        let statuses = client
2849            .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
2850            .await;
2851
2852        batch.assert_async().await;
2853        assert_eq!(statuses.len(), 1);
2854        assert_matches!(
2855            statuses.get(&crate::test_util::vuln_key("dup")),
2856            Some(UpgradeStatus::CandidateClean { version }) if version == "2.0.0"
2857        );
2858    }
2859
2860    #[tokio::test]
2861    async fn check_candidates_three_duplicates_share_one_query_and_outcome() {
2862        let (mut server, client) = mock_client().await;
2863        let batch = server
2864            .mock("POST", "/v1/querybatch")
2865            .with_status(200)
2866            .with_body(r#"{"results":[{}]}"#)
2867            .expect(1)
2868            .create_async()
2869            .await;
2870
2871        let candidates = vec![
2872            target("dup", "2.0.0"),
2873            target("dup", "2.0.0"),
2874            target("dup", "2.0.0"),
2875        ];
2876        let statuses = client
2877            .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
2878            .await;
2879
2880        batch.assert_async().await;
2881        assert_eq!(statuses.len(), 1);
2882        assert_matches!(
2883            statuses.get(&crate::test_util::vuln_key("dup")),
2884            Some(UpgradeStatus::CandidateClean { .. })
2885        );
2886    }
2887
2888    #[tokio::test]
2889    async fn check_candidates_mixed_duplicate_and_unique_keys_keep_their_own_outcomes() {
2890        let (mut server, client) = mock_client().await;
2891        let _batch = server
2892            .mock("POST", "/v1/querybatch")
2893            .with_status(200)
2894            .with_body(
2895                r#"{"results":[{},{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
2896            )
2897            .expect(1)
2898            .create_async()
2899            .await;
2900        let _record = server
2901            .mock("GET", "/v1/vulns/ADV-1")
2902            .with_status(200)
2903            .with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
2904            .create_async()
2905            .await;
2906
2907        let candidates = vec![
2908            target("dup", "2.0.0"),
2909            target("other", "3.0.0"),
2910            target("dup", "2.0.0"),
2911        ];
2912        let statuses = client
2913            .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
2914            .await;
2915
2916        assert_eq!(statuses.len(), 2);
2917        assert_matches!(
2918            statuses.get(&crate::test_util::vuln_key("dup")),
2919            Some(UpgradeStatus::CandidateClean { version }) if version == "2.0.0"
2920        );
2921        assert_matches!(
2922            statuses.get(&crate::test_util::vuln_key("other")),
2923            Some(UpgradeStatus::CandidateVulnerable { version, advisory_ids, .. })
2924                if version == "3.0.0" && advisory_ids.items() == ["ADV-1".to_string()]
2925        );
2926    }
2927
2928    #[tokio::test]
2929    async fn check_candidates_vulnerable_duplicates_carry_advisories() {
2930        let (mut server, client) = mock_client().await;
2931        let _batch = server
2932            .mock("POST", "/v1/querybatch")
2933            .with_status(200)
2934            .with_body(
2935                r#"{"results":[{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
2936            )
2937            .expect(1)
2938            .create_async()
2939            .await;
2940        let _record = server
2941            .mock("GET", "/v1/vulns/ADV-1")
2942            .with_status(200)
2943            .with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
2944            .create_async()
2945            .await;
2946
2947        let candidates = vec![target("dup", "2.0.0"), target("dup", "2.0.0")];
2948        let statuses = client
2949            .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
2950            .await;
2951
2952        assert_matches!(
2953            statuses.get(&crate::test_util::vuln_key("dup")),
2954            Some(UpgradeStatus::CandidateVulnerable { advisory_ids, .. })
2955                if advisory_ids.items() == ["ADV-1".to_string()] && advisory_ids.total() == 1
2956        );
2957    }
2958
2959    #[tokio::test]
2960    async fn scan_duplicate_keys_are_queried_once_and_keep_their_own_outcomes() {
2961        let (mut server, client) = mock_client().await;
2962        let batch = server
2963            .mock("POST", "/v1/querybatch")
2964            .with_status(200)
2965            .with_body(
2966                r#"{"results":[{},{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
2967            )
2968            .expect(1)
2969            .create_async()
2970            .await;
2971        let _record = server
2972            .mock("GET", "/v1/vulns/ADV-1")
2973            .with_status(200)
2974            .with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
2975            .create_async()
2976            .await;
2977
2978        let targets = vec![
2979            target("dup", "2.0.0"),
2980            target("other", "3.0.0"),
2981            target("dup", "2.0.0"),
2982        ];
2983        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
2984
2985        batch.assert_async().await;
2986        assert_eq!(outcomes.len(), 2);
2987        assert_matches!(
2988            outcomes.get(&crate::test_util::vuln_key("dup")),
2989            Some(ScanOutcome::Clean)
2990        );
2991        assert_matches!(
2992            outcomes.get(&crate::test_util::vuln_key("other")),
2993            Some(ScanOutcome::Vulnerable(_))
2994        );
2995    }
2996
2997    /// Same key with a different version is unreachable from the builders (the key encodes the
2998    /// in-use signature); `resolve` pins first-wins should it ever happen.
2999    #[tokio::test]
3000    async fn resolve_same_key_different_version_queries_only_the_first() {
3001        let (mut server, client) = mock_client().await;
3002        let batch = server
3003            .mock("POST", "/v1/querybatch")
3004            .with_status(200)
3005            .with_body(r#"{"results":[{}]}"#)
3006            .expect(1)
3007            .create_async()
3008            .await;
3009
3010        let targets = vec![target("dup", "1.0.0"), target("dup", "2.0.0")];
3011        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
3012
3013        batch.assert_async().await;
3014        assert_eq!(outcomes.len(), 1);
3015        assert_matches!(
3016            outcomes.get(&crate::test_util::vuln_key("dup")),
3017            Some(ScanOutcome::Clean)
3018        );
3019    }
3020
3021    #[tokio::test]
3022    async fn check_candidates_duplicate_keys_all_unverified_when_query_fails() {
3023        let (mut server, client) = mock_client().await;
3024        let _batch = server
3025            .mock("POST", "/v1/querybatch")
3026            .with_status(500)
3027            .create_async()
3028            .await;
3029
3030        let candidates = vec![target("dup", "2.0.0"), target("dup", "2.0.0")];
3031        let statuses = client
3032            .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
3033            .await;
3034
3035        assert_matches!(
3036            statuses.get(&crate::test_util::vuln_key("dup")),
3037            Some(UpgradeStatus::CandidateUnverified {
3038                reason: SkipReason::QueryFailed,
3039                ..
3040            })
3041        );
3042    }
3043
3044    /// Issue #1517: `check_candidates` must never silently drop a target it queried —
3045    /// [`SkipReason::UnmappableEcosystem`] (a whole-batch skip, since GitLab CI has no
3046    /// `osv_ecosystem`) must surface as `CandidateUnverified`, not an absent map entry.
3047    #[tokio::test]
3048    async fn check_candidates_maps_unmappable_ecosystem_to_candidate_unverified() {
3049        let client = client();
3050        let candidates = vec![target("pkg", "2.0.0")];
3051
3052        let statuses = client
3053            .check_candidates(EcosystemId::GitlabCi, &candidates, TEST_TIMEOUT)
3054            .await;
3055
3056        assert_matches!(
3057            statuses.get(&crate::test_util::vuln_key("pkg")),
3058            Some(UpgradeStatus::CandidateUnverified {
3059                version,
3060                reason: SkipReason::UnmappableEcosystem
3061            }) if version == "2.0.0"
3062        );
3063    }
3064
3065    /// Issue #1517 critique S2: a record that failed to fetch must never be silently excluded
3066    /// from `worst_severity`'s input — an `Informational`-only record that *did* fetch,
3067    /// alongside a second, unfetched record, must not read as `Some(Informational)` (which
3068    /// `latest_verdict` treats as `Verified`, the exact fail-open gap S2 found). `worst_severity`
3069    /// must come back `None` (blocking, per `CandidateVulnerable::worst_severity`'s own
3070    /// contract) whenever `advisories.items().len() < advisories.total()`.
3071    #[tokio::test]
3072    async fn check_candidates_forces_none_severity_when_a_record_failed_to_fetch() {
3073        let (mut server, client) = mock_client().await;
3074        let _batch = server
3075            .mock("POST", "/v1/querybatch")
3076            .with_status(200)
3077            .with_body(
3078                r#"{"results":[{"vulns":[
3079                    {"id":"ADV-OK","modified":"2023-01-01T00:00:00Z"},
3080                    {"id":"ADV-MISSING","modified":"2023-01-01T00:00:00Z"}
3081                ]}]}"#,
3082            )
3083            .create_async()
3084            .await;
3085        let _fetched = server
3086            .mock("GET", "/v1/vulns/ADV-OK")
3087            .with_status(200)
3088            .with_body(r#"{"id":"ADV-OK","modified":"2023-01-01T00:00:00Z"}"#)
3089            .create_async()
3090            .await;
3091        let _missing = server
3092            .mock("GET", "/v1/vulns/ADV-MISSING")
3093            .with_status(500)
3094            .create_async()
3095            .await;
3096
3097        let candidates = vec![target("bad-pkg", "2.0.0")];
3098        let statuses = client
3099            .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
3100            .await;
3101
3102        let Some(UpgradeStatus::CandidateVulnerable {
3103            advisory_ids,
3104            worst_severity,
3105            ..
3106        }) = statuses.get(&crate::test_util::vuln_key("bad-pkg"))
3107        else {
3108            panic!(
3109                "expected CandidateVulnerable, got {:?}",
3110                statuses.get(&crate::test_util::vuln_key("bad-pkg"))
3111            );
3112        };
3113        assert_eq!(advisory_ids.items(), ["ADV-OK".to_string()]);
3114        assert_eq!(
3115            advisory_ids.total(),
3116            2,
3117            "the unfetched record still counts toward total"
3118        );
3119        assert_eq!(
3120            *worst_severity, None,
3121            "an incomplete advisory set must never report a severity — the caller's \
3122             `latest_verdict` treats only `Some(Informational)` as non-blocking, and `None` \
3123             must stay blocking (Flagged), never silently pass as clean"
3124        );
3125    }
3126
3127    /// Issue #1517 critique S6: `check_candidates` must never silently drop a target whose
3128    /// query never even got a chance to run because the deadline had already elapsed —
3129    /// [`resolve`](OsvClient::resolve)'s own deadline check (checked before each chunk) marks
3130    /// the whole remaining batch [`SkipReason::QueryFailed`], and `check_candidates` must map
3131    /// that to [`UpgradeStatus::CandidateUnverified`], the fail-closed outcome every caller's
3132    /// [`crate::lsp_helpers::latest_verdict`] treats as `Unverified`, never as an absent
3133    /// (implicitly "safe") entry.
3134    #[tokio::test]
3135    async fn check_candidates_deadline_exceeded_yields_candidate_unverified() {
3136        let client = client();
3137        let candidates = vec![target("pkg", "2.0.0")];
3138
3139        // A zero-duration budget: `resolve`'s deadline (`Instant::now() + timeout`) has
3140        // already elapsed by the time the first chunk is checked, before any network call.
3141        let statuses = client
3142            .check_candidates(EcosystemId::Npm, &candidates, Duration::ZERO)
3143            .await;
3144
3145        assert_matches!(
3146            statuses.get(&crate::test_util::vuln_key("pkg")),
3147            Some(UpgradeStatus::CandidateUnverified {
3148                version,
3149                reason: SkipReason::QueryFailed,
3150            }) if version == "2.0.0"
3151        );
3152    }
3153
3154    #[tokio::test]
3155    async fn check_candidates_uses_display_version_not_wire_version() {
3156        // S1 regression guard: `ScanTarget.version` is the OSV wire spelling, but
3157        // `UpgradeStatus` is rendered to the user and must carry `display_version` instead.
3158        let (mut server, client) = mock_client().await;
3159        let _batch = server
3160            .mock("POST", "/v1/querybatch")
3161            .with_status(200)
3162            .with_body(r#"{"results":[{}]}"#)
3163            .create_async()
3164            .await;
3165
3166        let candidate = ScanTarget::new(
3167            crate::test_util::vuln_key("golang.org/x/text"),
3168            OsvQueryName::Confirmed(OsvPackageName::new("golang.org/x/text").unwrap()),
3169            OsvVersion::new("0.4.0"),
3170            ConcreteVersion::new("v0.4.0"),
3171        );
3172        let statuses = client
3173            .check_candidates(EcosystemId::Go, &[candidate], TEST_TIMEOUT)
3174            .await;
3175
3176        assert_matches!(
3177            statuses.get(&crate::test_util::vuln_key("golang.org/x/text")),
3178            Some(UpgradeStatus::CandidateClean { version }) if version == "v0.4.0"
3179        );
3180    }
3181
3182    #[test]
3183    fn query_cache_evicts_oldest_when_max_entries_reached() {
3184        let client = client();
3185        for i in 0..MAX_CACHE_ENTRIES {
3186            client.store_query_cache(
3187                OsvEcosystem::Npm,
3188                &target(&format!("pkg-{i}"), "1.0.0"),
3189                &[],
3190            );
3191        }
3192        assert_eq!(client.query_cache.len(), MAX_CACHE_ENTRIES);
3193
3194        client.store_query_cache(OsvEcosystem::Npm, &target("overflow", "1.0.0"), &[]);
3195
3196        assert!(
3197            client.query_cache.len() <= MAX_CACHE_ENTRIES,
3198            "query_cache must stay bounded at MAX_CACHE_ENTRIES, got {}",
3199            client.query_cache.len()
3200        );
3201        assert!(client.query_cache.len() < MAX_CACHE_ENTRIES + 1);
3202    }
3203
3204    #[test]
3205    fn record_cache_evicts_oldest_when_max_entries_reached() {
3206        let client = client();
3207        for i in 0..MAX_CACHE_ENTRIES {
3208            let advisory = Arc::new(
3209                Advisory::new(
3210                    format!("ADV-{i}"),
3211                    "2023-01-01T00:00:00Z".to_string(),
3212                    VulnSeverity::Unknown,
3213                )
3214                .expect("valid osv id"),
3215            );
3216            client.store_record_cache(&advisory);
3217        }
3218        assert_eq!(client.record_cache.len(), MAX_CACHE_ENTRIES);
3219
3220        let overflow = Arc::new(
3221            Advisory::new(
3222                "ADV-overflow".to_string(),
3223                "2023-01-01T00:00:00Z".to_string(),
3224                VulnSeverity::Unknown,
3225            )
3226            .expect("valid osv id"),
3227        );
3228        client.store_record_cache(&overflow);
3229
3230        assert!(
3231            client.record_cache.len() <= MAX_CACHE_ENTRIES,
3232            "record_cache must stay bounded at MAX_CACHE_ENTRIES, got {}",
3233            client.record_cache.len()
3234        );
3235    }
3236
3237    #[tokio::test]
3238    async fn query_cache_ttl_expiry_forces_requery() {
3239        let (mut server, client) = mock_client().await;
3240        let t = target("pkg", "1.0.0");
3241
3242        client.query_cache.insert(
3243            QueryCacheKey::for_target(OsvEcosystem::Npm, &t),
3244            QueryCacheEntry {
3245                vuln_ids: vec![],
3246                fetched_at: Instant::now()
3247                    .checked_sub(QUERY_CACHE_TTL + Duration::from_secs(1))
3248                    .expect("test clock has more than QUERY_CACHE_TTL of headroom"),
3249            },
3250        );
3251
3252        let batch = server
3253            .mock("POST", "/v1/querybatch")
3254            .with_status(200)
3255            .with_body(r#"{"results":[{}]}"#)
3256            .expect(1)
3257            .create_async()
3258            .await;
3259
3260        client.scan(EcosystemId::Npm, &[t], TEST_TIMEOUT).await;
3261
3262        batch.assert_async().await;
3263    }
3264
3265    #[tokio::test]
3266    async fn record_cache_newer_modified_invalidates_and_refetches() {
3267        let (mut server, client) = mock_client().await;
3268
3269        client.record_cache.insert(
3270            "ADV-1".to_string(),
3271            RecordCacheEntry {
3272                advisory: Arc::new(
3273                    Advisory::new(
3274                        "ADV-1".to_string(),
3275                        "2020-01-01T00:00:00Z".to_string(),
3276                        VulnSeverity::Unknown,
3277                    )
3278                    .expect("valid osv id")
3279                    .with_summary("stale summary".to_string()),
3280                ),
3281                modified: "2020-01-01T00:00:00Z".to_string(),
3282                fetched_at: Instant::now(),
3283            },
3284        );
3285
3286        let _batch = server
3287            .mock("POST", "/v1/querybatch")
3288            .with_status(200)
3289            .with_body(
3290                r#"{"results":[{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
3291            )
3292            .create_async()
3293            .await;
3294        let record = server
3295            .mock("GET", "/v1/vulns/ADV-1")
3296            .with_status(200)
3297            .with_body(
3298                r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z","summary":"updated summary"}"#,
3299            )
3300            .expect(1)
3301            .create_async()
3302            .await;
3303
3304        let targets = vec![target("pkg", "1.0.0")];
3305        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
3306
3307        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get(&crate::test_util::vuln_key("pkg"))
3308        else {
3309            panic!("expected Vulnerable outcome");
3310        };
3311        assert_eq!(
3312            dv.advisories.items()[0].summary.as_deref(),
3313            Some("updated summary")
3314        );
3315        record.assert_async().await;
3316    }
3317
3318    /// #1682: an unversioned GHA result set is shared across versions of one action, and the
3319    /// raw record is cached by id, so neither another version nor a cleared query cache
3320    /// refetches `/v1/vulns/{id}`.
3321    #[tokio::test]
3322    async fn gha_unversioned_records_are_fetched_once_across_versions() {
3323        let (mut server, client) = mock_client().await;
3324        let batch = server
3325            .mock("POST", "/v1/querybatch")
3326            .with_status(200)
3327            .with_body(GHA_UNVERSIONED_BODY)
3328            .expect(2)
3329            .create_async()
3330            .await;
3331        let record = server
3332            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
3333            .with_status(200)
3334            .with_body(GHA_RECORD_BODY)
3335            .expect(1)
3336            .create_async()
3337            .await;
3338        let key = crate::test_util::vuln_key("actions/download-artifact");
3339        let scan = |version: &'static str| {
3340            let client = &client;
3341            let key = key.clone();
3342            async move {
3343                client
3344                    .scan(
3345                        EcosystemId::GithubActions,
3346                        &[target("actions/download-artifact", version)],
3347                        TEST_TIMEOUT,
3348                    )
3349                    .await
3350                    .remove(&key)
3351                    .expect("one outcome per target")
3352            }
3353        };
3354
3355        assert_matches!(scan("4.1.2").await, ScanOutcome::Vulnerable(_));
3356        assert_matches!(scan("4.1.3").await, ScanOutcome::Clean);
3357        client.query_cache.clear();
3358        assert_matches!(scan("4.0.5").await, ScanOutcome::Vulnerable(_));
3359
3360        batch.assert_async().await;
3361        record.assert_async().await;
3362    }
3363
3364    #[tokio::test]
3365    async fn gha_failed_record_fetch_is_not_cached() {
3366        let (mut server, client) = mock_client().await;
3367        let _batch = server
3368            .mock("POST", "/v1/querybatch")
3369            .with_status(200)
3370            .with_body(GHA_UNVERSIONED_BODY)
3371            .create_async()
3372            .await;
3373        let failing = server
3374            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
3375            .with_status(500)
3376            .expect_at_least(1)
3377            .create_async()
3378            .await;
3379        let targets = [target("actions/download-artifact", "4.1.2")];
3380        let key = crate::test_util::vuln_key("actions/download-artifact");
3381
3382        let outcomes = client
3383            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
3384            .await;
3385        assert_matches!(
3386            outcomes.get(&key),
3387            Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
3388        );
3389        failing.remove_async().await;
3390
3391        let _ok = server
3392            .mock("GET", "/v1/vulns/GHSA-cxww-7g56-2vh6")
3393            .with_status(200)
3394            .with_body(GHA_RECORD_BODY)
3395            .expect(1)
3396            .create_async()
3397            .await;
3398        let outcomes = client
3399            .scan(EcosystemId::GithubActions, &targets, TEST_TIMEOUT)
3400            .await;
3401        assert_matches!(outcomes.get(&key), Some(ScanOutcome::Vulnerable(_)));
3402    }
3403
3404    /// #1682: records of different dependencies are fetched concurrently, not one dependency
3405    /// at a time.
3406    #[tokio::test]
3407    async fn resolve_local_stubs_fetches_records_across_dependencies_concurrently() {
3408        let (addr, _in_flight, watermark, total) =
3409            spawn_mock_vuln_server(Duration::from_millis(100)).await;
3410        let client = OsvClient::with_base_url(Arc::new(HttpCache::new()), format!("http://{addr}"));
3411        let pending: Vec<_> = (0..25)
3412            .map(|i| {
3413                (
3414                    target(&format!("actions/a{i}"), "1.0.0"),
3415                    vec![(format!("ADV-{i}"), "2023-01-01T00:00:00Z".to_string())],
3416                )
3417            })
3418            .collect();
3419        let mut outcomes = VulnerabilityMap::new();
3420
3421        client
3422            .resolve_local_stubs(
3423                OsvEcosystem::GitHubActions,
3424                pending,
3425                &mut outcomes,
3426                Instant::now() + TEST_TIMEOUT,
3427            )
3428            .await;
3429
3430        assert_eq!(outcomes.len(), 25);
3431        assert_eq!(total.load(Ordering::SeqCst), 25);
3432        let peak = watermark.load(Ordering::SeqCst);
3433        assert!(
3434            peak > 1,
3435            "records of separate dependencies must be in flight together"
3436        );
3437        assert!(
3438            peak <= RECORD_FETCH_CONCURRENCY,
3439            "peak in-flight ({peak}) must stay within the client-wide bound"
3440        );
3441    }
3442
3443    /// #1682: a stub whose `modified` differs from the cached record's invalidates it.
3444    #[tokio::test]
3445    async fn raw_record_cache_refetches_when_stub_modified_changes() {
3446        let (mut server, client) = mock_client().await;
3447        let record = server
3448            .mock("GET", "/v1/vulns/ADV-1")
3449            .with_status(200)
3450            .with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
3451            .expect(2)
3452            .create_async()
3453            .await;
3454        let deadline = Instant::now() + TEST_TIMEOUT;
3455
3456        for modified in [
3457            "2023-01-01T00:00:00Z",
3458            "2023-01-01T00:00:00Z",
3459            "2024-01-01T00:00:00Z",
3460        ] {
3461            assert!(
3462                client
3463                    .fetch_raw_record("ADV-1", modified, deadline)
3464                    .await
3465                    .is_some()
3466            );
3467        }
3468
3469        record.assert_async().await;
3470    }
3471
3472    #[test]
3473    fn raw_record_cache_evicts_oldest_when_max_entries_reached() {
3474        let client = OsvClient::new(Arc::new(HttpCache::new()));
3475        for i in 0..=MAX_CACHE_ENTRIES {
3476            client.store_raw_record_cache(&Arc::new(OsvVulnRecord {
3477                id: format!("ADV-{i}"),
3478                ..OsvVulnRecord::default()
3479            }));
3480        }
3481        assert!(
3482            client.raw_record_cache.len() <= MAX_CACHE_ENTRIES,
3483            "raw_record_cache must stay bounded, got {}",
3484            client.raw_record_cache.len()
3485        );
3486    }
3487
3488    /// A minimal raw-TCP `GET /v1/vulns/{id}` responder for the concurrency tests below.
3489    ///
3490    /// `mockito` 1.7.2 has no delay/hold-open API, so it can't force concurrent requests to
3491    /// overlap deterministically — a real accept loop with an artificial per-request delay is
3492    /// the only way to make "N requests were in flight at once" or "only one request landed"
3493    /// assertions reliable rather than racy. Returns the listener address, the current-in-flight
3494    /// counter, the peak-in-flight watermark, and the total-accepted-connections counter.
3495    async fn spawn_mock_vuln_server(
3496        delay: Duration,
3497    ) -> (
3498        std::net::SocketAddr,
3499        Arc<AtomicUsize>,
3500        Arc<AtomicUsize>,
3501        Arc<AtomicUsize>,
3502    ) {
3503        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3504        use tokio::net::TcpListener;
3505
3506        let listener = TcpListener::bind("127.0.0.1:0")
3507            .await
3508            .expect("bind loopback listener");
3509        let addr = listener.local_addr().expect("listener has a local addr");
3510
3511        let in_flight = Arc::new(AtomicUsize::new(0));
3512        let watermark = Arc::new(AtomicUsize::new(0));
3513        let total = Arc::new(AtomicUsize::new(0));
3514
3515        let (in_flight2, watermark2, total2) = (
3516            Arc::clone(&in_flight),
3517            Arc::clone(&watermark),
3518            Arc::clone(&total),
3519        );
3520        tokio::spawn(async move {
3521            loop {
3522                let Ok((mut stream, _)) = listener.accept().await else {
3523                    break;
3524                };
3525                let (in_flight, watermark, total) = (
3526                    Arc::clone(&in_flight2),
3527                    Arc::clone(&watermark2),
3528                    Arc::clone(&total2),
3529                );
3530                tokio::spawn(async move {
3531                    let mut buf = [0_u8; 4096];
3532                    let n = stream.read(&mut buf).await.unwrap_or(0);
3533                    let request = String::from_utf8_lossy(&buf[..n]);
3534                    let id = request
3535                        .lines()
3536                        .next()
3537                        .and_then(|line| line.split_whitespace().nth(1))
3538                        .and_then(|path| path.rsplit('/').next())
3539                        .unwrap_or("unknown")
3540                        .to_string();
3541
3542                    let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
3543                    watermark.fetch_max(now, Ordering::SeqCst);
3544                    total.fetch_add(1, Ordering::SeqCst);
3545
3546                    tokio::time::sleep(delay).await;
3547
3548                    let body = format!(r#"{{"id":"{id}","modified":"2023-01-01T00:00:00Z"}}"#);
3549                    let response = format!(
3550                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3551                        body.len()
3552                    );
3553                    let _ = stream.write_all(response.as_bytes()).await;
3554                    let _ = stream.shutdown().await;
3555
3556                    in_flight.fetch_sub(1, Ordering::SeqCst);
3557                });
3558            }
3559        });
3560
3561        (addr, in_flight, watermark, total)
3562    }
3563
3564    /// Issue #1535: `RECORD_FETCH_CONCURRENCY` must bound `fetch_record_single_flight`'s
3565    /// underlying HTTP fetches *client-wide*, not reset to a fresh window per call — a
3566    /// regression here (e.g. a semaphore constructed per-call instead of stored on `OsvClient`)
3567    /// would still pass every other test in this module, since none of them exercise
3568    /// cross-call concurrency.
3569    #[tokio::test]
3570    async fn record_fetch_semaphore_bounds_client_wide_concurrent_fetches() {
3571        let (addr, _in_flight, watermark, total) =
3572            spawn_mock_vuln_server(Duration::from_millis(50)).await;
3573        let client = Arc::new(OsvClient::with_base_url(
3574            Arc::new(HttpCache::new()),
3575            format!("http://{addr}"),
3576        ));
3577
3578        let deadline = Instant::now() + Duration::from_secs(30);
3579        let handles: Vec<_> = (0..20)
3580            .map(|i| {
3581                let client = Arc::clone(&client);
3582                tokio::spawn(async move {
3583                    client
3584                        .fetch_record_single_flight(&format!("ADVISORY-{i}"), deadline)
3585                        .await
3586                })
3587            })
3588            .collect();
3589
3590        for handle in handles {
3591            assert!(handle.await.expect("task did not panic").is_some());
3592        }
3593
3594        let peak = watermark.load(Ordering::SeqCst);
3595        assert!(
3596            peak <= RECORD_FETCH_CONCURRENCY,
3597            "peak concurrent in-flight fetches ({peak}) exceeded the client-wide bound \
3598             ({RECORD_FETCH_CONCURRENCY})"
3599        );
3600        assert_eq!(
3601            total.load(Ordering::SeqCst),
3602            20,
3603            "all 20 distinct-id fetches must still land, just not all at once"
3604        );
3605    }
3606
3607    /// Issue #1535: concurrent fetches of the *same* advisory id must coalesce into one
3608    /// in-flight `GET /v1/vulns/{id}` via `record_in_flight`'s single-flight `OnceCell`, not
3609    /// fire one request per caller.
3610    #[tokio::test]
3611    async fn fetch_record_single_flight_coalesces_concurrent_same_id_requests() {
3612        let (addr, _in_flight, _watermark, total) =
3613            spawn_mock_vuln_server(Duration::from_millis(50)).await;
3614        let client = Arc::new(OsvClient::with_base_url(
3615            Arc::new(HttpCache::new()),
3616            format!("http://{addr}"),
3617        ));
3618
3619        let deadline = Instant::now() + Duration::from_secs(30);
3620        let handles: Vec<_> = (0..20)
3621            .map(|_| {
3622                let client = Arc::clone(&client);
3623                tokio::spawn(
3624                    async move { client.fetch_record_single_flight("SAME-ID", deadline).await },
3625                )
3626            })
3627            .collect();
3628
3629        for handle in handles {
3630            let record = handle.await.expect("task did not panic");
3631            assert_eq!(
3632                record.map(|r| r.id.clone()),
3633                Some("SAME-ID".to_string()),
3634                "every follower must observe the leader's real fetched value"
3635            );
3636        }
3637
3638        assert_eq!(
3639            total.load(Ordering::SeqCst),
3640            1,
3641            "20 concurrent fetches of the same id must land exactly one HTTP request"
3642        );
3643    }
3644
3645    /// Issue #1539: the permit wait itself must be bounded by the scan deadline, not only the
3646    /// per-chunk/per-requery checks around it — otherwise a scan can overrun its timeout window
3647    /// sitting in the semaphore queue under heavy client-wide load. No mock server is needed:
3648    /// the semaphore is saturated so the deadline must expire before any HTTP fetch is even
3649    /// attempted. The whole call is wrapped in an outer test timeout (critique M2) so a
3650    /// regression that reintroduces an unbounded wait fails this test rather than hanging CI.
3651    #[tokio::test]
3652    async fn fetch_record_single_flight_fails_fast_when_permit_wait_exceeds_deadline() {
3653        let client = client();
3654
3655        let _permits: Vec<_> = futures::future::join_all(
3656            (0..RECORD_FETCH_CONCURRENCY)
3657                .map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
3658        )
3659        .await
3660        .into_iter()
3661        .map(|permit| permit.expect("semaphore is not closed"))
3662        .collect();
3663
3664        let deadline = Instant::now() + Duration::from_millis(50);
3665        let result = tokio::time::timeout(
3666            Duration::from_secs(5),
3667            client.fetch_record_single_flight("UNREACHABLE-ID", deadline),
3668        )
3669        .await
3670        .expect("the wait must fail at the deadline, not hang until a permit frees up");
3671
3672        assert!(
3673            result.is_none(),
3674            "a permit wait that outlives the deadline must fail closed, not eventually succeed"
3675        );
3676    }
3677
3678    /// Issue #1539 code-review finding: a *solo* caller (no other caller ever waits on the same
3679    /// advisory id) that times out must still evict its own `record_in_flight` entry — the
3680    /// finding-#1 fix (only evicting on `Ok`) otherwise leaves nobody to ever clean it up, leaking
3681    /// one entry per unique id that times out with no follower for the client's lifetime.
3682    #[tokio::test]
3683    async fn fetch_record_single_flight_solo_caller_timeout_does_not_leak_in_flight_entry() {
3684        let client = client();
3685
3686        let _permits: Vec<_> = futures::future::join_all(
3687            (0..RECORD_FETCH_CONCURRENCY)
3688                .map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
3689        )
3690        .await
3691        .into_iter()
3692        .map(|permit| permit.expect("semaphore is not closed"))
3693        .collect();
3694
3695        let deadline = Instant::now() + Duration::from_millis(50);
3696        let result = tokio::time::timeout(
3697            Duration::from_secs(5),
3698            client.fetch_record_single_flight("SOLO-ID", deadline),
3699        )
3700        .await
3701        .expect("a solo caller must fail at its own deadline, not hang");
3702
3703        assert!(
3704            result.is_none(),
3705            "a solo caller's own timeout must fail closed"
3706        );
3707        assert!(
3708            !client.record_in_flight.contains_key("SOLO-ID"),
3709            "a solo caller's timeout must evict its own record_in_flight entry, not leak it \
3710             for the rest of the client's lifetime"
3711        );
3712    }
3713
3714    /// Issue #1539 critique S1: `record_in_flight` is shared client-wide, so a caller joining a
3715    /// cell some other caller is already initializing must be bounded by *its own* deadline, not
3716    /// the initializer's. Caller A (near-expired deadline) starts initializing on a saturated
3717    /// semaphore; caller B (ample deadline) joins the same cell a moment later. Once A's timeout
3718    /// fires, `OnceCell::get_or_init`'s cancel-safety must let B take over its own initialization
3719    /// attempt with B's own deadline — proven here by releasing the permits only after A's
3720    /// deadline has elapsed but well before B's, so B can only succeed if it is not bound by A's
3721    /// (already-expired) deadline.
3722    #[tokio::test]
3723    async fn fetch_record_single_flight_bounds_each_caller_by_its_own_deadline() {
3724        let (addr, _in_flight, _watermark, _total) =
3725            spawn_mock_vuln_server(Duration::from_millis(10)).await;
3726        let client = Arc::new(OsvClient::with_base_url(
3727            Arc::new(HttpCache::new()),
3728            format!("http://{addr}"),
3729        ));
3730
3731        let permits: Vec<_> = futures::future::join_all(
3732            (0..RECORD_FETCH_CONCURRENCY)
3733                .map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
3734        )
3735        .await
3736        .into_iter()
3737        .map(|permit| permit.expect("semaphore is not closed"))
3738        .collect();
3739
3740        let short_deadline = Instant::now() + Duration::from_millis(50);
3741        let long_deadline = Instant::now() + Duration::from_secs(10);
3742
3743        let a = tokio::spawn({
3744            let client = Arc::clone(&client);
3745            async move {
3746                client
3747                    .fetch_record_single_flight("SHARED-ID", short_deadline)
3748                    .await
3749            }
3750        });
3751        // Give A time to become the cell's initializer (queued on the saturated semaphore)
3752        // before B joins the same in-flight cell.
3753        tokio::time::sleep(Duration::from_millis(5)).await;
3754        let b = tokio::spawn({
3755            let client = Arc::clone(&client);
3756            async move {
3757                client
3758                    .fetch_record_single_flight("SHARED-ID", long_deadline)
3759                    .await
3760            }
3761        });
3762
3763        // Release the permits only once A's own deadline has certainly elapsed — B must not
3764        // inherit A's timeout, so B can only observe `Some` if it re-initializes on its own.
3765        tokio::time::sleep(Duration::from_millis(100)).await;
3766        drop(permits);
3767
3768        let a_result = tokio::time::timeout(Duration::from_secs(5), a)
3769            .await
3770            .expect("A must fail at its own deadline, not hang")
3771            .expect("task did not panic");
3772        let b_result = tokio::time::timeout(Duration::from_secs(5), b)
3773            .await
3774            .expect("B must not be bounded by A's already-expired deadline")
3775            .expect("task did not panic");
3776
3777        assert!(
3778            a_result.is_none(),
3779            "A's own short deadline must fail its wait closed"
3780        );
3781        assert!(
3782            b_result.is_some(),
3783            "B must not be poisoned by A's timeout — it has to run its own initialization \
3784             attempt with its own, still-live deadline"
3785        );
3786    }
3787
3788    /// Issue #1539 critique finding #1: evicting `record_in_flight`'s entry unconditionally
3789    /// (including on a caller's own timeout) would remove the cell out from under a *different*
3790    /// caller still legitimately driving it to completion, breaking single-flight coalescing
3791    /// (#1535). Caller A (short deadline) starts initializing on a saturated semaphore; caller B
3792    /// (long deadline) joins the same cell and becomes the new leader once A's timeout fires and
3793    /// cancels A's attempt; caller C (long deadline) joins afterward, while B is still the active
3794    /// initializer. If A's own timeout wrongly evicted the shared cell, C would find no map entry
3795    /// and start a second, redundant `GET /v1/vulns/{id}` — asserted here via the mock server's
3796    /// request counter staying at exactly 1.
3797    #[tokio::test]
3798    async fn fetch_record_single_flight_third_caller_reuses_cell_after_first_callers_timeout() {
3799        let (addr, _in_flight, _watermark, total) =
3800            spawn_mock_vuln_server(Duration::from_millis(10)).await;
3801        let client = Arc::new(OsvClient::with_base_url(
3802            Arc::new(HttpCache::new()),
3803            format!("http://{addr}"),
3804        ));
3805
3806        let permits: Vec<_> = futures::future::join_all(
3807            (0..RECORD_FETCH_CONCURRENCY)
3808                .map(|_| Arc::clone(&client.record_fetch_semaphore).acquire_owned()),
3809        )
3810        .await
3811        .into_iter()
3812        .map(|permit| permit.expect("semaphore is not closed"))
3813        .collect();
3814
3815        let short_deadline = Instant::now() + Duration::from_millis(50);
3816        let long_deadline = Instant::now() + Duration::from_secs(10);
3817
3818        let a = tokio::spawn({
3819            let client = Arc::clone(&client);
3820            async move {
3821                client
3822                    .fetch_record_single_flight("SHARED-ID", short_deadline)
3823                    .await
3824            }
3825        });
3826        // Give A time to become the cell's initializer before B joins.
3827        tokio::time::sleep(Duration::from_millis(5)).await;
3828        let b = tokio::spawn({
3829            let client = Arc::clone(&client);
3830            async move {
3831                client
3832                    .fetch_record_single_flight("SHARED-ID", long_deadline)
3833                    .await
3834            }
3835        });
3836
3837        // Wait past A's deadline (so A has timed out and, pre-fix, would have evicted the cell)
3838        // before C joins — B must still be the one driving the cell to completion at this point,
3839        // since the semaphore stays saturated until well after this.
3840        tokio::time::sleep(Duration::from_millis(60)).await;
3841        let c = tokio::spawn({
3842            let client = Arc::clone(&client);
3843            async move {
3844                client
3845                    .fetch_record_single_flight("SHARED-ID", long_deadline)
3846                    .await
3847            }
3848        });
3849
3850        // Give C a moment to join the in-flight cell before releasing the permits B is waiting
3851        // on, then let B (and, transitively, C) complete.
3852        tokio::time::sleep(Duration::from_millis(50)).await;
3853        drop(permits);
3854
3855        let a_result = tokio::time::timeout(Duration::from_secs(5), a)
3856            .await
3857            .expect("A must fail at its own deadline, not hang")
3858            .expect("task did not panic");
3859        let b_result = tokio::time::timeout(Duration::from_secs(5), b)
3860            .await
3861            .expect("B must not hang")
3862            .expect("task did not panic");
3863        let c_result = tokio::time::timeout(Duration::from_secs(5), c)
3864            .await
3865            .expect("C must not hang")
3866            .expect("task did not panic");
3867
3868        assert!(a_result.is_none(), "A's own short deadline must fail");
3869        assert!(
3870            b_result.is_some(),
3871            "B must complete the fetch it is driving"
3872        );
3873        assert!(
3874            c_result.is_some(),
3875            "C must observe B's result by joining the same cell, not fail or hang"
3876        );
3877        assert_eq!(
3878            total.load(Ordering::SeqCst),
3879            1,
3880            "C must reuse B's in-flight cell rather than issuing a second, redundant fetch"
3881        );
3882    }
3883}