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 severity;
18mod types;
19
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23
24use dashmap::DashMap;
25
26pub use severity::to_diagnostic_severity as diagnostic_severity_for;
27pub use types::{
28    Advisory, Capped, DependencyVulnerabilities, FixRecommendation, ScanOutcome, ScanTarget,
29    SkipReason, UpgradeStatus, VulnSeverity, VulnerabilityMap, vulnerability_keys,
30};
31use types::{
32    OsvBatchRequest, OsvBatchResponse, OsvPackage, OsvQuery, OsvSingleQueryResponse, OsvVulnRecord,
33};
34
35use crate::cache::HttpCache;
36
37/// Advisories fetched (invariant 3) and rendered (§7) per dependency, plus a
38/// trailing "+N more advisories" entry when [`Capped::total`] exceeds this.
39pub const ADVISORY_DISPLAY_CAP: usize = 5;
40
41/// Query cache TTL (approved Q6).
42const QUERY_CACHE_TTL: Duration = Duration::from_hours(6);
43
44/// `/v1/querybatch` chunk size (FR-009).
45const BATCH_CHUNK_SIZE: usize = 1000;
46
47/// Bound on individually-requeried truncated entries per [`OsvClient::scan`]/
48/// [`OsvClient::check_candidates`] call (§8 invariant 2).
49const MAX_TRUNCATED_REQUERY_BUDGET: usize = 20;
50
51/// Bounded concurrency for the `/v1/vulns/{id}` fan-out (§8 invariant 3),
52/// mirroring the registry fetch fan-out's `buffer_unordered` usage.
53const RECORD_FETCH_CONCURRENCY: usize = 10;
54
55/// Entry-count bound shared by `query_cache` and `record_cache`.
56const MAX_CACHE_ENTRIES: usize = 10_000;
57
58/// Percentage of cache entries evicted when [`MAX_CACHE_ENTRIES`] is reached,
59/// mirroring [`crate::cache::HttpCache::evict_entries`].
60const CACHE_EVICTION_PERCENTAGE: usize = 10;
61
62const OSV_API_BASE: &str = "https://api.osv.dev";
63
64/// Compares two version-like strings by their leading numeric dot-segments,
65/// falling back to a lexicographic compare of any non-numeric remainder.
66///
67/// Used only to order [`Advisory::fixed_versions`] ascending — not a general
68/// semver comparator. Good enough for that purpose because the ordering only
69/// needs to pick out "the highest fixed version", and OSV's `fixed` events
70/// are plain dotted-numeric strings in every ecosystem this workspace scans.
71fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
72    fn segments(s: &str) -> Vec<u64> {
73        s.split('.')
74            .map(|part| {
75                part.chars()
76                    .take_while(|c| c.is_ascii_digit())
77                    .collect::<String>()
78                    .parse()
79                    .unwrap_or(0)
80            })
81            .collect()
82    }
83
84    let (sa, sb) = (segments(a), segments(b));
85    sa.cmp(&sb).then_with(|| a.cmp(b))
86}
87
88struct QueryCacheEntry {
89    vuln_ids: Vec<(String, String)>,
90    fetched_at: Instant,
91}
92
93struct RecordCacheEntry {
94    advisory: Arc<Advisory>,
95    modified: String,
96    fetched_at: Instant,
97}
98
99/// Evicts the oldest `1/CACHE_EVICTION_PERCENTAGE` of `map`'s entries,
100/// mirroring [`crate::cache::HttpCache::evict_entries`]'s oldest-first policy.
101fn evict_oldest<K, V>(map: &DashMap<K, V>, fetched_at: impl Fn(&V) -> Instant)
102where
103    K: Eq + std::hash::Hash + Clone + Ord,
104{
105    use std::cmp::Reverse;
106    use std::collections::BinaryHeap;
107
108    let target_removals = (MAX_CACHE_ENTRIES / CACHE_EVICTION_PERCENTAGE).max(1);
109    let mut oldest: BinaryHeap<Reverse<(Instant, K)>> = map
110        .iter()
111        .map(|entry| Reverse((fetched_at(entry.value()), entry.key().clone())))
112        .collect();
113
114    for _ in 0..target_removals {
115        let Some(Reverse((_, key))) = oldest.pop() else {
116            break;
117        };
118        map.remove(&key);
119    }
120}
121
122/// Batches dependency versions against OSV.dev and resolves matching
123/// advisories, with its own semantic cache layered on top of
124/// [`HttpCache`]'s transport (`post_json`/`get_cached`).
125///
126/// One instance is shared server-lifetime on `ServerState` in `deps-lsp`, so
127/// every open document's scan benefits from the same query/record cache.
128pub struct OsvClient {
129    cache: Arc<HttpCache>,
130    query_cache: DashMap<(&'static str, String, String), QueryCacheEntry>,
131    record_cache: DashMap<String, RecordCacheEntry>,
132    /// Overridable in test builds only, so `mockito` can stand in for
133    /// `https://api.osv.dev` — mirrors [`crate::cache::ensure_https`]'s existing
134    /// `#[cfg(test)]` relaxation for the same reason.
135    #[cfg(test)]
136    base_url: String,
137}
138
139impl OsvClient {
140    /// Creates a client that reuses `cache`'s HTTP transport (`Client`,
141    /// HTTPS enforcement, size cap, timeout) for both the batch POST and the
142    /// per-advisory GET.
143    #[must_use]
144    pub fn new(cache: Arc<HttpCache>) -> Self {
145        Self {
146            cache,
147            query_cache: DashMap::new(),
148            record_cache: DashMap::new(),
149            #[cfg(test)]
150            base_url: OSV_API_BASE.to_string(),
151        }
152    }
153
154    #[cfg(test)]
155    fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
156        Self {
157            cache,
158            query_cache: DashMap::new(),
159            record_cache: DashMap::new(),
160            base_url,
161        }
162    }
163
164    #[cfg(test)]
165    fn api_base(&self) -> &str {
166        &self.base_url
167    }
168
169    #[cfg(not(test))]
170    const fn api_base(&self) -> &str {
171        OSV_API_BASE
172    }
173
174    fn batch_url(&self) -> String {
175        format!("{}/v1/querybatch", self.api_base())
176    }
177
178    fn single_query_url(&self) -> String {
179        format!("{}/v1/query", self.api_base())
180    }
181
182    fn vuln_record_url(&self, id: &str) -> String {
183        format!("{}/v1/vulns/{id}", self.api_base())
184    }
185
186    /// Phase A: scans `deps` and returns the map consumed by the rendering
187    /// helpers.
188    ///
189    /// `timeout` bounds the *entire* scan (all chunks and any truncation
190    /// recovery), not any single request — the underlying `reqwest` client
191    /// already caps each individual request at 30s. The deadline is checked
192    /// between chunks/recovery items, not mid-request, so already-completed
193    /// work is never discarded on timeout: only whatever had not yet started
194    /// degrades to [`SkipReason::QueryFailed`]/[`SkipReason::Truncated`]
195    /// (critique S5).
196    ///
197    /// Never returns an error: every failure degrades to a
198    /// [`ScanOutcome::Skipped`] entry, never an absent one. Logs a
199    /// per-scan summary at `info` (§8 invariant 0).
200    pub async fn scan(
201        &self,
202        ecosystem: crate::EcosystemId,
203        deps: &[ScanTarget],
204        timeout: Duration,
205    ) -> VulnerabilityMap {
206        if deps.is_empty() {
207            return VulnerabilityMap::new();
208        }
209        let outcomes = self.resolve(ecosystem, deps, timeout).await;
210        log_scan_summary(&outcomes);
211        outcomes
212    }
213
214    /// Phase B: checks whether the versions about to be recommended (e.g.
215    /// "latest" from the registry) are themselves affected.
216    ///
217    /// Only meaningful for dependencies phase A already flagged — callers
218    /// should build `candidates` from that subset. `timeout` has the same
219    /// meaning as in [`Self::scan`].
220    pub async fn check_candidates(
221        &self,
222        ecosystem: crate::EcosystemId,
223        candidates: &[ScanTarget],
224        timeout: Duration,
225    ) -> HashMap<String, UpgradeStatus> {
226        if candidates.is_empty() {
227            return HashMap::new();
228        }
229
230        // Use `display_version`, not `version`: `version` is OSV's wire
231        // spelling (e.g. Go's `v`-prefix stripped), while the version
232        // surfaced back to the user via `UpgradeStatus` must stay in the
233        // ecosystem-native spelling (see `ScanTarget`'s doc).
234        let versions: HashMap<&str, &str> = candidates
235            .iter()
236            .map(|c| (c.key.as_str(), c.display_version.as_str()))
237            .collect();
238
239        let outcomes = self.resolve(ecosystem, candidates, timeout).await;
240
241        outcomes
242            .into_iter()
243            .filter_map(|(key, outcome)| {
244                let version = (*versions.get(key.as_str())?).to_string();
245                let status = match outcome {
246                    ScanOutcome::Clean => UpgradeStatus::CandidateClean { version },
247                    ScanOutcome::Vulnerable(dv) => UpgradeStatus::CandidateVulnerable {
248                        version,
249                        advisory_ids: Capped::new(
250                            dv.advisories.items().iter().map(|a| a.id.clone()).collect(),
251                            dv.advisories.total(),
252                        ),
253                    },
254                    ScanOutcome::Skipped(_) => return None,
255                };
256                Some((key, status))
257            })
258            .collect()
259    }
260
261    /// Shared resolution logic for [`Self::scan`] and [`Self::check_candidates`]:
262    /// cache lookup, chunked batch query (invariant 1), truncation recovery
263    /// (invariant 2), and bounded record fetch (invariant 3).
264    ///
265    /// `timeout` is enforced as a wall-clock deadline checked before each
266    /// chunk and before truncation recovery begins — never by wrapping the
267    /// whole future in `tokio::time::timeout`, which would drop
268    /// already-accumulated `outcomes` along with whatever was still running
269    /// (critique S5).
270    async fn resolve(
271        &self,
272        ecosystem: crate::EcosystemId,
273        targets: &[ScanTarget],
274        timeout: Duration,
275    ) -> HashMap<String, ScanOutcome> {
276        let deadline = Instant::now() + timeout;
277        let mut outcomes = HashMap::with_capacity(targets.len());
278
279        let Some(osv_eco) = ecosystem.osv_ecosystem() else {
280            for t in targets {
281                outcomes.insert(
282                    t.key.clone(),
283                    ScanOutcome::Skipped(SkipReason::UnmappableEcosystem),
284                );
285            }
286            return outcomes;
287        };
288
289        let mut to_query: Vec<ScanTarget> = Vec::new();
290        for t in targets {
291            let cache_key = (osv_eco, t.osv_name.clone(), t.version.clone());
292            let cached_ids = self.query_cache.get(&cache_key).and_then(|entry| {
293                (entry.fetched_at.elapsed() < QUERY_CACHE_TTL).then(|| entry.vuln_ids.clone())
294            });
295            if let Some(vuln_ids) = cached_ids {
296                outcomes.insert(
297                    t.key.clone(),
298                    self.build_outcome(osv_eco, &t.osv_name, &vuln_ids).await,
299                );
300            } else {
301                to_query.push(t.clone());
302            }
303        }
304
305        if to_query.is_empty() {
306            return outcomes;
307        }
308
309        let mut truncated: Vec<ScanTarget> = Vec::new();
310        let mut chunks = to_query.chunks(BATCH_CHUNK_SIZE);
311
312        while let Some(chunk) = chunks.next() {
313            if Instant::now() >= deadline {
314                tracing::warn!(
315                    remaining = chunk.len(),
316                    "OSV scan deadline exceeded, marking remaining chunks as query-failed"
317                );
318                mark_chunk_failed(chunk, &mut outcomes);
319                for remaining in chunks {
320                    mark_chunk_failed(remaining, &mut outcomes);
321                }
322                return outcomes;
323            }
324            self.resolve_chunk(osv_eco, chunk, &mut outcomes, &mut truncated)
325                .await;
326        }
327
328        if Instant::now() >= deadline {
329            tracing::warn!(
330                count = truncated.len(),
331                "OSV scan deadline exceeded before truncation recovery"
332            );
333            for target in &truncated {
334                outcomes.insert(
335                    target.key.clone(),
336                    ScanOutcome::Skipped(SkipReason::Truncated),
337                );
338            }
339            return outcomes;
340        }
341
342        self.recover_truncated(osv_eco, &truncated, &mut outcomes)
343            .await;
344
345        outcomes
346    }
347
348    /// Queries one batch chunk and populates `outcomes`/`truncated`.
349    ///
350    /// Owns `chunk` end-to-end and zips results only against it (never the
351    /// full document dependency list) — §8 invariant 1. On any failure
352    /// (network error, non-2xx, malformed JSON, or a result-count mismatch)
353    /// the *entire* chunk degrades to [`SkipReason::QueryFailed`] rather than
354    /// risking misattributing an advisory to the wrong dependency.
355    async fn resolve_chunk(
356        &self,
357        osv_eco: &'static str,
358        chunk: &[ScanTarget],
359        outcomes: &mut HashMap<String, ScanOutcome>,
360        truncated: &mut Vec<ScanTarget>,
361    ) {
362        let queries: Vec<OsvQuery> = chunk
363            .iter()
364            .map(|t| OsvQuery {
365                package: OsvPackage {
366                    name: t.osv_name.clone(),
367                    ecosystem: osv_eco.to_string(),
368                },
369                version: t.version.clone(),
370            })
371            .collect();
372
373        let body = OsvBatchRequest { queries };
374        let response_bytes = match self.cache.post_json(&self.batch_url(), &body).await {
375            Ok(b) => b,
376            Err(e) => {
377                tracing::warn!(error = %e, "OSV batch query failed");
378                mark_chunk_failed(chunk, outcomes);
379                return;
380            }
381        };
382
383        let parsed: OsvBatchResponse = match crate::parser::parse_json_checked(&response_bytes) {
384            Ok(p) => p,
385            Err(e) => {
386                tracing::warn!(error = %e, "failed to parse OSV batch response");
387                mark_chunk_failed(chunk, outcomes);
388                return;
389            }
390        };
391
392        if parsed.results.len() != chunk.len() {
393            tracing::warn!(
394                expected = chunk.len(),
395                got = parsed.results.len(),
396                "OSV batch result count mismatch, dropping chunk"
397            );
398            mark_chunk_failed(chunk, outcomes);
399            return;
400        }
401
402        for (target, result) in chunk.iter().zip(parsed.results) {
403            if result.next_page_token.is_some() {
404                truncated.push(target.clone());
405                continue;
406            }
407            let vuln_ids: Vec<(String, String)> = result
408                .vulns
409                .into_iter()
410                .map(|v| (v.id, v.modified))
411                .collect();
412            self.store_query_cache(osv_eco, target, &vuln_ids);
413            outcomes.insert(
414                target.key.clone(),
415                self.build_outcome(osv_eco, &target.osv_name, &vuln_ids)
416                    .await,
417            );
418        }
419    }
420
421    /// Recovers batch-truncated entries via individual `POST /v1/query`
422    /// calls (§8 invariant 2), bounded by [`MAX_TRUNCATED_REQUERY_BUDGET`]
423    /// and run concurrently (mirroring the registry fan-out, critique M3).
424    /// Entries beyond the budget become [`SkipReason::Truncated`] rather than
425    /// ever rendering as zero advisories.
426    async fn recover_truncated(
427        &self,
428        osv_eco: &'static str,
429        truncated: &[ScanTarget],
430        outcomes: &mut HashMap<String, ScanOutcome>,
431    ) {
432        use futures::stream::{self, StreamExt};
433
434        let budget = MAX_TRUNCATED_REQUERY_BUDGET.min(truncated.len());
435        let (to_recover, exhausted) = truncated.split_at(budget);
436
437        // Cloned (not borrowed) targets: a closure borrowing both `self` and
438        // an element from `truncated`'s slice inside `stream::map` triggers
439        // a higher-ranked-lifetime inference failure ("implementation of
440        // `FnOnce` is not general enough") once this future is nested inside
441        // an outer `tokio::spawn`, as `fetch_records` already learned to
442        // avoid via `.cloned()`.
443        let recovered: Vec<(String, ScanOutcome)> = stream::iter(to_recover.iter().cloned())
444            .map(|target| async move {
445                let outcome = match self.query_single(osv_eco, &target).await {
446                    // `/v1/query` can itself paginate — never trust its
447                    // `vulns.len()` as complete when it says there is more
448                    // (critique S4).
449                    Some(resp) if resp.next_page_token.is_some() => {
450                        tracing::warn!(
451                            dep = %target.key,
452                            "OSV single-package requery itself paginated; treating as still truncated"
453                        );
454                        ScanOutcome::Skipped(SkipReason::Truncated)
455                    }
456                    Some(resp) => self.outcome_from_full_records(osv_eco, &target, resp.vulns),
457                    None => ScanOutcome::Skipped(SkipReason::QueryFailed),
458                };
459                (target.key.clone(), outcome)
460            })
461            .buffer_unordered(RECORD_FETCH_CONCURRENCY)
462            .collect()
463            .await;
464
465        for (key, outcome) in recovered {
466            outcomes.insert(key, outcome);
467        }
468
469        for target in exhausted {
470            outcomes.insert(
471                target.key.clone(),
472                ScanOutcome::Skipped(SkipReason::Truncated),
473            );
474        }
475    }
476
477    /// Converts full advisory records recovered via `/v1/query` directly
478    /// into a [`ScanOutcome`], populating the record cache along the way —
479    /// no follow-up `GET /v1/vulns/{id}` is needed for these (§8 invariant 2).
480    /// A record whose id fails [`types::OsvVulnRecord::into_advisory`]'s
481    /// validation is dropped, not counted toward `advisories`/the cache, but
482    /// [`Capped::total`] still reflects OSV's reported count (critique M1).
483    fn outcome_from_full_records(
484        &self,
485        osv_eco: &'static str,
486        target: &ScanTarget,
487        records: Vec<OsvVulnRecord>,
488    ) -> ScanOutcome {
489        let total = records.len();
490        let mut advisories = Vec::with_capacity(total.min(ADVISORY_DISPLAY_CAP));
491        let mut vuln_ids = Vec::with_capacity(total);
492
493        for record in records {
494            let Some(advisory) = record.into_advisory(&target.osv_name, osv_eco) else {
495                continue;
496            };
497            let advisory = Arc::new(advisory);
498            vuln_ids.push((advisory.id.clone(), advisory.modified.clone()));
499            self.store_record_cache(&advisory);
500            if advisories.len() < ADVISORY_DISPLAY_CAP {
501                advisories.push(advisory);
502            }
503        }
504
505        self.store_query_cache(osv_eco, target, &vuln_ids);
506
507        if total == 0 {
508            ScanOutcome::Clean
509        } else {
510            ScanOutcome::Vulnerable(DependencyVulnerabilities {
511                advisories: Capped::new(advisories, total),
512                fix_target_status: UpgradeStatus::NotChecked,
513                upgrade_status: UpgradeStatus::NotChecked,
514            })
515        }
516    }
517
518    /// Builds a [`ScanOutcome`] from a list of `(id, modified)` stubs,
519    /// fetching up to [`ADVISORY_DISPLAY_CAP`] full records.
520    async fn build_outcome(
521        &self,
522        osv_eco: &str,
523        osv_name: &str,
524        vuln_ids: &[(String, String)],
525    ) -> ScanOutcome {
526        if vuln_ids.is_empty() {
527            return ScanOutcome::Clean;
528        }
529
530        let to_fetch = &vuln_ids[..vuln_ids.len().min(ADVISORY_DISPLAY_CAP)];
531        let advisories = self.fetch_records(osv_eco, osv_name, to_fetch).await;
532
533        ScanOutcome::Vulnerable(DependencyVulnerabilities {
534            advisories: Capped::new(advisories, vuln_ids.len()),
535            fix_target_status: UpgradeStatus::NotChecked,
536            upgrade_status: UpgradeStatus::NotChecked,
537        })
538    }
539
540    /// Fetches full advisory records for `ids`, checking the record cache
541    /// first and bounding fetch concurrency (§8 invariant 3). A record that
542    /// fails to fetch, parse, or validate (malformed id, or no matching
543    /// `affected[].package` — critique S3/M1) is dropped, not substituted
544    /// with a half-populated placeholder.
545    async fn fetch_records(
546        &self,
547        osv_eco: &str,
548        osv_name: &str,
549        ids: &[(String, String)],
550    ) -> Vec<Arc<Advisory>> {
551        use futures::stream::{self, StreamExt};
552
553        stream::iter(ids.iter().cloned())
554            .map(|(id, modified)| async move {
555                let cached = self.record_cache.get(&id).and_then(|entry| {
556                    (entry.modified == modified).then(|| Arc::clone(&entry.advisory))
557                });
558                if let Some(advisory) = cached {
559                    return Some(advisory);
560                }
561
562                let record = self.fetch_single_record(&id).await?;
563                let advisory = Arc::new(record.into_advisory(osv_name, osv_eco)?);
564                self.store_record_cache(&advisory);
565                Some(advisory)
566            })
567            .buffer_unordered(RECORD_FETCH_CONCURRENCY)
568            .collect::<Vec<_>>()
569            .await
570            .into_iter()
571            .flatten()
572            .collect()
573    }
574
575    /// Fetches a single advisory record. Uses [`HttpCache::get_transport_only`]
576    /// rather than [`HttpCache::get_cached`] deliberately: this client's own
577    /// `record_cache` (validated by `modified`, not `ETag`) is the real
578    /// cache for these bodies, so also caching them in `HttpCache`'s
579    /// entry map would double-cache every fetched record there, competing
580    /// with registry responses for its byte budget for no benefit (critique
581    /// M2 — nothing ever reads that copy back).
582    async fn fetch_single_record(&self, id: &str) -> Option<OsvVulnRecord> {
583        let url = self.vuln_record_url(id);
584        match self.cache.get_transport_only(&url).await {
585            Ok(bytes) => match crate::parser::parse_json_checked::<OsvVulnRecord>(&bytes) {
586                Ok(record) => Some(record),
587                Err(e) => {
588                    tracing::warn!(id, error = %e, "failed to parse OSV vulnerability record");
589                    None
590                }
591            },
592            Err(e) => {
593                tracing::warn!(id, error = %e, "failed to fetch OSV vulnerability record");
594                None
595            }
596        }
597    }
598
599    async fn query_single(
600        &self,
601        osv_eco: &'static str,
602        target: &ScanTarget,
603    ) -> Option<OsvSingleQueryResponse> {
604        let body = OsvQuery {
605            package: OsvPackage {
606                name: target.osv_name.clone(),
607                ecosystem: osv_eco.to_string(),
608            },
609            version: target.version.clone(),
610        };
611
612        let bytes = match self.cache.post_json(&self.single_query_url(), &body).await {
613            Ok(b) => b,
614            Err(e) => {
615                tracing::warn!(dep = %target.key, error = %e, "OSV single-package requery failed");
616                return None;
617            }
618        };
619
620        match crate::parser::parse_json_checked::<OsvSingleQueryResponse>(&bytes) {
621            Ok(resp) => Some(resp),
622            Err(e) => {
623                tracing::warn!(dep = %target.key, error = %e, "failed to parse OSV single-package response");
624                None
625            }
626        }
627    }
628
629    fn store_query_cache(
630        &self,
631        osv_eco: &'static str,
632        target: &ScanTarget,
633        vuln_ids: &[(String, String)],
634    ) {
635        if self.query_cache.len() >= MAX_CACHE_ENTRIES {
636            evict_oldest(&self.query_cache, |e| e.fetched_at);
637        }
638        self.query_cache.insert(
639            (osv_eco, target.osv_name.clone(), target.version.clone()),
640            QueryCacheEntry {
641                vuln_ids: vuln_ids.to_vec(),
642                fetched_at: Instant::now(),
643            },
644        );
645    }
646
647    fn store_record_cache(&self, advisory: &Arc<Advisory>) {
648        if self.record_cache.len() >= MAX_CACHE_ENTRIES {
649            evict_oldest(&self.record_cache, |e| e.fetched_at);
650        }
651        self.record_cache.insert(
652            advisory.id.clone(),
653            RecordCacheEntry {
654                advisory: Arc::clone(advisory),
655                modified: advisory.modified.clone(),
656                fetched_at: Instant::now(),
657            },
658        );
659    }
660}
661
662/// Marks every dependency in a failed chunk as [`SkipReason::QueryFailed`].
663fn mark_chunk_failed(chunk: &[ScanTarget], outcomes: &mut HashMap<String, ScanOutcome>) {
664    for t in chunk {
665        outcomes.insert(t.key.clone(), ScanOutcome::Skipped(SkipReason::QueryFailed));
666    }
667}
668
669/// Logs the `info`-level scan summary mandated by §8 invariant 0.
670fn log_scan_summary(outcomes: &HashMap<String, ScanOutcome>) {
671    let mut clean = 0usize;
672    let mut vulnerable = 0usize;
673    let mut skip_counts: HashMap<&'static str, usize> = HashMap::new();
674
675    for outcome in outcomes.values() {
676        match outcome {
677            ScanOutcome::Clean => clean += 1,
678            ScanOutcome::Vulnerable(_) => vulnerable += 1,
679            ScanOutcome::Skipped(reason) => {
680                *skip_counts.entry(reason.as_str()).or_insert(0) += 1;
681            }
682        }
683    }
684
685    let skipped: usize = skip_counts.values().sum();
686    let reasons = skip_counts
687        .iter()
688        .map(|(reason, count)| format!("{count} {reason}"))
689        .collect::<Vec<_>>()
690        .join(", ");
691
692    tracing::info!(
693        "OSV: scanned {}, clean {clean}, vulnerable {vulnerable}, skipped {skipped}{}",
694        outcomes.len(),
695        if reasons.is_empty() {
696            String::new()
697        } else {
698            format!(" ({reasons})")
699        }
700    );
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::EcosystemId;
707    use std::assert_matches;
708
709    fn client() -> OsvClient {
710        OsvClient::new(Arc::new(HttpCache::new()))
711    }
712
713    async fn mock_client() -> (mockito::ServerGuard, OsvClient) {
714        let server = mockito::Server::new_async().await;
715        let client = OsvClient::with_base_url(Arc::new(HttpCache::new()), server.url());
716        (server, client)
717    }
718
719    const TEST_TIMEOUT: Duration = Duration::from_secs(30);
720
721    fn target(name: &str, version: &str) -> ScanTarget {
722        ScanTarget {
723            key: name.to_string(),
724            osv_name: name.to_string(),
725            version: version.to_string(),
726            display_version: version.to_string(),
727        }
728    }
729
730    #[test]
731    fn compare_version_strings_orders_numerically() {
732        let mut versions = vec![
733            "0.2.10".to_string(),
734            "0.2.2".to_string(),
735            "0.2.23".to_string(),
736            "0.2.0".to_string(),
737        ];
738        versions.sort_by(|a, b| compare_version_strings(a, b));
739        assert_eq!(versions, vec!["0.2.0", "0.2.2", "0.2.10", "0.2.23"]);
740    }
741
742    #[tokio::test]
743    async fn scan_empty_input_returns_empty_map() {
744        let client = client();
745        let outcomes = client.scan(EcosystemId::Cargo, &[], TEST_TIMEOUT).await;
746        assert!(outcomes.is_empty());
747    }
748
749    #[tokio::test]
750    async fn check_candidates_empty_input_returns_empty_map() {
751        let client = client();
752        let statuses = client
753            .check_candidates(EcosystemId::Cargo, &[], TEST_TIMEOUT)
754            .await;
755        assert!(statuses.is_empty());
756    }
757
758    #[tokio::test]
759    async fn scan_all_clean_batch_result() {
760        let (mut server, client) = mock_client().await;
761        let _m = server
762            .mock("POST", "/v1/querybatch")
763            .with_status(200)
764            .with_body(r#"{"results":[{}]}"#)
765            .create_async()
766            .await;
767
768        let targets = vec![target("left-pad", "1.0.0")];
769        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
770
771        assert_eq!(outcomes.len(), 1);
772        assert_matches!(outcomes.get("left-pad"), Some(ScanOutcome::Clean));
773    }
774
775    #[tokio::test]
776    async fn scan_batch_http_400_skips_whole_chunk() {
777        let (mut server, client) = mock_client().await;
778        let _m = server
779            .mock("POST", "/v1/querybatch")
780            .with_status(400)
781            .with_body(r#"{"code":3,"message":"error in query at index 0"}"#)
782            .create_async()
783            .await;
784
785        let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
786        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
787
788        assert_eq!(outcomes.len(), 2);
789        for key in ["a", "b"] {
790            assert_matches!(
791                outcomes.get(key),
792                Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
793            );
794        }
795    }
796
797    #[tokio::test]
798    async fn scan_malformed_batch_json_skips_whole_chunk() {
799        let (mut server, client) = mock_client().await;
800        let _m = server
801            .mock("POST", "/v1/querybatch")
802            .with_status(200)
803            .with_body("not json")
804            .create_async()
805            .await;
806
807        let targets = vec![target("a", "1.0.0")];
808        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
809
810        assert_matches!(
811            outcomes.get("a"),
812            Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
813        );
814    }
815
816    #[tokio::test]
817    async fn scan_deeply_nested_batch_json_skips_whole_chunk() {
818        // #430 hardening: a `database_specific`/`ecosystem_specific`-shaped
819        // deeply nested array must be rejected by the depth guard before
820        // `serde_json::from_slice` ever sees it, degrading like any other
821        // malformed response — an earlier, cheaper rejection than
822        // `serde_json`'s own built-in recursion limit would give.
823        let (mut server, client) = mock_client().await;
824        let deeply_nested = format!(
825            "{}1{}",
826            "[".repeat(crate::parser::MAX_JSON_NESTING_DEPTH + 1),
827            "]".repeat(crate::parser::MAX_JSON_NESTING_DEPTH + 1)
828        );
829        let _m = server
830            .mock("POST", "/v1/querybatch")
831            .with_status(200)
832            .with_body(format!(
833                r#"{{"results":[{{"vulns":[{{"id":"ADV-1","modified":"2023-01-01T00:00:00Z","database_specific":{deeply_nested}}}]}}]}}"#
834            ))
835            .create_async()
836            .await;
837
838        let targets = vec![target("pkg", "1.0.0")];
839        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
840
841        assert_matches!(
842            outcomes.get("pkg"),
843            Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
844        );
845    }
846
847    #[tokio::test]
848    async fn scan_result_count_mismatch_drops_whole_chunk() {
849        let (mut server, client) = mock_client().await;
850        // Two queries sent, only one result returned.
851        let _m = server
852            .mock("POST", "/v1/querybatch")
853            .with_status(200)
854            .with_body(r#"{"results":[{}]}"#)
855            .create_async()
856            .await;
857
858        let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
859        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
860
861        for key in ["a", "b"] {
862            assert_matches!(
863                outcomes.get(key),
864                Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
865            );
866        }
867    }
868
869    #[tokio::test]
870    async fn scan_over_chunk_size_input_issues_exactly_two_batch_requests() {
871        use std::sync::atomic::{AtomicUsize, Ordering};
872
873        let (mut server, client) = mock_client().await;
874
875        let n = BATCH_CHUNK_SIZE + 1;
876        let targets: Vec<ScanTarget> = (0..n)
877            .map(|i| target(&format!("pkg-{i}"), "1.0.0"))
878            .collect();
879
880        let call_count = Arc::new(AtomicUsize::new(0));
881        let call_count_clone = Arc::clone(&call_count);
882
883        let batch = server
884            .mock("POST", "/v1/querybatch")
885            .with_status(200)
886            .with_body_from_request(move |req| {
887                call_count_clone.fetch_add(1, Ordering::SeqCst);
888                let body = req.body().expect("request body");
889                let parsed: serde_json::Value =
890                    serde_json::from_slice(body).expect("valid JSON request body");
891                let count = parsed["queries"].as_array().map_or(0, Vec::len);
892                let results = vec!["{}"; count].join(",");
893                format!(r#"{{"results":[{results}]}}"#).into_bytes()
894            })
895            .expect(2)
896            .create_async()
897            .await;
898
899        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
900
901        assert_eq!(outcomes.len(), n);
902        assert!(outcomes.values().all(|o| matches!(o, ScanOutcome::Clean)));
903        batch.assert_async().await;
904        assert_eq!(
905            call_count.load(Ordering::SeqCst),
906            2,
907            "a >1000-entry scan must issue exactly two chunked batch requests"
908        );
909    }
910
911    #[tokio::test]
912    async fn scan_deadline_exceeded_before_first_chunk_marks_everything_query_failed() {
913        let (_server, client) = mock_client().await;
914        // No mock registered at all: `client` still points at a live mockito
915        // server with no matching route, so a would-be request 404s — but
916        // the zero-duration deadline must make `resolve` bail before ever
917        // sending it, proving the deadline check runs before network I/O.
918        let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
919        let outcomes = client
920            .scan(EcosystemId::Npm, &targets, Duration::from_secs(0))
921            .await;
922
923        for key in ["a", "b"] {
924            assert_matches!(
925                outcomes.get(key),
926                Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
927            );
928        }
929    }
930
931    #[tokio::test]
932    async fn scan_filters_affected_entries_to_the_queried_package() {
933        // Critique S3: a record can cover several unrelated packages sharing
934        // one advisory id (e.g. log4j-core/log4j-api). Only the entry whose
935        // `package` matches the queried package must contribute
936        // fixed_versions/severity.
937        let (mut server, client) = mock_client().await;
938        let _batch = server
939            .mock("POST", "/v1/querybatch")
940            .with_status(200)
941            .with_body(
942                r#"{"results":[{"vulns":[{"id":"GHSA-cross-pkg","modified":"2023-01-01T00:00:00Z"}]}]}"#,
943            )
944            .create_async()
945            .await;
946        let _record = server
947            .mock("GET", "/v1/vulns/GHSA-cross-pkg")
948            .with_status(200)
949            .with_body(
950                r#"{"id":"GHSA-cross-pkg","modified":"2023-01-01T00:00:00Z",
951                   "affected":[
952                     {"package":{"name":"log4j-api","ecosystem":"Maven"},
953                      "ecosystem_specific":{"severity":"LOW"},
954                      "ranges":[{"events":[{"fixed":"1.0.0"}]}]},
955                     {"package":{"name":"log4j-core","ecosystem":"Maven"},
956                      "ecosystem_specific":{"severity":"CRITICAL"},
957                      "ranges":[{"events":[{"fixed":"2.17.1"}]}]}
958                   ]}"#,
959            )
960            .create_async()
961            .await;
962
963        let targets = vec![target("log4j-core", "2.14.1")];
964        let outcomes = client
965            .scan(EcosystemId::Maven, &targets, TEST_TIMEOUT)
966            .await;
967
968        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("log4j-core") else {
969            panic!("expected Vulnerable outcome");
970        };
971        // Must pick up log4j-core's own severity/fix, not log4j-api's.
972        assert_eq!(dv.advisories.items()[0].severity, VulnSeverity::Critical);
973        assert_eq!(
974            dv.advisories.items()[0].fixed_versions,
975            vec!["2.17.1".to_string()]
976        );
977    }
978
979    #[tokio::test]
980    async fn scan_malformed_advisory_id_is_dropped() {
981        // Critique M1: `id` is echoed into a markdown link destination and a
982        // Diagnostic.code; a malformed id must never survive into an
983        // `Advisory`.
984        let (mut server, client) = mock_client().await;
985        let _batch = server
986            .mock("POST", "/v1/querybatch")
987            .with_status(200)
988            .with_body(
989                r#"{"results":[{"vulns":[{"id":"evil](javascript:alert(1))","modified":"2023-01-01T00:00:00Z"}]}]}"#,
990            )
991            .create_async()
992            .await;
993
994        let targets = vec![target("pkg", "1.0.0")];
995        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
996
997        // total() still counts the batch stub; the malformed-id record
998        // is dropped rather than rendered with an unsafe id.
999        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("pkg") else {
1000            panic!("expected Vulnerable outcome, got {:?}", outcomes.get("pkg"));
1001        };
1002        assert_eq!(dv.advisories.total(), 1);
1003        assert!(dv.advisories.items().is_empty());
1004    }
1005
1006    #[tokio::test]
1007    async fn recover_truncated_single_query_that_itself_paginates_is_skipped_truncated() {
1008        // Critique S4: `/v1/query` can itself paginate; a `next_page_token`
1009        // on that response must never be trusted as a complete `vulns` list.
1010        let (mut server, client) = mock_client().await;
1011        let _batch = server
1012            .mock("POST", "/v1/querybatch")
1013            .with_status(200)
1014            .with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
1015            .create_async()
1016            .await;
1017        let _requery = server
1018            .mock("POST", "/v1/query")
1019            .with_status(200)
1020            .with_body(
1021                r#"{"vulns":[{"id":"GHSA-1","modified":"2023-01-01T00:00:00Z"}],"next_page_token":"still-more"}"#,
1022            )
1023            .create_async()
1024            .await;
1025
1026        let targets = vec![target("linux", "5.10.1")];
1027        let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
1028
1029        assert_matches!(
1030            outcomes.get("linux"),
1031            Some(ScanOutcome::Skipped(SkipReason::Truncated))
1032        );
1033    }
1034
1035    #[tokio::test]
1036    async fn scan_vulnerable_fetches_advisory_record() {
1037        let (mut server, client) = mock_client().await;
1038        let _batch = server
1039            .mock("POST", "/v1/querybatch")
1040            .with_status(200)
1041            .with_body(r#"{"results":[{"vulns":[{"id":"RUSTSEC-2020-0071","modified":"2023-01-01T00:00:00Z"}]}]}"#)
1042            .create_async()
1043            .await;
1044        // Real shape of RUSTSEC-2020-0071's `affected[].ranges` per
1045        // architecture.md §6: 8 `fixed` events spread across several
1046        // ranges (one per patched branch), deliberately out of order in the
1047        // JSON so a "take the last event, no sort" bug would still pass a
1048        // trivially-ordered 2-event fixture but fails this one. First `fixed`
1049        // in document order is `0.2.0`; the highest (the real guidance) is
1050        // `0.2.23`.
1051        let _record = server
1052            .mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
1053            .with_status(200)
1054            .with_body(
1055                r#"{"id":"RUSTSEC-2020-0071","modified":"2023-01-01T00:00:00Z",
1056                   "summary":"Potential segfault","database_specific":{"severity":"HIGH"},
1057                   "affected":[
1058                     {"package":{"name":"time","ecosystem":"crates.io"},"ranges":[
1059                       {"events":[{"introduced":"0"},{"fixed":"0.2.0"},{"fixed":"0.1.44"}]},
1060                       {"events":[{"introduced":"0"},{"fixed":"0.2.4"},{"fixed":"0.1.43"}]},
1061                       {"events":[{"introduced":"0"},{"fixed":"0.2.2"},{"fixed":"0.2.23"}]},
1062                       {"events":[{"introduced":"0"},{"fixed":"0.2.1"},{"fixed":"0.2.3"}]}
1063                     ]}
1064                   ]}"#,
1065            )
1066            .create_async()
1067            .await;
1068
1069        let targets = vec![target("time", "0.1.43")];
1070        let outcomes = client
1071            .scan(EcosystemId::Cargo, &targets, TEST_TIMEOUT)
1072            .await;
1073
1074        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("time") else {
1075            panic!(
1076                "expected Vulnerable outcome, got {:?}",
1077                outcomes.get("time")
1078            );
1079        };
1080        assert_eq!(dv.advisories.total(), 1);
1081        assert_eq!(dv.advisories.items().len(), 1);
1082        assert_eq!(dv.advisories.items()[0].id, "RUSTSEC-2020-0071");
1083        assert_eq!(dv.advisories.items()[0].severity, VulnSeverity::High);
1084        assert_eq!(
1085            dv.advisories.items()[0].fixed_versions,
1086            vec![
1087                "0.1.43", "0.1.44", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.23"
1088            ]
1089        );
1090        // The highest fixed version, not the first in document order.
1091        assert_eq!(
1092            dv.advisories.items()[0].fixed_versions.last(),
1093            Some(&"0.2.23".to_string())
1094        );
1095    }
1096
1097    #[tokio::test]
1098    async fn scan_dropped_advisory_record_still_yields_vulnerable_with_fewer_advisories() {
1099        let (mut server, client) = mock_client().await;
1100        let _batch = server
1101            .mock("POST", "/v1/querybatch")
1102            .with_status(200)
1103            .with_body(
1104                r#"{"results":[{"vulns":[{"id":"MISSING-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
1105            )
1106            .create_async()
1107            .await;
1108        let _record = server
1109            .mock("GET", "/v1/vulns/MISSING-1")
1110            .with_status(404)
1111            .create_async()
1112            .await;
1113
1114        let targets = vec![target("pkg", "1.0.0")];
1115        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1116
1117        // total() still reflects the batch stub count; the failed fetch
1118        // is dropped rather than rendered half-populated.
1119        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("pkg") else {
1120            panic!("expected Vulnerable outcome, got {:?}", outcomes.get("pkg"));
1121        };
1122        assert_eq!(dv.advisories.total(), 1);
1123        assert!(dv.advisories.items().is_empty());
1124    }
1125
1126    #[tokio::test]
1127    async fn scan_next_page_token_is_never_rendered_as_clean() {
1128        let (mut server, client) = mock_client().await;
1129        // No `vulns` key at all — only `next_page_token` — per the live-verified
1130        // truncation shape in architecture.md §8 invariant 2.
1131        let _batch = server
1132            .mock("POST", "/v1/querybatch")
1133            .with_status(200)
1134            .with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
1135            .create_async()
1136            .await;
1137        let _requery = server
1138            .mock("POST", "/v1/query")
1139            .with_status(200)
1140            .with_body(
1141                r#"{"vulns":[{"id":"GHSA-1","modified":"2023-01-01T00:00:00Z","database_specific":{"severity":"CRITICAL"}}]}"#,
1142            )
1143            .create_async()
1144            .await;
1145
1146        let targets = vec![target("linux", "5.10.1")];
1147        let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
1148
1149        let Some(outcome) = outcomes.get("linux") else {
1150            panic!("dependency missing from outcome map");
1151        };
1152        assert!(
1153            !matches!(outcome, ScanOutcome::Clean),
1154            "a truncated batch result must never render as clean"
1155        );
1156        assert_matches!(outcome, ScanOutcome::Vulnerable(_));
1157    }
1158
1159    #[tokio::test]
1160    async fn scan_advisory_fetch_is_capped_but_total_known_reflects_full_count() {
1161        let (mut server, client) = mock_client().await;
1162
1163        let vulns_json: String = (0..40)
1164            .map(|i| format!(r#"{{"id":"ADV-{i}","modified":"2023-01-01T00:00:00Z"}}"#))
1165            .collect::<Vec<_>>()
1166            .join(",");
1167        let _batch = server
1168            .mock("POST", "/v1/querybatch")
1169            .with_status(200)
1170            .with_body(format!(r#"{{"results":[{{"vulns":[{vulns_json}]}}]}}"#))
1171            .create_async()
1172            .await;
1173
1174        // Only expect fetches for however many the cap allows.
1175        let record = server
1176            .mock(
1177                "GET",
1178                mockito::Matcher::Regex(r"^/v1/vulns/ADV-\d+$".into()),
1179            )
1180            .with_status(200)
1181            .with_body(r#"{"id":"ADV-x","modified":"2023-01-01T00:00:00Z"}"#)
1182            .expect(ADVISORY_DISPLAY_CAP)
1183            .create_async()
1184            .await;
1185
1186        let targets = vec![target("rack", "2.0.5")];
1187        let outcomes = client
1188            .scan(EcosystemId::Bundler, &targets, TEST_TIMEOUT)
1189            .await;
1190
1191        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("rack") else {
1192            panic!("expected Vulnerable outcome");
1193        };
1194        assert_eq!(dv.advisories.total(), 40);
1195        assert_eq!(dv.advisories.items().len(), ADVISORY_DISPLAY_CAP);
1196        record.assert_async().await;
1197    }
1198
1199    #[tokio::test]
1200    async fn scan_second_call_within_ttl_issues_zero_requests() {
1201        let (mut server, client) = mock_client().await;
1202        let batch = server
1203            .mock("POST", "/v1/querybatch")
1204            .with_status(200)
1205            .with_body(r#"{"results":[{}]}"#)
1206            .expect(1)
1207            .create_async()
1208            .await;
1209
1210        let targets = vec![target("pkg", "1.0.0")];
1211        client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1212        client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1213
1214        batch.assert_async().await;
1215    }
1216
1217    #[tokio::test]
1218    async fn check_candidates_maps_clean_and_vulnerable() {
1219        let (mut server, client) = mock_client().await;
1220        let _batch = server
1221            .mock("POST", "/v1/querybatch")
1222            .with_status(200)
1223            .with_body(
1224                r#"{"results":[{},{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
1225            )
1226            .create_async()
1227            .await;
1228        let _record = server
1229            .mock("GET", "/v1/vulns/ADV-1")
1230            .with_status(200)
1231            .with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
1232            .create_async()
1233            .await;
1234
1235        let candidates = vec![target("clean-pkg", "2.0.0"), target("bad-pkg", "2.0.0")];
1236        let statuses = client
1237            .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
1238            .await;
1239
1240        assert_matches!(
1241            statuses.get("clean-pkg"),
1242            Some(UpgradeStatus::CandidateClean { version }) if version == "2.0.0"
1243        );
1244        assert_matches!(
1245            statuses.get("bad-pkg"),
1246            Some(UpgradeStatus::CandidateVulnerable { version, advisory_ids })
1247                if version == "2.0.0"
1248                    && advisory_ids.items() == ["ADV-1".to_string()]
1249                    && advisory_ids.total() == 1
1250        );
1251    }
1252
1253    #[tokio::test]
1254    async fn check_candidates_uses_display_version_not_wire_version() {
1255        // S1 regression guard: `ScanTarget.version` is the OSV wire spelling
1256        // (e.g. Go's "v" prefix stripped), but `UpgradeStatus` is rendered
1257        // back to the user (hover's "Latest version {} is also affected") —
1258        // it must carry `display_version`, the ecosystem-native spelling,
1259        // never the wire one.
1260        let (mut server, client) = mock_client().await;
1261        let _batch = server
1262            .mock("POST", "/v1/querybatch")
1263            .with_status(200)
1264            .with_body(r#"{"results":[{}]}"#)
1265            .create_async()
1266            .await;
1267
1268        let candidate = ScanTarget {
1269            key: "golang.org/x/text".to_string(),
1270            osv_name: "golang.org/x/text".to_string(),
1271            version: "0.4.0".to_string(),
1272            display_version: "v0.4.0".to_string(),
1273        };
1274        let statuses = client
1275            .check_candidates(EcosystemId::Go, &[candidate], TEST_TIMEOUT)
1276            .await;
1277
1278        assert_matches!(
1279            statuses.get("golang.org/x/text"),
1280            Some(UpgradeStatus::CandidateClean { version }) if version == "v0.4.0"
1281        );
1282    }
1283
1284    #[test]
1285    fn query_cache_evicts_oldest_when_max_entries_reached() {
1286        let client = client();
1287        for i in 0..MAX_CACHE_ENTRIES {
1288            client.store_query_cache("npm", &target(&format!("pkg-{i}"), "1.0.0"), &[]);
1289        }
1290        assert_eq!(client.query_cache.len(), MAX_CACHE_ENTRIES);
1291
1292        client.store_query_cache("npm", &target("overflow", "1.0.0"), &[]);
1293
1294        assert!(
1295            client.query_cache.len() <= MAX_CACHE_ENTRIES,
1296            "query_cache must stay bounded at MAX_CACHE_ENTRIES, got {}",
1297            client.query_cache.len()
1298        );
1299        assert!(client.query_cache.len() < MAX_CACHE_ENTRIES + 1);
1300    }
1301
1302    #[test]
1303    fn record_cache_evicts_oldest_when_max_entries_reached() {
1304        let client = client();
1305        for i in 0..MAX_CACHE_ENTRIES {
1306            let advisory = Arc::new(Advisory {
1307                id: format!("ADV-{i}"),
1308                modified: "2023-01-01T00:00:00Z".to_string(),
1309                summary: None,
1310                aliases: vec![],
1311                severity: VulnSeverity::Unknown,
1312                cvss_vector: None,
1313                fixed_versions: vec![],
1314                url: format!("https://osv.dev/vulnerability/ADV-{i}"),
1315            });
1316            client.store_record_cache(&advisory);
1317        }
1318        assert_eq!(client.record_cache.len(), MAX_CACHE_ENTRIES);
1319
1320        let overflow = Arc::new(Advisory {
1321            id: "ADV-overflow".to_string(),
1322            modified: "2023-01-01T00:00:00Z".to_string(),
1323            summary: None,
1324            aliases: vec![],
1325            severity: VulnSeverity::Unknown,
1326            cvss_vector: None,
1327            fixed_versions: vec![],
1328            url: "https://osv.dev/vulnerability/ADV-overflow".to_string(),
1329        });
1330        client.store_record_cache(&overflow);
1331
1332        assert!(
1333            client.record_cache.len() <= MAX_CACHE_ENTRIES,
1334            "record_cache must stay bounded at MAX_CACHE_ENTRIES, got {}",
1335            client.record_cache.len()
1336        );
1337    }
1338
1339    #[tokio::test]
1340    async fn query_cache_ttl_expiry_forces_requery() {
1341        let (mut server, client) = mock_client().await;
1342        let t = target("pkg", "1.0.0");
1343
1344        // Pre-populate the cache with an entry older than QUERY_CACHE_TTL.
1345        client.query_cache.insert(
1346            ("npm", t.osv_name.clone(), t.version.clone()),
1347            QueryCacheEntry {
1348                vuln_ids: vec![],
1349                fetched_at: Instant::now()
1350                    .checked_sub(QUERY_CACHE_TTL + Duration::from_secs(1))
1351                    .expect("test clock has more than QUERY_CACHE_TTL of headroom"),
1352            },
1353        );
1354
1355        let batch = server
1356            .mock("POST", "/v1/querybatch")
1357            .with_status(200)
1358            .with_body(r#"{"results":[{}]}"#)
1359            .expect(1)
1360            .create_async()
1361            .await;
1362
1363        client.scan(EcosystemId::Npm, &[t], TEST_TIMEOUT).await;
1364
1365        batch.assert_async().await;
1366    }
1367
1368    #[tokio::test]
1369    async fn record_cache_newer_modified_invalidates_and_refetches() {
1370        let (mut server, client) = mock_client().await;
1371
1372        // Pre-populate record_cache with a stale `modified` timestamp.
1373        client.record_cache.insert(
1374            "ADV-1".to_string(),
1375            RecordCacheEntry {
1376                advisory: Arc::new(Advisory {
1377                    id: "ADV-1".to_string(),
1378                    modified: "2020-01-01T00:00:00Z".to_string(),
1379                    summary: Some("stale summary".to_string()),
1380                    aliases: vec![],
1381                    severity: VulnSeverity::Unknown,
1382                    cvss_vector: None,
1383                    fixed_versions: vec![],
1384                    url: "https://osv.dev/vulnerability/ADV-1".to_string(),
1385                }),
1386                modified: "2020-01-01T00:00:00Z".to_string(),
1387                fetched_at: Instant::now(),
1388            },
1389        );
1390
1391        let _batch = server
1392            .mock("POST", "/v1/querybatch")
1393            .with_status(200)
1394            .with_body(
1395                r#"{"results":[{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
1396            )
1397            .create_async()
1398            .await;
1399        let record = server
1400            .mock("GET", "/v1/vulns/ADV-1")
1401            .with_status(200)
1402            .with_body(
1403                r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z","summary":"updated summary"}"#,
1404            )
1405            .expect(1)
1406            .create_async()
1407            .await;
1408
1409        let targets = vec![target("pkg", "1.0.0")];
1410        let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1411
1412        let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("pkg") else {
1413            panic!("expected Vulnerable outcome");
1414        };
1415        assert_eq!(
1416            dv.advisories.items()[0].summary.as_deref(),
1417            Some("updated summary")
1418        );
1419        record.assert_async().await;
1420    }
1421}