1mod 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
43pub const ADVISORY_DISPLAY_CAP: usize = 5;
52
53pub const MAX_ADVISORY_RECORDS: usize = 50;
65
66const QUERY_CACHE_TTL: Duration = Duration::from_hours(6);
68
69const BATCH_CHUNK_SIZE: usize = 1000;
71
72const MAX_TRUNCATED_REQUERY_BUDGET: usize = 20;
75
76const RECORD_FETCH_CONCURRENCY: usize = 10;
86
87const MAX_CACHE_ENTRIES: usize = 10_000;
89
90const OSV_API_BASE: &str = "https://api.osv.dev";
91
92fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
119 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 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 fn is_plain_release(s: &str) -> bool {
146 release_prefix(s).len() == s.len()
147 }
148
149 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#[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
206struct 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
232pub struct OsvClient {
239 cache: Arc<HttpCache>,
240 query_cache: DashMap<QueryCacheKey, QueryCacheEntry>,
241 record_cache: DashMap<String, RecordCacheEntry>,
242 raw_record_cache: DashMap<String, RawRecordCacheEntry>,
246 record_fetch_semaphore: Arc<tokio::sync::Semaphore>,
251 record_in_flight: DashMap<String, Arc<InFlightRecord>>,
260 #[cfg(any(test, feature = "test-util"))]
265 base_url: String,
266}
267
268impl OsvClient {
269 #[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 #[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 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 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 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 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 None => UpgradeStatus::CandidateUnverified {
430 version,
431 reason: SkipReason::QueryFailed,
432 },
433 };
434 (key, status)
435 })
436 .collect()
437 }
438
439 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 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 #[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 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 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 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 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 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 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 let recovered: Vec<(VulnKey, ScanOutcome)> = stream::iter(to_recover.iter().cloned())
834 .map(|target| async move {
835 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 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 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 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 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 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 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 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 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 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 #[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 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 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
1249fn 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
1256fn 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 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 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 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 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 assert_eq!(dv.advisories.items().len(), MAX_ADVISORY_RECORDS);
2699 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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 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 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}