1mod severity;
18mod types;
19
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23
24use dashmap::DashMap;
25
26pub use severity::to_diagnostic_severity as diagnostic_severity_for;
27pub use types::{
28 Advisory, Capped, DependencyVulnerabilities, FixRecommendation, ScanOutcome, ScanTarget,
29 SkipReason, UpgradeStatus, VulnSeverity, VulnerabilityMap, vulnerability_keys,
30};
31use types::{
32 OsvBatchRequest, OsvBatchResponse, OsvPackage, OsvQuery, OsvSingleQueryResponse, OsvVulnRecord,
33};
34
35use crate::cache::HttpCache;
36
37pub const ADVISORY_DISPLAY_CAP: usize = 5;
40
41const QUERY_CACHE_TTL: Duration = Duration::from_hours(6);
43
44const BATCH_CHUNK_SIZE: usize = 1000;
46
47const MAX_TRUNCATED_REQUERY_BUDGET: usize = 20;
50
51const RECORD_FETCH_CONCURRENCY: usize = 10;
54
55const MAX_CACHE_ENTRIES: usize = 10_000;
57
58const CACHE_EVICTION_PERCENTAGE: usize = 10;
61
62const OSV_API_BASE: &str = "https://api.osv.dev";
63
64fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
72 fn segments(s: &str) -> Vec<u64> {
73 s.split('.')
74 .map(|part| {
75 part.chars()
76 .take_while(|c| c.is_ascii_digit())
77 .collect::<String>()
78 .parse()
79 .unwrap_or(0)
80 })
81 .collect()
82 }
83
84 let (sa, sb) = (segments(a), segments(b));
85 sa.cmp(&sb).then_with(|| a.cmp(b))
86}
87
88struct QueryCacheEntry {
89 vuln_ids: Vec<(String, String)>,
90 fetched_at: Instant,
91}
92
93struct RecordCacheEntry {
94 advisory: Arc<Advisory>,
95 modified: String,
96 fetched_at: Instant,
97}
98
99fn evict_oldest<K, V>(map: &DashMap<K, V>, fetched_at: impl Fn(&V) -> Instant)
102where
103 K: Eq + std::hash::Hash + Clone + Ord,
104{
105 use std::cmp::Reverse;
106 use std::collections::BinaryHeap;
107
108 let target_removals = (MAX_CACHE_ENTRIES / CACHE_EVICTION_PERCENTAGE).max(1);
109 let mut oldest: BinaryHeap<Reverse<(Instant, K)>> = map
110 .iter()
111 .map(|entry| Reverse((fetched_at(entry.value()), entry.key().clone())))
112 .collect();
113
114 for _ in 0..target_removals {
115 let Some(Reverse((_, key))) = oldest.pop() else {
116 break;
117 };
118 map.remove(&key);
119 }
120}
121
122pub struct OsvClient {
129 cache: Arc<HttpCache>,
130 query_cache: DashMap<(&'static str, String, String), QueryCacheEntry>,
131 record_cache: DashMap<String, RecordCacheEntry>,
132 #[cfg(test)]
136 base_url: String,
137}
138
139impl OsvClient {
140 #[must_use]
144 pub fn new(cache: Arc<HttpCache>) -> Self {
145 Self {
146 cache,
147 query_cache: DashMap::new(),
148 record_cache: DashMap::new(),
149 #[cfg(test)]
150 base_url: OSV_API_BASE.to_string(),
151 }
152 }
153
154 #[cfg(test)]
155 fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
156 Self {
157 cache,
158 query_cache: DashMap::new(),
159 record_cache: DashMap::new(),
160 base_url,
161 }
162 }
163
164 #[cfg(test)]
165 fn api_base(&self) -> &str {
166 &self.base_url
167 }
168
169 #[cfg(not(test))]
170 const fn api_base(&self) -> &str {
171 OSV_API_BASE
172 }
173
174 fn batch_url(&self) -> String {
175 format!("{}/v1/querybatch", self.api_base())
176 }
177
178 fn single_query_url(&self) -> String {
179 format!("{}/v1/query", self.api_base())
180 }
181
182 fn vuln_record_url(&self, id: &str) -> String {
183 format!("{}/v1/vulns/{id}", self.api_base())
184 }
185
186 pub async fn scan(
201 &self,
202 ecosystem: crate::EcosystemId,
203 deps: &[ScanTarget],
204 timeout: Duration,
205 ) -> VulnerabilityMap {
206 if deps.is_empty() {
207 return VulnerabilityMap::new();
208 }
209 let outcomes = self.resolve(ecosystem, deps, timeout).await;
210 log_scan_summary(&outcomes);
211 outcomes
212 }
213
214 pub async fn check_candidates(
221 &self,
222 ecosystem: crate::EcosystemId,
223 candidates: &[ScanTarget],
224 timeout: Duration,
225 ) -> HashMap<String, UpgradeStatus> {
226 if candidates.is_empty() {
227 return HashMap::new();
228 }
229
230 let versions: HashMap<&str, &str> = candidates
235 .iter()
236 .map(|c| (c.key.as_str(), c.display_version.as_str()))
237 .collect();
238
239 let outcomes = self.resolve(ecosystem, candidates, timeout).await;
240
241 outcomes
242 .into_iter()
243 .filter_map(|(key, outcome)| {
244 let version = (*versions.get(key.as_str())?).to_string();
245 let status = match outcome {
246 ScanOutcome::Clean => UpgradeStatus::CandidateClean { version },
247 ScanOutcome::Vulnerable(dv) => UpgradeStatus::CandidateVulnerable {
248 version,
249 advisory_ids: Capped::new(
250 dv.advisories.items().iter().map(|a| a.id.clone()).collect(),
251 dv.advisories.total(),
252 ),
253 },
254 ScanOutcome::Skipped(_) => return None,
255 };
256 Some((key, status))
257 })
258 .collect()
259 }
260
261 async fn resolve(
271 &self,
272 ecosystem: crate::EcosystemId,
273 targets: &[ScanTarget],
274 timeout: Duration,
275 ) -> HashMap<String, ScanOutcome> {
276 let deadline = Instant::now() + timeout;
277 let mut outcomes = HashMap::with_capacity(targets.len());
278
279 let Some(osv_eco) = ecosystem.osv_ecosystem() else {
280 for t in targets {
281 outcomes.insert(
282 t.key.clone(),
283 ScanOutcome::Skipped(SkipReason::UnmappableEcosystem),
284 );
285 }
286 return outcomes;
287 };
288
289 let mut to_query: Vec<ScanTarget> = Vec::new();
290 for t in targets {
291 let cache_key = (osv_eco, t.osv_name.clone(), t.version.clone());
292 let cached_ids = self.query_cache.get(&cache_key).and_then(|entry| {
293 (entry.fetched_at.elapsed() < QUERY_CACHE_TTL).then(|| entry.vuln_ids.clone())
294 });
295 if let Some(vuln_ids) = cached_ids {
296 outcomes.insert(
297 t.key.clone(),
298 self.build_outcome(osv_eco, &t.osv_name, &vuln_ids).await,
299 );
300 } else {
301 to_query.push(t.clone());
302 }
303 }
304
305 if to_query.is_empty() {
306 return outcomes;
307 }
308
309 let mut truncated: Vec<ScanTarget> = Vec::new();
310 let mut chunks = to_query.chunks(BATCH_CHUNK_SIZE);
311
312 while let Some(chunk) = chunks.next() {
313 if Instant::now() >= deadline {
314 tracing::warn!(
315 remaining = chunk.len(),
316 "OSV scan deadline exceeded, marking remaining chunks as query-failed"
317 );
318 mark_chunk_failed(chunk, &mut outcomes);
319 for remaining in chunks {
320 mark_chunk_failed(remaining, &mut outcomes);
321 }
322 return outcomes;
323 }
324 self.resolve_chunk(osv_eco, chunk, &mut outcomes, &mut truncated)
325 .await;
326 }
327
328 if Instant::now() >= deadline {
329 tracing::warn!(
330 count = truncated.len(),
331 "OSV scan deadline exceeded before truncation recovery"
332 );
333 for target in &truncated {
334 outcomes.insert(
335 target.key.clone(),
336 ScanOutcome::Skipped(SkipReason::Truncated),
337 );
338 }
339 return outcomes;
340 }
341
342 self.recover_truncated(osv_eco, &truncated, &mut outcomes)
343 .await;
344
345 outcomes
346 }
347
348 async fn resolve_chunk(
356 &self,
357 osv_eco: &'static str,
358 chunk: &[ScanTarget],
359 outcomes: &mut HashMap<String, ScanOutcome>,
360 truncated: &mut Vec<ScanTarget>,
361 ) {
362 let queries: Vec<OsvQuery> = chunk
363 .iter()
364 .map(|t| OsvQuery {
365 package: OsvPackage {
366 name: t.osv_name.clone(),
367 ecosystem: osv_eco.to_string(),
368 },
369 version: t.version.clone(),
370 })
371 .collect();
372
373 let body = OsvBatchRequest { queries };
374 let response_bytes = match self.cache.post_json(&self.batch_url(), &body).await {
375 Ok(b) => b,
376 Err(e) => {
377 tracing::warn!(error = %e, "OSV batch query failed");
378 mark_chunk_failed(chunk, outcomes);
379 return;
380 }
381 };
382
383 let parsed: OsvBatchResponse = match crate::parser::parse_json_checked(&response_bytes) {
384 Ok(p) => p,
385 Err(e) => {
386 tracing::warn!(error = %e, "failed to parse OSV batch response");
387 mark_chunk_failed(chunk, outcomes);
388 return;
389 }
390 };
391
392 if parsed.results.len() != chunk.len() {
393 tracing::warn!(
394 expected = chunk.len(),
395 got = parsed.results.len(),
396 "OSV batch result count mismatch, dropping chunk"
397 );
398 mark_chunk_failed(chunk, outcomes);
399 return;
400 }
401
402 for (target, result) in chunk.iter().zip(parsed.results) {
403 if result.next_page_token.is_some() {
404 truncated.push(target.clone());
405 continue;
406 }
407 let vuln_ids: Vec<(String, String)> = result
408 .vulns
409 .into_iter()
410 .map(|v| (v.id, v.modified))
411 .collect();
412 self.store_query_cache(osv_eco, target, &vuln_ids);
413 outcomes.insert(
414 target.key.clone(),
415 self.build_outcome(osv_eco, &target.osv_name, &vuln_ids)
416 .await,
417 );
418 }
419 }
420
421 async fn recover_truncated(
427 &self,
428 osv_eco: &'static str,
429 truncated: &[ScanTarget],
430 outcomes: &mut HashMap<String, ScanOutcome>,
431 ) {
432 use futures::stream::{self, StreamExt};
433
434 let budget = MAX_TRUNCATED_REQUERY_BUDGET.min(truncated.len());
435 let (to_recover, exhausted) = truncated.split_at(budget);
436
437 let recovered: Vec<(String, ScanOutcome)> = stream::iter(to_recover.iter().cloned())
444 .map(|target| async move {
445 let outcome = match self.query_single(osv_eco, &target).await {
446 Some(resp) if resp.next_page_token.is_some() => {
450 tracing::warn!(
451 dep = %target.key,
452 "OSV single-package requery itself paginated; treating as still truncated"
453 );
454 ScanOutcome::Skipped(SkipReason::Truncated)
455 }
456 Some(resp) => self.outcome_from_full_records(osv_eco, &target, resp.vulns),
457 None => ScanOutcome::Skipped(SkipReason::QueryFailed),
458 };
459 (target.key.clone(), outcome)
460 })
461 .buffer_unordered(RECORD_FETCH_CONCURRENCY)
462 .collect()
463 .await;
464
465 for (key, outcome) in recovered {
466 outcomes.insert(key, outcome);
467 }
468
469 for target in exhausted {
470 outcomes.insert(
471 target.key.clone(),
472 ScanOutcome::Skipped(SkipReason::Truncated),
473 );
474 }
475 }
476
477 fn outcome_from_full_records(
484 &self,
485 osv_eco: &'static str,
486 target: &ScanTarget,
487 records: Vec<OsvVulnRecord>,
488 ) -> ScanOutcome {
489 let total = records.len();
490 let mut advisories = Vec::with_capacity(total.min(ADVISORY_DISPLAY_CAP));
491 let mut vuln_ids = Vec::with_capacity(total);
492
493 for record in records {
494 let Some(advisory) = record.into_advisory(&target.osv_name, osv_eco) else {
495 continue;
496 };
497 let advisory = Arc::new(advisory);
498 vuln_ids.push((advisory.id.clone(), advisory.modified.clone()));
499 self.store_record_cache(&advisory);
500 if advisories.len() < ADVISORY_DISPLAY_CAP {
501 advisories.push(advisory);
502 }
503 }
504
505 self.store_query_cache(osv_eco, target, &vuln_ids);
506
507 if total == 0 {
508 ScanOutcome::Clean
509 } else {
510 ScanOutcome::Vulnerable(DependencyVulnerabilities {
511 advisories: Capped::new(advisories, total),
512 fix_target_status: UpgradeStatus::NotChecked,
513 upgrade_status: UpgradeStatus::NotChecked,
514 })
515 }
516 }
517
518 async fn build_outcome(
521 &self,
522 osv_eco: &str,
523 osv_name: &str,
524 vuln_ids: &[(String, String)],
525 ) -> ScanOutcome {
526 if vuln_ids.is_empty() {
527 return ScanOutcome::Clean;
528 }
529
530 let to_fetch = &vuln_ids[..vuln_ids.len().min(ADVISORY_DISPLAY_CAP)];
531 let advisories = self.fetch_records(osv_eco, osv_name, to_fetch).await;
532
533 ScanOutcome::Vulnerable(DependencyVulnerabilities {
534 advisories: Capped::new(advisories, vuln_ids.len()),
535 fix_target_status: UpgradeStatus::NotChecked,
536 upgrade_status: UpgradeStatus::NotChecked,
537 })
538 }
539
540 async fn fetch_records(
546 &self,
547 osv_eco: &str,
548 osv_name: &str,
549 ids: &[(String, String)],
550 ) -> Vec<Arc<Advisory>> {
551 use futures::stream::{self, StreamExt};
552
553 stream::iter(ids.iter().cloned())
554 .map(|(id, modified)| async move {
555 let cached = self.record_cache.get(&id).and_then(|entry| {
556 (entry.modified == modified).then(|| Arc::clone(&entry.advisory))
557 });
558 if let Some(advisory) = cached {
559 return Some(advisory);
560 }
561
562 let record = self.fetch_single_record(&id).await?;
563 let advisory = Arc::new(record.into_advisory(osv_name, osv_eco)?);
564 self.store_record_cache(&advisory);
565 Some(advisory)
566 })
567 .buffer_unordered(RECORD_FETCH_CONCURRENCY)
568 .collect::<Vec<_>>()
569 .await
570 .into_iter()
571 .flatten()
572 .collect()
573 }
574
575 async fn fetch_single_record(&self, id: &str) -> Option<OsvVulnRecord> {
583 let url = self.vuln_record_url(id);
584 match self.cache.get_transport_only(&url).await {
585 Ok(bytes) => match crate::parser::parse_json_checked::<OsvVulnRecord>(&bytes) {
586 Ok(record) => Some(record),
587 Err(e) => {
588 tracing::warn!(id, error = %e, "failed to parse OSV vulnerability record");
589 None
590 }
591 },
592 Err(e) => {
593 tracing::warn!(id, error = %e, "failed to fetch OSV vulnerability record");
594 None
595 }
596 }
597 }
598
599 async fn query_single(
600 &self,
601 osv_eco: &'static str,
602 target: &ScanTarget,
603 ) -> Option<OsvSingleQueryResponse> {
604 let body = OsvQuery {
605 package: OsvPackage {
606 name: target.osv_name.clone(),
607 ecosystem: osv_eco.to_string(),
608 },
609 version: target.version.clone(),
610 };
611
612 let bytes = match self.cache.post_json(&self.single_query_url(), &body).await {
613 Ok(b) => b,
614 Err(e) => {
615 tracing::warn!(dep = %target.key, error = %e, "OSV single-package requery failed");
616 return None;
617 }
618 };
619
620 match crate::parser::parse_json_checked::<OsvSingleQueryResponse>(&bytes) {
621 Ok(resp) => Some(resp),
622 Err(e) => {
623 tracing::warn!(dep = %target.key, error = %e, "failed to parse OSV single-package response");
624 None
625 }
626 }
627 }
628
629 fn store_query_cache(
630 &self,
631 osv_eco: &'static str,
632 target: &ScanTarget,
633 vuln_ids: &[(String, String)],
634 ) {
635 if self.query_cache.len() >= MAX_CACHE_ENTRIES {
636 evict_oldest(&self.query_cache, |e| e.fetched_at);
637 }
638 self.query_cache.insert(
639 (osv_eco, target.osv_name.clone(), target.version.clone()),
640 QueryCacheEntry {
641 vuln_ids: vuln_ids.to_vec(),
642 fetched_at: Instant::now(),
643 },
644 );
645 }
646
647 fn store_record_cache(&self, advisory: &Arc<Advisory>) {
648 if self.record_cache.len() >= MAX_CACHE_ENTRIES {
649 evict_oldest(&self.record_cache, |e| e.fetched_at);
650 }
651 self.record_cache.insert(
652 advisory.id.clone(),
653 RecordCacheEntry {
654 advisory: Arc::clone(advisory),
655 modified: advisory.modified.clone(),
656 fetched_at: Instant::now(),
657 },
658 );
659 }
660}
661
662fn mark_chunk_failed(chunk: &[ScanTarget], outcomes: &mut HashMap<String, ScanOutcome>) {
664 for t in chunk {
665 outcomes.insert(t.key.clone(), ScanOutcome::Skipped(SkipReason::QueryFailed));
666 }
667}
668
669fn log_scan_summary(outcomes: &HashMap<String, ScanOutcome>) {
671 let mut clean = 0usize;
672 let mut vulnerable = 0usize;
673 let mut skip_counts: HashMap<&'static str, usize> = HashMap::new();
674
675 for outcome in outcomes.values() {
676 match outcome {
677 ScanOutcome::Clean => clean += 1,
678 ScanOutcome::Vulnerable(_) => vulnerable += 1,
679 ScanOutcome::Skipped(reason) => {
680 *skip_counts.entry(reason.as_str()).or_insert(0) += 1;
681 }
682 }
683 }
684
685 let skipped: usize = skip_counts.values().sum();
686 let reasons = skip_counts
687 .iter()
688 .map(|(reason, count)| format!("{count} {reason}"))
689 .collect::<Vec<_>>()
690 .join(", ");
691
692 tracing::info!(
693 "OSV: scanned {}, clean {clean}, vulnerable {vulnerable}, skipped {skipped}{}",
694 outcomes.len(),
695 if reasons.is_empty() {
696 String::new()
697 } else {
698 format!(" ({reasons})")
699 }
700 );
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::EcosystemId;
707 use std::assert_matches;
708
709 fn client() -> OsvClient {
710 OsvClient::new(Arc::new(HttpCache::new()))
711 }
712
713 async fn mock_client() -> (mockito::ServerGuard, OsvClient) {
714 let server = mockito::Server::new_async().await;
715 let client = OsvClient::with_base_url(Arc::new(HttpCache::new()), server.url());
716 (server, client)
717 }
718
719 const TEST_TIMEOUT: Duration = Duration::from_secs(30);
720
721 fn target(name: &str, version: &str) -> ScanTarget {
722 ScanTarget {
723 key: name.to_string(),
724 osv_name: name.to_string(),
725 version: version.to_string(),
726 display_version: version.to_string(),
727 }
728 }
729
730 #[test]
731 fn compare_version_strings_orders_numerically() {
732 let mut versions = vec![
733 "0.2.10".to_string(),
734 "0.2.2".to_string(),
735 "0.2.23".to_string(),
736 "0.2.0".to_string(),
737 ];
738 versions.sort_by(|a, b| compare_version_strings(a, b));
739 assert_eq!(versions, vec!["0.2.0", "0.2.2", "0.2.10", "0.2.23"]);
740 }
741
742 #[tokio::test]
743 async fn scan_empty_input_returns_empty_map() {
744 let client = client();
745 let outcomes = client.scan(EcosystemId::Cargo, &[], TEST_TIMEOUT).await;
746 assert!(outcomes.is_empty());
747 }
748
749 #[tokio::test]
750 async fn check_candidates_empty_input_returns_empty_map() {
751 let client = client();
752 let statuses = client
753 .check_candidates(EcosystemId::Cargo, &[], TEST_TIMEOUT)
754 .await;
755 assert!(statuses.is_empty());
756 }
757
758 #[tokio::test]
759 async fn scan_all_clean_batch_result() {
760 let (mut server, client) = mock_client().await;
761 let _m = server
762 .mock("POST", "/v1/querybatch")
763 .with_status(200)
764 .with_body(r#"{"results":[{}]}"#)
765 .create_async()
766 .await;
767
768 let targets = vec![target("left-pad", "1.0.0")];
769 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
770
771 assert_eq!(outcomes.len(), 1);
772 assert_matches!(outcomes.get("left-pad"), Some(ScanOutcome::Clean));
773 }
774
775 #[tokio::test]
776 async fn scan_batch_http_400_skips_whole_chunk() {
777 let (mut server, client) = mock_client().await;
778 let _m = server
779 .mock("POST", "/v1/querybatch")
780 .with_status(400)
781 .with_body(r#"{"code":3,"message":"error in query at index 0"}"#)
782 .create_async()
783 .await;
784
785 let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
786 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
787
788 assert_eq!(outcomes.len(), 2);
789 for key in ["a", "b"] {
790 assert_matches!(
791 outcomes.get(key),
792 Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
793 );
794 }
795 }
796
797 #[tokio::test]
798 async fn scan_malformed_batch_json_skips_whole_chunk() {
799 let (mut server, client) = mock_client().await;
800 let _m = server
801 .mock("POST", "/v1/querybatch")
802 .with_status(200)
803 .with_body("not json")
804 .create_async()
805 .await;
806
807 let targets = vec![target("a", "1.0.0")];
808 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
809
810 assert_matches!(
811 outcomes.get("a"),
812 Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
813 );
814 }
815
816 #[tokio::test]
817 async fn scan_deeply_nested_batch_json_skips_whole_chunk() {
818 let (mut server, client) = mock_client().await;
824 let deeply_nested = format!(
825 "{}1{}",
826 "[".repeat(crate::parser::MAX_JSON_NESTING_DEPTH + 1),
827 "]".repeat(crate::parser::MAX_JSON_NESTING_DEPTH + 1)
828 );
829 let _m = server
830 .mock("POST", "/v1/querybatch")
831 .with_status(200)
832 .with_body(format!(
833 r#"{{"results":[{{"vulns":[{{"id":"ADV-1","modified":"2023-01-01T00:00:00Z","database_specific":{deeply_nested}}}]}}]}}"#
834 ))
835 .create_async()
836 .await;
837
838 let targets = vec![target("pkg", "1.0.0")];
839 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
840
841 assert_matches!(
842 outcomes.get("pkg"),
843 Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
844 );
845 }
846
847 #[tokio::test]
848 async fn scan_result_count_mismatch_drops_whole_chunk() {
849 let (mut server, client) = mock_client().await;
850 let _m = server
852 .mock("POST", "/v1/querybatch")
853 .with_status(200)
854 .with_body(r#"{"results":[{}]}"#)
855 .create_async()
856 .await;
857
858 let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
859 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
860
861 for key in ["a", "b"] {
862 assert_matches!(
863 outcomes.get(key),
864 Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
865 );
866 }
867 }
868
869 #[tokio::test]
870 async fn scan_over_chunk_size_input_issues_exactly_two_batch_requests() {
871 use std::sync::atomic::{AtomicUsize, Ordering};
872
873 let (mut server, client) = mock_client().await;
874
875 let n = BATCH_CHUNK_SIZE + 1;
876 let targets: Vec<ScanTarget> = (0..n)
877 .map(|i| target(&format!("pkg-{i}"), "1.0.0"))
878 .collect();
879
880 let call_count = Arc::new(AtomicUsize::new(0));
881 let call_count_clone = Arc::clone(&call_count);
882
883 let batch = server
884 .mock("POST", "/v1/querybatch")
885 .with_status(200)
886 .with_body_from_request(move |req| {
887 call_count_clone.fetch_add(1, Ordering::SeqCst);
888 let body = req.body().expect("request body");
889 let parsed: serde_json::Value =
890 serde_json::from_slice(body).expect("valid JSON request body");
891 let count = parsed["queries"].as_array().map_or(0, Vec::len);
892 let results = vec!["{}"; count].join(",");
893 format!(r#"{{"results":[{results}]}}"#).into_bytes()
894 })
895 .expect(2)
896 .create_async()
897 .await;
898
899 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
900
901 assert_eq!(outcomes.len(), n);
902 assert!(outcomes.values().all(|o| matches!(o, ScanOutcome::Clean)));
903 batch.assert_async().await;
904 assert_eq!(
905 call_count.load(Ordering::SeqCst),
906 2,
907 "a >1000-entry scan must issue exactly two chunked batch requests"
908 );
909 }
910
911 #[tokio::test]
912 async fn scan_deadline_exceeded_before_first_chunk_marks_everything_query_failed() {
913 let (_server, client) = mock_client().await;
914 let targets = vec![target("a", "1.0.0"), target("b", "1.0.0")];
919 let outcomes = client
920 .scan(EcosystemId::Npm, &targets, Duration::from_secs(0))
921 .await;
922
923 for key in ["a", "b"] {
924 assert_matches!(
925 outcomes.get(key),
926 Some(ScanOutcome::Skipped(SkipReason::QueryFailed))
927 );
928 }
929 }
930
931 #[tokio::test]
932 async fn scan_filters_affected_entries_to_the_queried_package() {
933 let (mut server, client) = mock_client().await;
938 let _batch = server
939 .mock("POST", "/v1/querybatch")
940 .with_status(200)
941 .with_body(
942 r#"{"results":[{"vulns":[{"id":"GHSA-cross-pkg","modified":"2023-01-01T00:00:00Z"}]}]}"#,
943 )
944 .create_async()
945 .await;
946 let _record = server
947 .mock("GET", "/v1/vulns/GHSA-cross-pkg")
948 .with_status(200)
949 .with_body(
950 r#"{"id":"GHSA-cross-pkg","modified":"2023-01-01T00:00:00Z",
951 "affected":[
952 {"package":{"name":"log4j-api","ecosystem":"Maven"},
953 "ecosystem_specific":{"severity":"LOW"},
954 "ranges":[{"events":[{"fixed":"1.0.0"}]}]},
955 {"package":{"name":"log4j-core","ecosystem":"Maven"},
956 "ecosystem_specific":{"severity":"CRITICAL"},
957 "ranges":[{"events":[{"fixed":"2.17.1"}]}]}
958 ]}"#,
959 )
960 .create_async()
961 .await;
962
963 let targets = vec![target("log4j-core", "2.14.1")];
964 let outcomes = client
965 .scan(EcosystemId::Maven, &targets, TEST_TIMEOUT)
966 .await;
967
968 let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("log4j-core") else {
969 panic!("expected Vulnerable outcome");
970 };
971 assert_eq!(dv.advisories.items()[0].severity, VulnSeverity::Critical);
973 assert_eq!(
974 dv.advisories.items()[0].fixed_versions,
975 vec!["2.17.1".to_string()]
976 );
977 }
978
979 #[tokio::test]
980 async fn scan_malformed_advisory_id_is_dropped() {
981 let (mut server, client) = mock_client().await;
985 let _batch = server
986 .mock("POST", "/v1/querybatch")
987 .with_status(200)
988 .with_body(
989 r#"{"results":[{"vulns":[{"id":"evil](javascript:alert(1))","modified":"2023-01-01T00:00:00Z"}]}]}"#,
990 )
991 .create_async()
992 .await;
993
994 let targets = vec![target("pkg", "1.0.0")];
995 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
996
997 let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("pkg") else {
1000 panic!("expected Vulnerable outcome, got {:?}", outcomes.get("pkg"));
1001 };
1002 assert_eq!(dv.advisories.total(), 1);
1003 assert!(dv.advisories.items().is_empty());
1004 }
1005
1006 #[tokio::test]
1007 async fn recover_truncated_single_query_that_itself_paginates_is_skipped_truncated() {
1008 let (mut server, client) = mock_client().await;
1011 let _batch = server
1012 .mock("POST", "/v1/querybatch")
1013 .with_status(200)
1014 .with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
1015 .create_async()
1016 .await;
1017 let _requery = server
1018 .mock("POST", "/v1/query")
1019 .with_status(200)
1020 .with_body(
1021 r#"{"vulns":[{"id":"GHSA-1","modified":"2023-01-01T00:00:00Z"}],"next_page_token":"still-more"}"#,
1022 )
1023 .create_async()
1024 .await;
1025
1026 let targets = vec![target("linux", "5.10.1")];
1027 let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
1028
1029 assert_matches!(
1030 outcomes.get("linux"),
1031 Some(ScanOutcome::Skipped(SkipReason::Truncated))
1032 );
1033 }
1034
1035 #[tokio::test]
1036 async fn scan_vulnerable_fetches_advisory_record() {
1037 let (mut server, client) = mock_client().await;
1038 let _batch = server
1039 .mock("POST", "/v1/querybatch")
1040 .with_status(200)
1041 .with_body(r#"{"results":[{"vulns":[{"id":"RUSTSEC-2020-0071","modified":"2023-01-01T00:00:00Z"}]}]}"#)
1042 .create_async()
1043 .await;
1044 let _record = server
1052 .mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
1053 .with_status(200)
1054 .with_body(
1055 r#"{"id":"RUSTSEC-2020-0071","modified":"2023-01-01T00:00:00Z",
1056 "summary":"Potential segfault","database_specific":{"severity":"HIGH"},
1057 "affected":[
1058 {"package":{"name":"time","ecosystem":"crates.io"},"ranges":[
1059 {"events":[{"introduced":"0"},{"fixed":"0.2.0"},{"fixed":"0.1.44"}]},
1060 {"events":[{"introduced":"0"},{"fixed":"0.2.4"},{"fixed":"0.1.43"}]},
1061 {"events":[{"introduced":"0"},{"fixed":"0.2.2"},{"fixed":"0.2.23"}]},
1062 {"events":[{"introduced":"0"},{"fixed":"0.2.1"},{"fixed":"0.2.3"}]}
1063 ]}
1064 ]}"#,
1065 )
1066 .create_async()
1067 .await;
1068
1069 let targets = vec![target("time", "0.1.43")];
1070 let outcomes = client
1071 .scan(EcosystemId::Cargo, &targets, TEST_TIMEOUT)
1072 .await;
1073
1074 let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("time") else {
1075 panic!(
1076 "expected Vulnerable outcome, got {:?}",
1077 outcomes.get("time")
1078 );
1079 };
1080 assert_eq!(dv.advisories.total(), 1);
1081 assert_eq!(dv.advisories.items().len(), 1);
1082 assert_eq!(dv.advisories.items()[0].id, "RUSTSEC-2020-0071");
1083 assert_eq!(dv.advisories.items()[0].severity, VulnSeverity::High);
1084 assert_eq!(
1085 dv.advisories.items()[0].fixed_versions,
1086 vec![
1087 "0.1.43", "0.1.44", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.23"
1088 ]
1089 );
1090 assert_eq!(
1092 dv.advisories.items()[0].fixed_versions.last(),
1093 Some(&"0.2.23".to_string())
1094 );
1095 }
1096
1097 #[tokio::test]
1098 async fn scan_dropped_advisory_record_still_yields_vulnerable_with_fewer_advisories() {
1099 let (mut server, client) = mock_client().await;
1100 let _batch = server
1101 .mock("POST", "/v1/querybatch")
1102 .with_status(200)
1103 .with_body(
1104 r#"{"results":[{"vulns":[{"id":"MISSING-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
1105 )
1106 .create_async()
1107 .await;
1108 let _record = server
1109 .mock("GET", "/v1/vulns/MISSING-1")
1110 .with_status(404)
1111 .create_async()
1112 .await;
1113
1114 let targets = vec![target("pkg", "1.0.0")];
1115 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1116
1117 let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("pkg") else {
1120 panic!("expected Vulnerable outcome, got {:?}", outcomes.get("pkg"));
1121 };
1122 assert_eq!(dv.advisories.total(), 1);
1123 assert!(dv.advisories.items().is_empty());
1124 }
1125
1126 #[tokio::test]
1127 async fn scan_next_page_token_is_never_rendered_as_clean() {
1128 let (mut server, client) = mock_client().await;
1129 let _batch = server
1132 .mock("POST", "/v1/querybatch")
1133 .with_status(200)
1134 .with_body(r#"{"results":[{"next_page_token":"abc"}]}"#)
1135 .create_async()
1136 .await;
1137 let _requery = server
1138 .mock("POST", "/v1/query")
1139 .with_status(200)
1140 .with_body(
1141 r#"{"vulns":[{"id":"GHSA-1","modified":"2023-01-01T00:00:00Z","database_specific":{"severity":"CRITICAL"}}]}"#,
1142 )
1143 .create_async()
1144 .await;
1145
1146 let targets = vec![target("linux", "5.10.1")];
1147 let outcomes = client.scan(EcosystemId::Go, &targets, TEST_TIMEOUT).await;
1148
1149 let Some(outcome) = outcomes.get("linux") else {
1150 panic!("dependency missing from outcome map");
1151 };
1152 assert!(
1153 !matches!(outcome, ScanOutcome::Clean),
1154 "a truncated batch result must never render as clean"
1155 );
1156 assert_matches!(outcome, ScanOutcome::Vulnerable(_));
1157 }
1158
1159 #[tokio::test]
1160 async fn scan_advisory_fetch_is_capped_but_total_known_reflects_full_count() {
1161 let (mut server, client) = mock_client().await;
1162
1163 let vulns_json: String = (0..40)
1164 .map(|i| format!(r#"{{"id":"ADV-{i}","modified":"2023-01-01T00:00:00Z"}}"#))
1165 .collect::<Vec<_>>()
1166 .join(",");
1167 let _batch = server
1168 .mock("POST", "/v1/querybatch")
1169 .with_status(200)
1170 .with_body(format!(r#"{{"results":[{{"vulns":[{vulns_json}]}}]}}"#))
1171 .create_async()
1172 .await;
1173
1174 let record = server
1176 .mock(
1177 "GET",
1178 mockito::Matcher::Regex(r"^/v1/vulns/ADV-\d+$".into()),
1179 )
1180 .with_status(200)
1181 .with_body(r#"{"id":"ADV-x","modified":"2023-01-01T00:00:00Z"}"#)
1182 .expect(ADVISORY_DISPLAY_CAP)
1183 .create_async()
1184 .await;
1185
1186 let targets = vec![target("rack", "2.0.5")];
1187 let outcomes = client
1188 .scan(EcosystemId::Bundler, &targets, TEST_TIMEOUT)
1189 .await;
1190
1191 let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("rack") else {
1192 panic!("expected Vulnerable outcome");
1193 };
1194 assert_eq!(dv.advisories.total(), 40);
1195 assert_eq!(dv.advisories.items().len(), ADVISORY_DISPLAY_CAP);
1196 record.assert_async().await;
1197 }
1198
1199 #[tokio::test]
1200 async fn scan_second_call_within_ttl_issues_zero_requests() {
1201 let (mut server, client) = mock_client().await;
1202 let batch = server
1203 .mock("POST", "/v1/querybatch")
1204 .with_status(200)
1205 .with_body(r#"{"results":[{}]}"#)
1206 .expect(1)
1207 .create_async()
1208 .await;
1209
1210 let targets = vec![target("pkg", "1.0.0")];
1211 client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1212 client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1213
1214 batch.assert_async().await;
1215 }
1216
1217 #[tokio::test]
1218 async fn check_candidates_maps_clean_and_vulnerable() {
1219 let (mut server, client) = mock_client().await;
1220 let _batch = server
1221 .mock("POST", "/v1/querybatch")
1222 .with_status(200)
1223 .with_body(
1224 r#"{"results":[{},{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
1225 )
1226 .create_async()
1227 .await;
1228 let _record = server
1229 .mock("GET", "/v1/vulns/ADV-1")
1230 .with_status(200)
1231 .with_body(r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}"#)
1232 .create_async()
1233 .await;
1234
1235 let candidates = vec![target("clean-pkg", "2.0.0"), target("bad-pkg", "2.0.0")];
1236 let statuses = client
1237 .check_candidates(EcosystemId::Npm, &candidates, TEST_TIMEOUT)
1238 .await;
1239
1240 assert_matches!(
1241 statuses.get("clean-pkg"),
1242 Some(UpgradeStatus::CandidateClean { version }) if version == "2.0.0"
1243 );
1244 assert_matches!(
1245 statuses.get("bad-pkg"),
1246 Some(UpgradeStatus::CandidateVulnerable { version, advisory_ids })
1247 if version == "2.0.0"
1248 && advisory_ids.items() == ["ADV-1".to_string()]
1249 && advisory_ids.total() == 1
1250 );
1251 }
1252
1253 #[tokio::test]
1254 async fn check_candidates_uses_display_version_not_wire_version() {
1255 let (mut server, client) = mock_client().await;
1261 let _batch = server
1262 .mock("POST", "/v1/querybatch")
1263 .with_status(200)
1264 .with_body(r#"{"results":[{}]}"#)
1265 .create_async()
1266 .await;
1267
1268 let candidate = ScanTarget {
1269 key: "golang.org/x/text".to_string(),
1270 osv_name: "golang.org/x/text".to_string(),
1271 version: "0.4.0".to_string(),
1272 display_version: "v0.4.0".to_string(),
1273 };
1274 let statuses = client
1275 .check_candidates(EcosystemId::Go, &[candidate], TEST_TIMEOUT)
1276 .await;
1277
1278 assert_matches!(
1279 statuses.get("golang.org/x/text"),
1280 Some(UpgradeStatus::CandidateClean { version }) if version == "v0.4.0"
1281 );
1282 }
1283
1284 #[test]
1285 fn query_cache_evicts_oldest_when_max_entries_reached() {
1286 let client = client();
1287 for i in 0..MAX_CACHE_ENTRIES {
1288 client.store_query_cache("npm", &target(&format!("pkg-{i}"), "1.0.0"), &[]);
1289 }
1290 assert_eq!(client.query_cache.len(), MAX_CACHE_ENTRIES);
1291
1292 client.store_query_cache("npm", &target("overflow", "1.0.0"), &[]);
1293
1294 assert!(
1295 client.query_cache.len() <= MAX_CACHE_ENTRIES,
1296 "query_cache must stay bounded at MAX_CACHE_ENTRIES, got {}",
1297 client.query_cache.len()
1298 );
1299 assert!(client.query_cache.len() < MAX_CACHE_ENTRIES + 1);
1300 }
1301
1302 #[test]
1303 fn record_cache_evicts_oldest_when_max_entries_reached() {
1304 let client = client();
1305 for i in 0..MAX_CACHE_ENTRIES {
1306 let advisory = Arc::new(Advisory {
1307 id: format!("ADV-{i}"),
1308 modified: "2023-01-01T00:00:00Z".to_string(),
1309 summary: None,
1310 aliases: vec![],
1311 severity: VulnSeverity::Unknown,
1312 cvss_vector: None,
1313 fixed_versions: vec![],
1314 url: format!("https://osv.dev/vulnerability/ADV-{i}"),
1315 });
1316 client.store_record_cache(&advisory);
1317 }
1318 assert_eq!(client.record_cache.len(), MAX_CACHE_ENTRIES);
1319
1320 let overflow = Arc::new(Advisory {
1321 id: "ADV-overflow".to_string(),
1322 modified: "2023-01-01T00:00:00Z".to_string(),
1323 summary: None,
1324 aliases: vec![],
1325 severity: VulnSeverity::Unknown,
1326 cvss_vector: None,
1327 fixed_versions: vec![],
1328 url: "https://osv.dev/vulnerability/ADV-overflow".to_string(),
1329 });
1330 client.store_record_cache(&overflow);
1331
1332 assert!(
1333 client.record_cache.len() <= MAX_CACHE_ENTRIES,
1334 "record_cache must stay bounded at MAX_CACHE_ENTRIES, got {}",
1335 client.record_cache.len()
1336 );
1337 }
1338
1339 #[tokio::test]
1340 async fn query_cache_ttl_expiry_forces_requery() {
1341 let (mut server, client) = mock_client().await;
1342 let t = target("pkg", "1.0.0");
1343
1344 client.query_cache.insert(
1346 ("npm", t.osv_name.clone(), t.version.clone()),
1347 QueryCacheEntry {
1348 vuln_ids: vec![],
1349 fetched_at: Instant::now()
1350 .checked_sub(QUERY_CACHE_TTL + Duration::from_secs(1))
1351 .expect("test clock has more than QUERY_CACHE_TTL of headroom"),
1352 },
1353 );
1354
1355 let batch = server
1356 .mock("POST", "/v1/querybatch")
1357 .with_status(200)
1358 .with_body(r#"{"results":[{}]}"#)
1359 .expect(1)
1360 .create_async()
1361 .await;
1362
1363 client.scan(EcosystemId::Npm, &[t], TEST_TIMEOUT).await;
1364
1365 batch.assert_async().await;
1366 }
1367
1368 #[tokio::test]
1369 async fn record_cache_newer_modified_invalidates_and_refetches() {
1370 let (mut server, client) = mock_client().await;
1371
1372 client.record_cache.insert(
1374 "ADV-1".to_string(),
1375 RecordCacheEntry {
1376 advisory: Arc::new(Advisory {
1377 id: "ADV-1".to_string(),
1378 modified: "2020-01-01T00:00:00Z".to_string(),
1379 summary: Some("stale summary".to_string()),
1380 aliases: vec![],
1381 severity: VulnSeverity::Unknown,
1382 cvss_vector: None,
1383 fixed_versions: vec![],
1384 url: "https://osv.dev/vulnerability/ADV-1".to_string(),
1385 }),
1386 modified: "2020-01-01T00:00:00Z".to_string(),
1387 fetched_at: Instant::now(),
1388 },
1389 );
1390
1391 let _batch = server
1392 .mock("POST", "/v1/querybatch")
1393 .with_status(200)
1394 .with_body(
1395 r#"{"results":[{"vulns":[{"id":"ADV-1","modified":"2023-01-01T00:00:00Z"}]}]}"#,
1396 )
1397 .create_async()
1398 .await;
1399 let record = server
1400 .mock("GET", "/v1/vulns/ADV-1")
1401 .with_status(200)
1402 .with_body(
1403 r#"{"id":"ADV-1","modified":"2023-01-01T00:00:00Z","summary":"updated summary"}"#,
1404 )
1405 .expect(1)
1406 .create_async()
1407 .await;
1408
1409 let targets = vec![target("pkg", "1.0.0")];
1410 let outcomes = client.scan(EcosystemId::Npm, &targets, TEST_TIMEOUT).await;
1411
1412 let Some(ScanOutcome::Vulnerable(dv)) = outcomes.get("pkg") else {
1413 panic!("expected Vulnerable outcome");
1414 };
1415 assert_eq!(
1416 dv.advisories.items()[0].summary.as_deref(),
1417 Some("updated summary")
1418 );
1419 record.assert_async().await;
1420 }
1421}