1mod memo;
34mod types;
35mod typosquat;
36
37use std::collections::HashMap;
38use std::sync::Arc;
39use std::time::{Duration, Instant};
40
41use dashmap::DashMap;
42use dashmap::DashSet;
43
44use memo::{CallOutcome, CoalescedMemo, DEPS_DEV_TTLS, GOSSIP_TTLS, TtlMemo};
45use types::{
46 DependentsWire, DepsDevProject, DepsDevVersionInfo, GetPackageWire, GossipBatchRequestWire,
47 GossipFindingType, GossipFindingsBatchRequestWire, GossipFindingsBatchWire, GossipFindingsWire,
48 GossipPackageKeyRefWire, GossipVersionFindingsWire, ProvenanceEntry, RelatedProject,
49 SimilarlyNamedPackagesWire,
50};
51pub use types::{
52 GossipCooldown, GossipFindings, GossipLowUsage, GossipRiskLevel, ProvenanceStatus,
53 ScorecardSummary, SupplyChainTrustSignal,
54};
55pub use typosquat::TyposquatSignal;
56use typosquat::{SimilarPackageCandidate, TYPOSQUAT_MAX_CANDIDATES_CHECKED, evaluate_candidates};
57
58use crate::EcosystemId;
59use crate::cache::{BodyLimit, HttpCache};
60use crate::error::DepsError;
61use crate::lsp_helpers::{is_dot_segment, warn_rejected_value};
62
63const DEPS_DEV_API: &str = "https://api.deps.dev";
64
65const DEPS_DEV_CALL_TIMEOUT: Duration = Duration::from_millis(400);
71
72const TYPOSQUAT_CALL_TIMEOUT: Duration = Duration::from_secs(3);
84
85const DEPS_DEV_SUCCESS_TTL: Duration = Duration::from_hours(1);
92
93const DEPS_DEV_ERROR_TTL: Duration = Duration::from_secs(90);
97
98const MAX_MEMO_ENTRIES: usize = 512;
102
103const DEPS_DEV_BODY_LIMIT: usize = 1024 * 1024;
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum FetchCompleteness {
120 Complete,
124 Incomplete,
128}
129
130impl FetchCompleteness {
131 #[must_use]
135 pub fn combine(self, other: Self) -> Self {
136 if self == Self::Incomplete || other == Self::Incomplete {
137 Self::Incomplete
138 } else {
139 Self::Complete
140 }
141 }
142}
143
144const GOSSIP_CALL_TIMEOUT: Duration = Duration::from_millis(400);
149
150const GOSSIP_BATCH_CALL_TIMEOUT: Duration = Duration::from_secs(5);
157
158const GOSSIP_BATCH_MAX_PAGES: usize = 20;
162
163const GOSSIP_SUCCESS_TTL: Duration = Duration::from_hours(1);
168
169const GOSSIP_ERROR_TTL: Duration = Duration::from_secs(90);
172
173const GOSSIP_PREFETCH_CONCURRENCY: usize = 8;
181
182const GOSSIP_REFRESH_BACKOFF: Duration = Duration::from_mins(15);
187
188#[derive(Debug, Hash, PartialEq, Eq, Clone)]
199struct MemoKey {
200 base: String,
201 system: DepsDevSystem,
202 name: String,
203 version: String,
204}
205
206#[derive(Debug, Hash, PartialEq, Eq, Clone)]
212struct ProjectKeyMemo {
213 base: String,
214 project_key: String,
216}
217
218#[derive(Debug, Hash, PartialEq, Eq, Clone)]
230struct SimilarityMemoKey {
231 base: String,
232 system: DepsDevSystem,
233 name: String,
234}
235
236#[derive(Debug, Hash, PartialEq, Eq, Clone)]
241struct PopularityMemoKey {
242 base: String,
243 system: DepsDevSystem,
244 name: String,
245}
246
247#[derive(Debug, Hash, PartialEq, Eq, Clone)]
252struct GossipMemoKey {
253 base: String,
254 system: DepsDevSystem,
255 name: String,
256}
257
258#[derive(Debug, Hash, PartialEq, Eq, Clone)]
263struct GossipVersionMemoKey {
264 base: String,
265 system: DepsDevSystem,
266 name: String,
267 version: String,
268}
269
270struct DashSetInFlightGuard<'a, K: std::hash::Hash + Eq> {
276 set: &'a DashSet<K>,
277 key: K,
278}
279
280impl<K: std::hash::Hash + Eq> Drop for DashSetInFlightGuard<'_, K> {
281 fn drop(&mut self) {
282 self.set.remove(&self.key);
283 }
284}
285
286#[must_use]
303pub(crate) const fn deps_dev_system(id: EcosystemId) -> Option<DepsDevSystem> {
304 match id {
305 EcosystemId::Npm => Some(DepsDevSystem::Npm),
306 EcosystemId::Cargo => Some(DepsDevSystem::Cargo),
307 EcosystemId::Go => Some(DepsDevSystem::Go),
308 EcosystemId::Maven => Some(DepsDevSystem::Maven),
309 EcosystemId::Pypi => Some(DepsDevSystem::Pypi),
310 EcosystemId::Bundler => Some(DepsDevSystem::Rubygems),
311 EcosystemId::NuGet => Some(DepsDevSystem::NuGet),
312 EcosystemId::Composer
313 | EcosystemId::Dart
314 | EcosystemId::Swift
315 | EcosystemId::Gradle
316 | EcosystemId::Deno
317 | EcosystemId::GithubActions
318 | EcosystemId::GitlabCi => None,
319 }
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
329pub enum DepsDevSystem {
330 Npm,
332 Cargo,
334 Go,
336 Maven,
338 Pypi,
340 Rubygems,
342 NuGet,
344}
345
346impl DepsDevSystem {
347 #[must_use]
349 const fn as_path_segment(self) -> &'static str {
350 match self {
351 Self::Npm => "npm",
352 Self::Cargo => "cargo",
353 Self::Go => "go",
354 Self::Maven => "maven",
355 Self::Pypi => "pypi",
356 Self::Rubygems => "rubygems",
357 Self::NuGet => "nuget",
358 }
359 }
360}
361
362fn is_host_label(segment: &str) -> bool {
365 !segment.is_empty()
366 && !segment.starts_with('-')
367 && !segment.ends_with('-')
368 && segment
369 .bytes()
370 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
371}
372
373fn is_valid_project_key(key: &str) -> bool {
385 let segments: Vec<&str> = key.split('/').collect();
386 if !(2..=4).contains(&segments.len()) || segments.iter().any(|s| s.is_empty()) {
387 return false;
388 }
389 if segments.iter().any(|s| is_dot_segment(s)) {
390 return false;
391 }
392 #[expect(
393 clippy::expect_used,
394 reason = "split_first cannot return None: the length check above already guarantees \
395 segments has at least 2 entries"
396 )]
397 let (host, rest) = segments
398 .split_first()
399 .expect("segments has at least 2 entries");
400 if !host.contains('.') || !host.split('.').all(is_host_label) {
401 return false;
402 }
403 rest.iter().all(|s| {
404 s.bytes()
405 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
406 })
407}
408
409fn classify_provenance(
412 slsa: &[ProvenanceEntry],
413 attestations: &[ProvenanceEntry],
414) -> ProvenanceStatus {
415 if slsa.is_empty() && attestations.is_empty() {
416 ProvenanceStatus::None
417 } else if slsa.iter().chain(attestations).any(|e| e.verified) {
418 ProvenanceStatus::Verified
419 } else {
420 ProvenanceStatus::Unverified
421 }
422}
423
424fn choose_project_key(projects: &[RelatedProject]) -> Option<(String, bool)> {
429 let attested = projects
430 .iter()
431 .find(|p| p.relation_type == "SOURCE_REPO" && p.relation_provenance == "SLSA_ATTESTATION");
432 let (chosen, self_reported) = attested.map(|p| (p, false)).or_else(|| {
433 projects
434 .iter()
435 .find(|p| p.relation_type == "SOURCE_REPO")
436 .map(|p| (p, true))
437 })?;
438
439 is_valid_project_key(&chosen.project_key.id)
440 .then(|| (chosen.project_key.id.clone(), self_reported))
441}
442
443pub struct DepsDevClient {
448 cache: Arc<HttpCache>,
449 base_url: String,
450 trusted_origin: String,
451 signals: CoalescedMemo<MemoKey, Option<SupplyChainTrustSignal>>,
455 projects: TtlMemo<ProjectKeyMemo, Option<f32>>,
461 similarity: CoalescedMemo<SimilarityMemoKey, Vec<SimilarPackageCandidate>>,
467 popularity: CoalescedMemo<PopularityMemoKey, Option<u64>>,
473 gossip: TtlMemo<GossipMemoKey, Option<GossipFindings>>,
478 gossip_in_flight: DashSet<GossipMemoKey>,
482 gossip_versions: TtlMemo<GossipVersionMemoKey, Option<GossipFindings>>,
487 gossip_version_in_flight: DashSet<GossipVersionMemoKey>,
489 gossip_semaphore: tokio::sync::Semaphore,
492 gossip_last_refresh_attempt: DashMap<GossipMemoKey, Instant>,
498 gossip_refresh_in_flight: DashSet<GossipMemoKey>,
508}
509
510fn gossip_findings_from_entry(entry: &GossipVersionFindingsWire) -> GossipFindings {
520 let mut cooldown = None;
521 let mut low_usage = None;
522
523 for finding in &entry.findings {
524 match finding.finding_type {
525 GossipFindingType::Cooldown => {
526 if cooldown.is_none()
527 && let Some(ctx) = &finding.cooldown_context
528 && let Some(end) = crate::PublishTime::parse_rfc3339(&ctx.end)
529 {
530 cooldown = Some(GossipCooldown {
531 end,
532 risk: finding.risk.into(),
533 });
534 }
535 }
536 GossipFindingType::LowUsage => {
537 if low_usage.is_none() {
538 let alternative_packages = finding
539 .low_usage_context
540 .as_ref()
541 .map(|ctx| ctx.alternative_packages.clone())
542 .unwrap_or_default();
543 low_usage = Some(GossipLowUsage {
544 risk: finding.risk.into(),
545 alternative_packages,
546 });
547 }
548 }
549 GossipFindingType::Other => {}
550 }
551 }
552
553 if cooldown.is_none()
562 && let Some(end) = entry
563 .cooldown_end
564 .as_deref()
565 .and_then(crate::PublishTime::parse_rfc3339)
566 {
567 cooldown = Some(GossipCooldown {
568 end,
569 risk: GossipRiskLevel::Informational,
570 });
571 }
572
573 GossipFindings {
574 version: entry.version_key.version.clone(),
575 cooldown,
576 low_usage,
577 }
578}
579
580impl std::fmt::Debug for DepsDevClient {
584 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585 f.debug_struct("DepsDevClient").finish_non_exhaustive()
586 }
587}
588
589impl DepsDevClient {
590 #[must_use]
593 pub fn new(cache: Arc<HttpCache>) -> Self {
594 Self::with_base_url(cache, DEPS_DEV_API.to_string())
595 }
596
597 #[cfg(any(test, feature = "test-util"))]
600 #[must_use]
601 pub fn for_test(cache: Arc<HttpCache>, base_url: impl Into<String>) -> Self {
602 Self::with_base_url(cache, base_url.into())
603 }
604
605 fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
606 let trusted_origin = format!("{base_url}/");
607 Self {
608 cache,
609 base_url,
610 trusted_origin,
611 signals: CoalescedMemo::new(DEPS_DEV_TTLS),
612 projects: TtlMemo::new(DEPS_DEV_TTLS),
613 similarity: CoalescedMemo::new(DEPS_DEV_TTLS),
614 popularity: CoalescedMemo::new(DEPS_DEV_TTLS),
615 gossip: TtlMemo::new(GOSSIP_TTLS),
616 gossip_in_flight: DashSet::new(),
617 gossip_versions: TtlMemo::new(GOSSIP_TTLS),
618 gossip_version_in_flight: DashSet::new(),
619 gossip_semaphore: tokio::sync::Semaphore::new(GOSSIP_PREFETCH_CONCURRENCY),
620 gossip_last_refresh_attempt: DashMap::new(),
621 gossip_refresh_in_flight: DashSet::new(),
622 }
623 }
624
625 pub async fn trust_signal(
635 &self,
636 system: DepsDevSystem,
637 name: &str,
638 version: &str,
639 ) -> Option<SupplyChainTrustSignal> {
640 let key = MemoKey {
641 base: self.base_url.clone(),
642 system,
643 name: name.to_string(),
644 version: version.to_string(),
645 };
646
647 self.signals
648 .get_or_fetch(key, || self.fetch(system, name, version))
649 .await
650 .into_value()
651 }
652
653 #[tracing::instrument(
663 skip(self),
664 fields(url = %crate::redact::RedactedUrl::new(url))
665 )]
666 async fn get(&self, url: &str, timeout: Duration) -> Result<bytes::Bytes, DepsDevFetchError> {
667 match tokio::time::timeout(
668 timeout,
669 self.cache
670 .get_transport_only_with_headers_limited_trusted_origin(
671 url,
672 &[],
673 BodyLimit::new(DEPS_DEV_BODY_LIMIT),
674 &self.trusted_origin,
675 ),
676 )
677 .await
678 {
679 Ok(Ok(bytes)) => Ok(bytes),
680 Ok(Err(e)) if e.is_not_found() => Err(DepsDevFetchError::NotFound),
681 Ok(Err(e)) => Err(DepsDevFetchError::Failed(e)),
682 Err(_) => Err(DepsDevFetchError::TimedOut),
683 }
684 }
685
686 async fn get_json<W: serde::de::DeserializeOwned>(
693 &self,
694 url: &str,
695 timeout: Duration,
696 call: DepsDevCall<'_>,
697 ) -> Result<W, CallFailure> {
698 let label = call.label();
699 match self.get(url, timeout).await {
700 Ok(bytes) => match crate::parser::parse_json_checked::<W>(&bytes) {
701 Ok(value) => Ok(value),
702 Err(e) => {
703 tracing::debug!(error = %e, "deps.dev {label} response parse failed");
704 Err(CallFailure::Transient)
705 }
706 },
707 Err(DepsDevFetchError::NotFound) => Err(CallFailure::NotFound),
708 Err(DepsDevFetchError::Failed(e)) => {
710 let (status, cause) = e.safe_tracing_summary();
711 tracing::debug!(status = ?status, cause, "deps.dev {label} fetch failed");
712 Err(CallFailure::Transient)
713 }
714 Err(DepsDevFetchError::TimedOut) => {
715 if let DepsDevCall::Project { key } = &call {
716 tracing::debug!(project_key = key, "deps.dev {label} fetch timed out");
717 } else if let Some(name) = call.timed_out_name() {
718 tracing::debug!(
719 package = %crate::redact::redact_declaration_key(name),
720 "deps.dev {label} fetch timed out"
721 );
722 }
723 Err(CallFailure::Transient)
724 }
725 }
726 }
727
728 async fn fetch(
733 &self,
734 system: DepsDevSystem,
735 name: &str,
736 version: &str,
737 ) -> CallOutcome<Option<SupplyChainTrustSignal>> {
738 if is_dot_segment(name) {
739 warn_rejected_value("is_dot_segment", "deps.dev trust-signal request URL", name);
740 return CallOutcome::Definitive(None);
741 }
742 if is_dot_segment(version) {
743 warn_rejected_value(
744 "is_dot_segment",
745 "deps.dev trust-signal request URL",
746 version,
747 );
748 return CallOutcome::Definitive(None);
749 }
750
751 let version_url = format!(
752 "{}/v3/systems/{}/packages/{}/versions/{}",
753 self.base_url,
754 system.as_path_segment(),
755 urlencoding::encode(name),
756 urlencoding::encode(version),
757 );
758
759 let (provenance, related_projects, licenses) = match self
760 .get_json::<DepsDevVersionInfo>(
761 &version_url,
762 DEPS_DEV_CALL_TIMEOUT,
763 DepsDevCall::Version { name },
764 )
765 .await
766 {
767 Ok(info) => {
768 let provenance = classify_provenance(&info.slsa_provenances, &info.attestations);
769 (Some(provenance), info.related_projects, info.licenses)
770 }
771 Err(CallFailure::NotFound) => return CallOutcome::Definitive(None),
772 Err(CallFailure::Transient) => return CallOutcome::Degraded(None),
773 };
774
775 let (scorecard, project_completeness) = match choose_project_key(&related_projects) {
779 Some((project_key, self_reported)) => {
780 let (raw_score, completeness) =
781 self.fetch_scorecard(&project_key).await.into_parts();
782 let scorecard = raw_score.map(|overall_score| ScorecardSummary {
783 overall_score,
784 self_reported,
785 });
786 (scorecard, completeness)
787 }
788 None => (None, FetchCompleteness::Complete),
789 };
790
791 let signal = SupplyChainTrustSignal {
792 scorecard,
793 provenance,
794 licenses,
795 };
796 CallOutcome::with_completeness(Some(signal), project_completeness)
797 }
798
799 async fn fetch_scorecard(&self, project_key: &str) -> CallOutcome<Option<f32>> {
807 let memo_key = ProjectKeyMemo {
808 base: self.base_url.clone(),
809 project_key: project_key.to_string(),
810 };
811
812 if let Some(outcome) = self.projects.get_fresh(&memo_key) {
813 return outcome;
814 }
815
816 let url = format!(
817 "{}/v3/projects/{}",
818 self.base_url,
819 urlencoding::encode(project_key),
820 );
821
822 let outcome = match self
823 .get_json::<DepsDevProject>(
824 &url,
825 DEPS_DEV_CALL_TIMEOUT,
826 DepsDevCall::Project { key: project_key },
827 )
828 .await
829 {
830 Ok(project) => {
831 let overall_score = project
832 .scorecard
833 .and_then(|s| s.overall_score)
834 .filter(|score| (0.0..=10.0).contains(score));
835 CallOutcome::Definitive(overall_score)
836 }
837 Err(CallFailure::NotFound) => CallOutcome::Definitive(None),
838 Err(CallFailure::Transient) => CallOutcome::Degraded(None),
839 };
840
841 self.projects.insert(memo_key, outcome.clone());
842 outcome
843 }
844
845 pub async fn typosquat_signal(
861 &self,
862 system: DepsDevSystem,
863 name: &str,
864 ) -> (Option<TyposquatSignal>, FetchCompleteness) {
865 let (candidates, similarity_completeness) = self.similar_packages(system, name).await;
866 if candidates.is_empty() {
867 return (None, similarity_completeness);
868 }
869
870 let candidate_futures = candidates.iter().map(|candidate| async move {
883 let (dependent_count, completeness) = self.popularity(system, &candidate.name).await;
884 (candidate.name.clone(), dependent_count, completeness)
885 });
886 let ((declared_dependent_count, declared_completeness), resolved_candidates) =
887 futures::future::join(
888 self.popularity(system, name),
889 futures::future::join_all(candidate_futures),
890 )
891 .await;
892
893 let completeness = resolved_candidates.iter().fold(
898 similarity_completeness.combine(declared_completeness),
899 |acc, (_, _, c)| acc.combine(*c),
900 );
901
902 let Some(declared_dependent_count) = declared_dependent_count else {
903 return (None, completeness);
904 };
905
906 let resolved: Vec<(String, u64)> = resolved_candidates
907 .into_iter()
908 .filter_map(|(name, dependent_count, _)| dependent_count.map(|count| (name, count)))
909 .collect();
910
911 (
912 evaluate_candidates(name, declared_dependent_count, &resolved),
913 completeness,
914 )
915 }
916
917 async fn similar_packages(
922 &self,
923 system: DepsDevSystem,
924 name: &str,
925 ) -> (Vec<SimilarPackageCandidate>, FetchCompleteness) {
926 if is_dot_segment(name) {
927 warn_rejected_value(
928 "is_dot_segment",
929 "deps.dev similarly-named-packages request URL",
930 name,
931 );
932 return (Vec::new(), FetchCompleteness::Complete);
935 }
936
937 let key = SimilarityMemoKey {
938 base: self.base_url.clone(),
939 system,
940 name: name.to_string(),
941 };
942
943 self.similarity
944 .get_or_fetch(key, || self.fetch_similar_packages(system, name))
945 .await
946 .into_parts()
947 }
948
949 async fn fetch_similar_packages(
951 &self,
952 system: DepsDevSystem,
953 name: &str,
954 ) -> CallOutcome<Vec<SimilarPackageCandidate>> {
955 let url = format!(
956 "{}/v3alpha/systems/{}/packages/{}:similarlyNamedPackages",
957 self.base_url,
958 system.as_path_segment(),
959 urlencoding::encode(name),
960 );
961
962 match self
963 .get_json::<SimilarlyNamedPackagesWire>(
964 &url,
965 TYPOSQUAT_CALL_TIMEOUT,
966 DepsDevCall::SimilarlyNamed { name },
967 )
968 .await
969 {
970 Ok(wire) => {
971 let candidates = wire
977 .packages
978 .into_iter()
979 .map(|p| SimilarPackageCandidate {
980 name: p.package_key.name,
981 })
982 .filter(|candidate| candidate.name != name)
983 .take(TYPOSQUAT_MAX_CANDIDATES_CHECKED)
984 .collect();
985 CallOutcome::Definitive(candidates)
986 }
987 Err(CallFailure::NotFound) => CallOutcome::Definitive(Vec::new()),
988 Err(CallFailure::Transient) => CallOutcome::Degraded(Vec::new()),
989 }
990 }
991
992 async fn popularity(
997 &self,
998 system: DepsDevSystem,
999 name: &str,
1000 ) -> (Option<u64>, FetchCompleteness) {
1001 let key = PopularityMemoKey {
1002 base: self.base_url.clone(),
1003 system,
1004 name: name.to_string(),
1005 };
1006
1007 self.popularity
1008 .get_or_fetch(key, || self.fetch_popularity(system, name))
1009 .await
1010 .into_parts()
1011 }
1012
1013 async fn fetch_popularity(
1016 &self,
1017 system: DepsDevSystem,
1018 name: &str,
1019 ) -> CallOutcome<Option<u64>> {
1020 if is_dot_segment(name) {
1021 warn_rejected_value("is_dot_segment", "deps.dev package request URL", name);
1022 return CallOutcome::Definitive(None);
1023 }
1024
1025 let package_url = format!(
1026 "{}/v3alpha/systems/{}/packages/{}",
1027 self.base_url,
1028 system.as_path_segment(),
1029 urlencoding::encode(name),
1030 );
1031
1032 let default_version = match self
1033 .get_json::<GetPackageWire>(
1034 &package_url,
1035 TYPOSQUAT_CALL_TIMEOUT,
1036 DepsDevCall::Package { name },
1037 )
1038 .await
1039 {
1040 Ok(package) => match package.versions.into_iter().find(|v| v.is_default) {
1041 Some(v) => v.version_key.version,
1042 None => return CallOutcome::Definitive(None),
1043 },
1044 Err(CallFailure::NotFound) => return CallOutcome::Definitive(None),
1045 Err(CallFailure::Transient) => return CallOutcome::Degraded(None),
1046 };
1047
1048 if is_dot_segment(&default_version) {
1049 warn_rejected_value(
1050 "is_dot_segment",
1051 "deps.dev dependents request URL",
1052 &default_version,
1053 );
1054 return CallOutcome::Definitive(None);
1055 }
1056
1057 let dependents_url = format!(
1058 "{}/v3alpha/systems/{}/packages/{}/versions/{}:dependents",
1059 self.base_url,
1060 system.as_path_segment(),
1061 urlencoding::encode(name),
1062 urlencoding::encode(&default_version),
1063 );
1064
1065 match self
1066 .get_json::<DependentsWire>(
1067 &dependents_url,
1068 TYPOSQUAT_CALL_TIMEOUT,
1069 DepsDevCall::Dependents { name },
1070 )
1071 .await
1072 {
1073 Ok(wire) => CallOutcome::Definitive(Some(wire.dependent_count)),
1074 Err(CallFailure::NotFound | CallFailure::Transient) => CallOutcome::Degraded(None),
1080 }
1081 }
1082
1083 pub async fn gossip_findings_batch(
1105 &self,
1106 system: DepsDevSystem,
1107 names: &[String],
1108 ) -> HashMap<String, GossipFindings> {
1109 let mut result = HashMap::new();
1110 let mut misses: Vec<String> = Vec::new();
1111
1112 for name in names {
1113 let key = self.gossip_key(system, name);
1114 if let Some(outcome) = self.gossip.get_fresh(&key) {
1115 if let Some(findings) = outcome.into_value() {
1116 result.insert(name.clone(), findings);
1117 }
1118 continue;
1119 }
1120 misses.push(name.clone());
1121 }
1122
1123 if misses.is_empty() {
1124 return result;
1125 }
1126
1127 let mut claimed: Vec<String> = Vec::with_capacity(misses.len());
1128 let mut guards: Vec<DashSetInFlightGuard<'_, GossipMemoKey>> =
1140 Vec::with_capacity(misses.len());
1141 for name in misses {
1142 let key = self.gossip_key(system, &name);
1143 if self.gossip_in_flight.insert(key.clone()) {
1144 guards.push(DashSetInFlightGuard {
1145 set: &self.gossip_in_flight,
1146 key,
1147 });
1148 claimed.push(name);
1149 }
1150 }
1151 if claimed.is_empty() {
1152 return result;
1153 }
1154
1155 #[expect(
1159 clippy::expect_used,
1160 reason = "gossip_semaphore is never closed for the lifetime of this client"
1161 )]
1162 let _permit = self
1163 .gossip_semaphore
1164 .acquire()
1165 .await
1166 .expect("gossip_semaphore is never closed");
1167
1168 let fetched = self.fetch_gossip_batch(system, &claimed).await;
1169
1170 drop(guards);
1175
1176 for name in claimed {
1177 let outcome = match fetched.get(&name) {
1182 Some(findings) => CallOutcome::Definitive(findings.clone()),
1183 None => CallOutcome::Degraded(None),
1184 };
1185 if let Some(findings) = outcome.clone().into_value() {
1186 result.insert(name.clone(), findings);
1187 }
1188 self.gossip.insert(self.gossip_key(system, &name), outcome);
1189 }
1190
1191 result
1192 }
1193
1194 pub async fn force_refresh_gossip_findings(
1219 &self,
1220 system: DepsDevSystem,
1221 names: &[String],
1222 ) -> HashMap<String, GossipFindings> {
1223 let mut eligible: Vec<String> = Vec::with_capacity(names.len());
1224 let mut guards: Vec<DashSetInFlightGuard<'_, GossipMemoKey>> =
1225 Vec::with_capacity(names.len());
1226 let now = Instant::now();
1227
1228 for name in names {
1229 let key = self.gossip_key(system, name);
1230 if !self.gossip_refresh_in_flight.insert(key.clone()) {
1231 continue;
1232 }
1233 let guard = DashSetInFlightGuard {
1234 set: &self.gossip_refresh_in_flight,
1235 key: key.clone(),
1236 };
1237
1238 let throttled = self
1239 .gossip_last_refresh_attempt
1240 .get(&key)
1241 .is_some_and(|last| now.duration_since(*last) < GOSSIP_REFRESH_BACKOFF);
1242 if throttled {
1243 drop(guard);
1244 continue;
1245 }
1246 if !self.gossip_last_refresh_attempt.contains_key(&key) {
1247 crate::cache_policy::evict_expired_then_oldest(
1248 &self.gossip_last_refresh_attempt,
1249 MAX_MEMO_ENTRIES,
1250 |attempt| *attempt,
1251 |_| GOSSIP_REFRESH_BACKOFF,
1252 );
1253 }
1254 self.gossip_last_refresh_attempt.insert(key, now);
1255 eligible.push(name.clone());
1256 guards.push(guard);
1257 }
1258
1259 if eligible.is_empty() {
1260 return HashMap::new();
1261 }
1262
1263 for name in &eligible {
1266 self.gossip.remove(&self.gossip_key(system, name));
1267 }
1268
1269 #[expect(
1271 clippy::expect_used,
1272 reason = "gossip_semaphore is never closed for the lifetime of this client"
1273 )]
1274 let _permit = self
1275 .gossip_semaphore
1276 .acquire()
1277 .await
1278 .expect("gossip_semaphore is never closed");
1279
1280 let fetched = self.fetch_gossip_batch(system, &eligible).await;
1281
1282 drop(guards);
1286
1287 let mut result = HashMap::new();
1288 for name in eligible {
1289 let outcome = match fetched.get(&name) {
1290 Some(findings) => CallOutcome::Definitive(findings.clone()),
1291 None => CallOutcome::Degraded(None),
1292 };
1293 if let Some(findings) = outcome.clone().into_value() {
1294 result.insert(name.clone(), findings);
1295 }
1296 self.gossip.insert(self.gossip_key(system, &name), outcome);
1297 }
1298 result
1299 }
1300
1301 fn gossip_key(&self, system: DepsDevSystem, name: &str) -> GossipMemoKey {
1302 GossipMemoKey {
1303 base: self.base_url.clone(),
1304 system,
1305 name: name.to_string(),
1306 }
1307 }
1308
1309 async fn fetch_gossip_batch(
1319 &self,
1320 system: DepsDevSystem,
1321 names: &[String],
1322 ) -> HashMap<String, Option<GossipFindings>> {
1323 let url = format!("{}/v3alpha/findingsbatch", self.base_url);
1324 let system_upper = system.as_path_segment().to_uppercase();
1325 let requests: Vec<GossipBatchRequestWire<'_>> = names
1326 .iter()
1327 .map(|name| GossipBatchRequestWire {
1328 package_key: GossipPackageKeyRefWire {
1329 system: &system_upper,
1330 name,
1331 },
1332 })
1333 .collect();
1334
1335 let mut result = HashMap::new();
1336 let mut page_token: Option<String> = None;
1337
1338 for _ in 0..GOSSIP_BATCH_MAX_PAGES {
1339 let body = GossipFindingsBatchRequestWire {
1340 requests: &requests,
1341 page_token: page_token.as_deref(),
1342 };
1343
1344 let bytes = match tokio::time::timeout(
1345 GOSSIP_BATCH_CALL_TIMEOUT,
1346 self.cache.post_json_limited_trusted_origin(
1347 &url,
1348 &body,
1349 BodyLimit::new(DEPS_DEV_BODY_LIMIT),
1350 &self.trusted_origin,
1351 ),
1352 )
1353 .await
1354 {
1355 Ok(Ok(bytes)) => bytes,
1356 Ok(Err(e)) => {
1357 let (status, cause) = e.safe_tracing_summary();
1358 tracing::debug!(
1359 status = ?status,
1360 cause,
1361 "deps.dev GOSSIP findings batch fetch failed"
1362 );
1363 return HashMap::new();
1364 }
1365 Err(_) => {
1366 tracing::debug!("deps.dev GOSSIP findings batch fetch timed out");
1367 return HashMap::new();
1368 }
1369 };
1370
1371 let wire = match crate::parser::parse_json_checked::<GossipFindingsBatchWire>(&bytes) {
1372 Ok(wire) => wire,
1373 Err(e) => {
1374 tracing::debug!(
1375 error = %e,
1376 "deps.dev GOSSIP findings batch response parse failed"
1377 );
1378 return HashMap::new();
1379 }
1380 };
1381
1382 for entry in wire.responses {
1383 let name = entry.request.package_key.name;
1384 let findings = entry
1385 .findings
1386 .default_version
1387 .as_ref()
1388 .map(gossip_findings_from_entry);
1389 result.insert(name, findings);
1390 }
1391
1392 if wire.next_page_token.is_empty() {
1393 break;
1394 }
1395 page_token = Some(wire.next_page_token);
1396 }
1397
1398 result
1399 }
1400
1401 pub async fn gossip_findings_for_version(
1414 &self,
1415 system: DepsDevSystem,
1416 name: &str,
1417 version: &str,
1418 ) -> Option<GossipFindings> {
1419 let key = GossipVersionMemoKey {
1420 base: self.base_url.clone(),
1421 system,
1422 name: name.to_string(),
1423 version: version.to_string(),
1424 };
1425
1426 if let Some(outcome) = self.gossip_versions.get_fresh(&key) {
1427 return outcome.into_value();
1428 }
1429
1430 if !self.gossip_version_in_flight.insert(key.clone()) {
1431 return None;
1432 }
1433 let _guard = DashSetInFlightGuard {
1434 set: &self.gossip_version_in_flight,
1435 key: key.clone(),
1436 };
1437
1438 let outcome = self.fetch_gossip_version(system, name, version).await;
1439 let findings = outcome.clone().into_value();
1440 self.gossip_versions.insert(key, outcome);
1441 findings
1442 }
1443
1444 async fn fetch_gossip_version(
1451 &self,
1452 system: DepsDevSystem,
1453 name: &str,
1454 version: &str,
1455 ) -> CallOutcome<Option<GossipFindings>> {
1456 if is_dot_segment(name) {
1457 warn_rejected_value(
1458 "is_dot_segment",
1459 "deps.dev GOSSIP findings request URL",
1460 name,
1461 );
1462 return CallOutcome::Definitive(None);
1463 }
1464 if is_dot_segment(version) {
1465 warn_rejected_value(
1466 "is_dot_segment",
1467 "deps.dev GOSSIP findings request URL",
1468 version,
1469 );
1470 return CallOutcome::Definitive(None);
1471 }
1472
1473 let url = format!(
1474 "{}/v3alpha/systems/{}/packages/{}/versions/{}:findings",
1475 self.base_url,
1476 system.as_path_segment(),
1477 urlencoding::encode(name),
1478 urlencoding::encode(version),
1479 );
1480
1481 match self
1482 .get_json::<GossipFindingsWire>(
1483 &url,
1484 GOSSIP_CALL_TIMEOUT,
1485 DepsDevCall::GossipVersionFindings { name },
1486 )
1487 .await
1488 {
1489 Ok(wire) => {
1490 let findings = wire
1497 .requested_version
1498 .as_ref()
1499 .map(gossip_findings_from_entry);
1500 CallOutcome::Definitive(findings)
1501 }
1502 Err(CallFailure::NotFound) => CallOutcome::Definitive(None),
1503 Err(CallFailure::Transient) => CallOutcome::Degraded(None),
1504 }
1505 }
1506}
1507
1508enum DepsDevFetchError {
1512 NotFound,
1513 Failed(DepsError),
1514 TimedOut,
1515}
1516
1517enum DepsDevCall<'a> {
1521 Version { name: &'a str },
1523 Project { key: &'a str },
1525 SimilarlyNamed { name: &'a str },
1527 Package { name: &'a str },
1529 Dependents { name: &'a str },
1531 GossipVersionFindings { name: &'a str },
1533}
1534
1535impl DepsDevCall<'_> {
1536 const fn label(&self) -> &'static str {
1538 match self {
1539 Self::Version { .. } => "version",
1540 Self::Project { .. } => "project",
1541 Self::SimilarlyNamed { .. } => "similarly-named-packages",
1542 Self::Package { .. } => "package",
1543 Self::Dependents { .. } => "dependents",
1544 Self::GossipVersionFindings { .. } => "GOSSIP version findings",
1545 }
1546 }
1547
1548 const fn timed_out_name(&self) -> Option<&str> {
1552 match self {
1553 Self::Version { name }
1554 | Self::SimilarlyNamed { name }
1555 | Self::Package { name }
1556 | Self::Dependents { name }
1557 | Self::GossipVersionFindings { name } => Some(name),
1558 Self::Project { .. } => None,
1559 }
1560 }
1561}
1562
1563enum CallFailure {
1568 NotFound,
1570 Transient,
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576 use super::*;
1577
1578 fn client() -> DepsDevClient {
1579 DepsDevClient::new(Arc::new(HttpCache::new()))
1580 }
1581
1582 async fn mock_client() -> (mockito::ServerGuard, DepsDevClient) {
1583 let server = mockito::Server::new_async().await;
1584 let client = DepsDevClient::for_test(Arc::new(HttpCache::new()), server.url());
1585 (server, client)
1586 }
1587
1588 const EXPRESS_VERSION_NO_PROVENANCE: &str = r#"{
1589 "slsaProvenances": [],
1590 "attestations": [],
1591 "relatedProjects": [
1592 {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"},
1593 {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
1594 ]
1595 }"#;
1596
1597 const SIGSTORE_VERSION_VERIFIED: &str = r#"{
1598 "slsaProvenances": [{"verified": true, "sourceRepository": "github.com/sigstore/sigstore-js"}],
1599 "attestations": [],
1600 "relatedProjects": [
1601 {"projectKey": {"id": "github.com/sigstore/sigstore-js"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
1602 ]
1603 }"#;
1604
1605 const EXPRESS_PROJECT: &str = r#"{"scorecard": {"overallScore": 8.5}}"#;
1606
1607 #[test]
1608 fn deps_dev_system_covers_seven_ecosystems() {
1609 assert_eq!(deps_dev_system(EcosystemId::Npm), Some(DepsDevSystem::Npm));
1610 assert_eq!(
1611 deps_dev_system(EcosystemId::Cargo),
1612 Some(DepsDevSystem::Cargo)
1613 );
1614 assert_eq!(deps_dev_system(EcosystemId::Go), Some(DepsDevSystem::Go));
1615 assert_eq!(
1616 deps_dev_system(EcosystemId::Maven),
1617 Some(DepsDevSystem::Maven)
1618 );
1619 assert_eq!(
1620 deps_dev_system(EcosystemId::Pypi),
1621 Some(DepsDevSystem::Pypi)
1622 );
1623 assert_eq!(
1624 deps_dev_system(EcosystemId::Bundler),
1625 Some(DepsDevSystem::Rubygems)
1626 );
1627 assert_eq!(
1628 deps_dev_system(EcosystemId::NuGet),
1629 Some(DepsDevSystem::NuGet)
1630 );
1631 }
1632
1633 #[test]
1634 fn deps_dev_system_as_path_segment_matches_deps_dev_api() {
1635 assert_eq!(DepsDevSystem::Npm.as_path_segment(), "npm");
1636 assert_eq!(DepsDevSystem::Cargo.as_path_segment(), "cargo");
1637 assert_eq!(DepsDevSystem::Go.as_path_segment(), "go");
1638 assert_eq!(DepsDevSystem::Maven.as_path_segment(), "maven");
1639 assert_eq!(DepsDevSystem::Pypi.as_path_segment(), "pypi");
1640 assert_eq!(DepsDevSystem::Rubygems.as_path_segment(), "rubygems");
1641 assert_eq!(DepsDevSystem::NuGet.as_path_segment(), "nuget");
1642 }
1643
1644 #[test]
1645 fn deps_dev_system_excludes_uncovered_ecosystems() {
1646 assert_eq!(deps_dev_system(EcosystemId::Composer), None);
1647 assert_eq!(deps_dev_system(EcosystemId::Dart), None);
1648 assert_eq!(deps_dev_system(EcosystemId::Swift), None);
1649 assert_eq!(deps_dev_system(EcosystemId::Gradle), None);
1650 assert_eq!(deps_dev_system(EcosystemId::Deno), None);
1651 assert_eq!(deps_dev_system(EcosystemId::GithubActions), None);
1652 }
1653
1654 #[test]
1655 fn is_valid_project_key_accepts_github_style_key() {
1656 assert!(is_valid_project_key("github.com/expressjs/express"));
1657 }
1658
1659 #[test]
1660 fn is_valid_project_key_rejects_traversal() {
1661 assert!(!is_valid_project_key("github.com/../../etc"));
1662 assert!(!is_valid_project_key("github.com/expressjs/.."));
1663 }
1664
1665 #[test]
1666 fn is_valid_project_key_rejects_non_host_first_segment() {
1667 assert!(!is_valid_project_key("not-a-host/expressjs/express"));
1668 }
1669
1670 #[test]
1671 fn is_valid_project_key_rejects_too_few_or_too_many_segments() {
1672 assert!(!is_valid_project_key("github.com"));
1673 assert!(!is_valid_project_key("github.com/a/b/c/d"));
1674 }
1675
1676 #[test]
1677 fn classify_provenance_both_empty_is_none() {
1678 assert_eq!(classify_provenance(&[], &[]), ProvenanceStatus::None);
1679 }
1680
1681 #[test]
1682 fn classify_provenance_any_verified_is_verified() {
1683 let entries = [ProvenanceEntry { verified: true }];
1684 assert_eq!(
1685 classify_provenance(&entries, &[]),
1686 ProvenanceStatus::Verified
1687 );
1688 }
1689
1690 #[test]
1691 fn classify_provenance_nonempty_unverified_is_unverified() {
1692 let entries = [ProvenanceEntry { verified: false }];
1693 assert_eq!(
1694 classify_provenance(&entries, &[]),
1695 ProvenanceStatus::Unverified
1696 );
1697 }
1698
1699 #[tokio::test]
1700 async fn trust_signal_renders_score_and_verified_provenance() {
1701 let (mut server, client) = mock_client().await;
1702 let _version = server
1703 .mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
1704 .with_status(200)
1705 .with_body(SIGSTORE_VERSION_VERIFIED)
1706 .create_async()
1707 .await;
1708 let _project = server
1709 .mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
1710 .with_status(200)
1711 .with_body(r#"{"scorecard": {"overallScore": 9.1}}"#)
1712 .create_async()
1713 .await;
1714
1715 let signal = client
1716 .trust_signal(DepsDevSystem::Npm, "sigstore", "2.3.1")
1717 .await
1718 .expect("signal expected");
1719 assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
1720 let scorecard = signal.scorecard.expect("scorecard expected");
1721 assert!((scorecard.overall_score - 9.1).abs() < f32::EPSILON);
1722 assert!(!scorecard.self_reported);
1723 }
1724
1725 #[tokio::test]
1729 async fn trust_signal_parses_licenses_from_version_response() {
1730 let (mut server, client) = mock_client().await;
1731 let _version = server
1732 .mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
1733 .with_status(200)
1734 .with_body(
1735 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [], "licenses": ["MIT", "Apache-2.0"]}"#,
1736 )
1737 .create_async()
1738 .await;
1739
1740 let signal = client
1741 .trust_signal(DepsDevSystem::Npm, "sigstore", "2.3.1")
1742 .await
1743 .expect("signal expected");
1744 assert_eq!(
1745 signal.licenses,
1746 vec!["MIT".to_string(), "Apache-2.0".to_string()]
1747 );
1748 }
1749
1750 #[tokio::test]
1753 async fn trust_signal_missing_licenses_field_is_empty_vec() {
1754 let (mut server, client) = mock_client().await;
1755 let _version = server
1756 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1757 .with_status(200)
1758 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
1759 .create_async()
1760 .await;
1761
1762 let signal = client
1763 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
1764 .await
1765 .expect("signal expected");
1766 assert!(signal.licenses.is_empty());
1767 }
1768
1769 #[tokio::test]
1770 async fn trust_signal_self_reported_relation_is_marked() {
1771 let (mut server, client) = mock_client().await;
1772 let _version = server
1773 .mock("GET", "/v3/systems/npm/packages/left-pad/versions/1.0.0")
1774 .with_status(200)
1775 .with_body(
1776 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1777 {"projectKey": {"id": "github.com/example/left-pad"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
1778 ]}"#,
1779 )
1780 .create_async()
1781 .await;
1782 let _project = server
1783 .mock("GET", "/v3/projects/github.com%2Fexample%2Fleft-pad")
1784 .with_status(200)
1785 .with_body(EXPRESS_PROJECT)
1786 .create_async()
1787 .await;
1788
1789 let signal = client
1790 .trust_signal(DepsDevSystem::Npm, "left-pad", "1.0.0")
1791 .await
1792 .expect("signal expected");
1793 let scorecard = signal.scorecard.expect("scorecard expected");
1794 assert!(scorecard.self_reported);
1795 assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
1796 }
1797
1798 #[tokio::test]
1799 async fn trust_signal_both_endpoints_fail_returns_none() {
1800 let (mut server, client) = mock_client().await;
1801 let _version = server
1802 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1803 .with_status(500)
1804 .create_async()
1805 .await;
1806
1807 let signal = client
1808 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
1809 .await;
1810 assert!(signal.is_none());
1811 }
1812
1813 #[tokio::test]
1814 async fn trust_signal_project_call_fails_keeps_provenance() {
1815 let (mut server, client) = mock_client().await;
1816 let _version = server
1817 .mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
1818 .with_status(200)
1819 .with_body(SIGSTORE_VERSION_VERIFIED)
1820 .create_async()
1821 .await;
1822 let _project = server
1823 .mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
1824 .with_status(500)
1825 .create_async()
1826 .await;
1827
1828 let signal = client
1829 .trust_signal(DepsDevSystem::Npm, "sigstore", "2.3.1")
1830 .await
1831 .expect("signal expected");
1832 assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
1833 assert!(signal.scorecard.is_none());
1834 }
1835
1836 #[tokio::test]
1837 async fn trust_signal_no_source_repo_omits_scorecard_keeps_provenance() {
1838 let (mut server, client) = mock_client().await;
1839 let _version = server
1840 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1841 .with_status(200)
1842 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
1843 .create_async()
1844 .await;
1845 let _project = server
1846 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1847 .with_status(200)
1848 .with_body(EXPRESS_PROJECT)
1849 .create_async()
1850 .await;
1851
1852 let signal = client
1853 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
1854 .await
1855 .expect("signal expected");
1856 assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
1857 let scorecard = signal.scorecard.expect("scorecard expected");
1858 assert!((scorecard.overall_score - 8.5).abs() < f32::EPSILON);
1859 }
1860
1861 #[tokio::test]
1862 async fn trust_signal_malformed_json_returns_none() {
1863 let (mut server, client) = mock_client().await;
1864 let _version = server
1865 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1866 .with_status(200)
1867 .with_body("not json")
1868 .create_async()
1869 .await;
1870
1871 let signal = client
1872 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
1873 .await;
1874 assert!(signal.is_none());
1875 }
1876
1877 #[tokio::test]
1878 async fn trust_signal_404_plaintext_body_returns_none_no_panic() {
1879 let (mut server, client) = mock_client().await;
1880 let _version = server
1881 .mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
1882 .with_status(404)
1883 .with_body("version not found")
1884 .create_async()
1885 .await;
1886
1887 let signal = client
1888 .trust_signal(DepsDevSystem::Npm, "missing", "1.0.0")
1889 .await;
1890 assert!(signal.is_none());
1891 }
1892
1893 #[tokio::test]
1894 async fn trust_signal_scorecard_overall_score_absent_omits_scorecard_never_zero() {
1895 let (mut server, client) = mock_client().await;
1896 let _version = server
1897 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1898 .with_status(200)
1899 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
1900 .create_async()
1901 .await;
1902 let _project = server
1903 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1904 .with_status(200)
1905 .with_body(r#"{"scorecard": {}}"#)
1906 .create_async()
1907 .await;
1908
1909 let signal = client
1910 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
1911 .await
1912 .expect("signal expected (provenance still present)");
1913 assert!(signal.scorecard.is_none());
1914 }
1915
1916 #[tokio::test]
1917 async fn trust_signal_second_call_within_ttl_issues_zero_requests() {
1918 let (mut server, client) = mock_client().await;
1919 let version = server
1920 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1921 .with_status(200)
1922 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
1923 .expect(1)
1924 .create_async()
1925 .await;
1926 let project = server
1927 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1928 .with_status(200)
1929 .with_body(EXPRESS_PROJECT)
1930 .expect(1)
1931 .create_async()
1932 .await;
1933
1934 client
1935 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
1936 .await;
1937 client
1938 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
1939 .await;
1940
1941 version.assert_async().await;
1942 project.assert_async().await;
1943 }
1944
1945 #[tokio::test]
1946 async fn trust_signal_404_is_not_requeried_within_success_ttl() {
1947 let (mut server, client) = mock_client().await;
1948 let version = server
1949 .mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
1950 .with_status(404)
1951 .expect(1)
1952 .create_async()
1953 .await;
1954
1955 client
1956 .trust_signal(DepsDevSystem::Npm, "missing", "1.0.0")
1957 .await;
1958 client
1959 .trust_signal(DepsDevSystem::Npm, "missing", "1.0.0")
1960 .await;
1961
1962 version.assert_async().await;
1963 }
1964
1965 #[tokio::test]
1966 async fn trust_signal_two_packages_sharing_project_key_issue_one_project_call() {
1967 let (mut server, client) = mock_client().await;
1968 let _v1 = server
1969 .mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
1970 .with_status(200)
1971 .with_body(
1972 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1973 {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
1974 ]}"#,
1975 )
1976 .create_async()
1977 .await;
1978 let _v2 = server
1979 .mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
1980 .with_status(200)
1981 .with_body(
1982 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1983 {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
1984 ]}"#,
1985 )
1986 .create_async()
1987 .await;
1988 let project = server
1989 .mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
1990 .with_status(200)
1991 .with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
1992 .expect(1)
1993 .create_async()
1994 .await;
1995
1996 client
1997 .trust_signal(DepsDevSystem::Npm, "pkg-a", "1.0.0")
1998 .await;
1999 client
2000 .trust_signal(DepsDevSystem::Npm, "pkg-b", "1.0.0")
2001 .await;
2002
2003 project.assert_async().await;
2004 }
2005
2006 #[tokio::test]
2007 async fn trust_signal_percent_encodes_go_module_path() {
2008 let (mut server, client) = mock_client().await;
2009 let version = server
2010 .mock(
2011 "GET",
2012 "/v3/systems/go/packages/golang.org%2Fx%2Ftext/versions/v0.4.0",
2013 )
2014 .with_status(200)
2015 .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
2016 .expect(1)
2017 .create_async()
2018 .await;
2019
2020 client
2021 .trust_signal(DepsDevSystem::Go, "golang.org/x/text", "v0.4.0")
2022 .await;
2023
2024 version.assert_async().await;
2025 }
2026
2027 #[tokio::test]
2028 async fn trust_signal_percent_encodes_scoped_npm_name() {
2029 let (mut server, client) = mock_client().await;
2030 let version = server
2031 .mock(
2032 "GET",
2033 "/v3/systems/npm/packages/%40types%2Fnode/versions/20.0.0",
2034 )
2035 .with_status(200)
2036 .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
2037 .expect(1)
2038 .create_async()
2039 .await;
2040
2041 client
2042 .trust_signal(DepsDevSystem::Npm, "@types/node", "20.0.0")
2043 .await;
2044
2045 version.assert_async().await;
2046 }
2047
2048 #[tokio::test]
2049 async fn trust_signal_percent_encodes_maven_coordinate() {
2050 let (mut server, client) = mock_client().await;
2051 let version = server
2052 .mock(
2053 "GET",
2054 "/v3/systems/maven/packages/com.google.guava%3Aguava/versions/32.0.0",
2055 )
2056 .with_status(200)
2057 .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
2058 .expect(1)
2059 .create_async()
2060 .await;
2061
2062 client
2063 .trust_signal(DepsDevSystem::Maven, "com.google.guava:guava", "32.0.0")
2064 .await;
2065
2066 version.assert_async().await;
2067 }
2068
2069 #[tokio::test]
2070 async fn trust_signal_memo_keys_do_not_alias_on_control_characters() {
2071 let client = client();
2072 client.signals.memo().insert(
2073 MemoKey {
2074 base: "https://api.deps.dev".to_string(),
2075 system: DepsDevSystem::Npm,
2076 name: "a\0b".to_string(),
2077 version: "c".to_string(),
2078 },
2079 CallOutcome::Definitive(Some(SupplyChainTrustSignal::default())),
2080 );
2081 assert!(!client.signals.memo().contains_key(&MemoKey {
2082 base: "https://api.deps.dev".to_string(),
2083 system: DepsDevSystem::Npm,
2084 name: "a".to_string(),
2085 version: "b\0c".to_string(),
2086 }));
2087 }
2088
2089 #[tokio::test]
2090 async fn trust_signal_invalid_project_key_issues_zero_project_requests() {
2091 let (mut server, client) = mock_client().await;
2092 let _version = server
2093 .mock("GET", "/v3/systems/npm/packages/evil/versions/1.0.0")
2094 .with_status(200)
2095 .with_body(
2096 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
2097 {"projectKey": {"id": "github.com/../../etc"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
2098 ]}"#,
2099 )
2100 .create_async()
2101 .await;
2102 let project = server
2103 .mock("GET", mockito::Matcher::Regex(r"^/v3/projects/.*".into()))
2104 .expect(0)
2105 .create_async()
2106 .await;
2107
2108 let signal = client
2109 .trust_signal(DepsDevSystem::Npm, "evil", "1.0.0")
2110 .await
2111 .expect("signal expected (provenance still present)");
2112 assert!(signal.scorecard.is_none());
2113 project.assert_async().await;
2114 }
2115
2116 #[tokio::test]
2120 async fn trust_signal_dot_segment_name_rejected_before_request() {
2121 let (mut server, client) = mock_client().await;
2122 let call = server
2123 .mock("GET", mockito::Matcher::Any)
2124 .expect(0)
2125 .create_async()
2126 .await;
2127
2128 assert!(
2129 client
2130 .trust_signal(DepsDevSystem::Npm, ".", "1.0.0")
2131 .await
2132 .is_none()
2133 );
2134 assert!(
2135 client
2136 .trust_signal(DepsDevSystem::Npm, "..", "1.0.0")
2137 .await
2138 .is_none()
2139 );
2140 call.assert_async().await;
2141 }
2142
2143 #[tokio::test]
2146 async fn trust_signal_dot_segment_version_rejected_before_request() {
2147 let (mut server, client) = mock_client().await;
2148 let call = server
2149 .mock("GET", mockito::Matcher::Any)
2150 .expect(0)
2151 .create_async()
2152 .await;
2153
2154 assert!(
2155 client
2156 .trust_signal(DepsDevSystem::Npm, "left-pad", ".")
2157 .await
2158 .is_none()
2159 );
2160 assert!(
2161 client
2162 .trust_signal(DepsDevSystem::Npm, "left-pad", "..")
2163 .await
2164 .is_none()
2165 );
2166 call.assert_async().await;
2167 }
2168
2169 #[tokio::test]
2170 async fn trust_signal_concurrent_calls_for_same_key_issue_one_request() {
2171 use std::sync::atomic::{AtomicUsize, Ordering};
2172
2173 let (mut server, client) = mock_client().await;
2174 let call_count = Arc::new(AtomicUsize::new(0));
2175 let call_count_clone = Arc::clone(&call_count);
2176 let client = Arc::new(client);
2177
2178 let _version = server
2179 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
2180 .with_status(200)
2181 .with_body_from_request(move |_req| {
2182 call_count_clone.fetch_add(1, Ordering::SeqCst);
2183 EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
2184 })
2185 .create_async()
2186 .await;
2187 let _project = server
2188 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
2189 .with_status(200)
2190 .with_body(EXPRESS_PROJECT)
2191 .create_async()
2192 .await;
2193
2194 let (a, b) = tokio::join!(
2195 {
2196 let client = Arc::clone(&client);
2197 async move {
2198 client
2199 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2200 .await
2201 }
2202 },
2203 {
2204 let client = Arc::clone(&client);
2205 async move {
2206 client
2207 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2208 .await
2209 }
2210 }
2211 );
2212 assert!(a.is_some());
2216 assert!(b.is_some());
2217 assert_eq!(call_count.load(Ordering::SeqCst), 1);
2218 }
2219
2220 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2232 async fn trust_signal_survives_dropped_join_handle_and_warms_memo() {
2233 let (mut server, client) = mock_client().await;
2234 let client = Arc::new(client);
2235 let version = server
2236 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
2237 .with_status(200)
2238 .with_body_from_request(|_req| {
2239 std::thread::sleep(Duration::from_millis(60));
2240 EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
2241 })
2242 .expect(1)
2243 .create_async()
2244 .await;
2245
2246 let spawn_client = Arc::clone(&client);
2247 let handle = tokio::spawn(async move {
2248 spawn_client
2249 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2250 .await
2251 });
2252 let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
2255 assert!(
2256 outcome.is_err(),
2257 "the artificial budget must elapse before the mock responds"
2258 );
2259 tokio::time::sleep(Duration::from_millis(250)).await;
2265
2266 let second = client
2267 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2268 .await;
2269 assert!(
2270 second.is_some(),
2271 "the memo warmed by the detached task must serve the next call"
2272 );
2273 version.assert_async().await;
2274 }
2275
2276 #[tokio::test]
2285 async fn trust_signal_project_memo_never_leaks_self_reported_across_packages() {
2286 let (mut server, client) = mock_client().await;
2287 let _version_a = server
2288 .mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
2289 .with_status(200)
2290 .with_body(
2291 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
2292 {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
2293 ]}"#,
2294 )
2295 .create_async()
2296 .await;
2297 let _version_b = server
2298 .mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
2299 .with_status(200)
2300 .with_body(
2301 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
2302 {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
2303 ]}"#,
2304 )
2305 .create_async()
2306 .await;
2307 let project = server
2308 .mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
2309 .with_status(200)
2310 .with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
2311 .expect(1)
2312 .create_async()
2313 .await;
2314
2315 let signal_a = client
2317 .trust_signal(DepsDevSystem::Npm, "pkg-a", "1.0.0")
2318 .await
2319 .expect("signal expected");
2320 assert!(
2321 !signal_a
2322 .scorecard
2323 .expect("scorecard expected")
2324 .self_reported,
2325 "A's attested relation must not be marked self-reported"
2326 );
2327
2328 let signal_b = client
2331 .trust_signal(DepsDevSystem::Npm, "pkg-b", "1.0.0")
2332 .await
2333 .expect("signal expected");
2334 assert!(
2335 signal_b
2336 .scorecard
2337 .expect("scorecard expected")
2338 .self_reported,
2339 "B's UNVERIFIED_METADATA relation must be marked self-reported even though A's \
2340 attested fetch warmed the shared project memo first"
2341 );
2342
2343 project.assert_async().await;
2345 }
2346
2347 #[tokio::test]
2352 async fn trust_signal_project_call_failure_downgrades_version_memo_ttl() {
2353 let (mut server, client) = mock_client().await;
2354 let _version = server
2355 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
2356 .with_status(200)
2357 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
2358 .create_async()
2359 .await;
2360 let _project = server
2361 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
2362 .with_status(500)
2363 .create_async()
2364 .await;
2365
2366 let signal = client
2367 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2368 .await
2369 .expect("signal expected (provenance still present)");
2370 assert!(signal.scorecard.is_none());
2371
2372 let key = MemoKey {
2373 base: client.base_url.clone(),
2374 system: DepsDevSystem::Npm,
2375 name: "express".to_string(),
2376 version: "4.19.2".to_string(),
2377 };
2378 let entry_ttl = client
2379 .signals
2380 .memo()
2381 .ttl_of(&key)
2382 .expect("memo entry expected");
2383 assert_eq!(
2384 entry_ttl, DEPS_DEV_ERROR_TTL,
2385 "a failed project call must downgrade the whole signal's memo TTL to the short \
2386 error TTL, not the 1h success TTL"
2387 );
2388 }
2389
2390 #[tokio::test]
2396 async fn trust_signal_project_overall_score_wrong_type_omits_scorecard_never_panics() {
2397 let (mut server, client) = mock_client().await;
2398 let _version = server
2399 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
2400 .with_status(200)
2401 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
2402 .create_async()
2403 .await;
2404 let _project = server
2405 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
2406 .with_status(200)
2407 .with_body(r#"{"scorecard": {"overallScore": "not-a-number"}}"#)
2408 .create_async()
2409 .await;
2410
2411 let signal = client
2412 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2413 .await
2414 .expect("signal expected (provenance still present)");
2415 assert!(signal.scorecard.is_none());
2416 }
2417
2418 #[tokio::test]
2422 async fn trust_signal_in_flight_claim_released_after_failure() {
2423 let (mut server, client) = mock_client().await;
2424 let _version = server
2425 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
2426 .with_status(500)
2427 .create_async()
2428 .await;
2429
2430 let signal = client
2431 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2432 .await;
2433 assert!(signal.is_none());
2434
2435 let key = MemoKey {
2436 base: client.base_url.clone(),
2437 system: DepsDevSystem::Npm,
2438 name: "express".to_string(),
2439 version: "4.19.2".to_string(),
2440 };
2441 assert!(
2442 !client.signals.in_flight_contains(&key),
2443 "the in-flight claim must be released after a failed fetch, not just a successful one"
2444 );
2445 }
2446
2447 #[tokio::test]
2452 async fn trust_signal_version_call_failure_ttl_is_error_ttl() {
2453 let (mut server, client) = mock_client().await;
2454 let _version = server
2455 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
2456 .with_status(500)
2457 .create_async()
2458 .await;
2459
2460 let signal = client
2461 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2462 .await;
2463 assert!(signal.is_none());
2464
2465 let key = MemoKey {
2466 base: client.base_url.clone(),
2467 system: DepsDevSystem::Npm,
2468 name: "express".to_string(),
2469 version: "4.19.2".to_string(),
2470 };
2471 let entry_ttl = client
2472 .signals
2473 .memo()
2474 .ttl_of(&key)
2475 .expect("memo entry expected");
2476 assert_eq!(
2477 entry_ttl, DEPS_DEV_ERROR_TTL,
2478 "a failed version call must memoize the short error TTL, not the 1h success TTL"
2479 );
2480 }
2481
2482 #[test]
2486 fn memo_evicts_when_max_entries_reached() {
2487 let client = client();
2488 for i in 0..MAX_MEMO_ENTRIES {
2489 client.signals.memo().insert(
2490 MemoKey {
2491 base: "https://api.deps.dev".to_string(),
2492 system: DepsDevSystem::Npm,
2493 name: format!("pkg-{i}"),
2494 version: "1.0.0".to_string(),
2495 },
2496 CallOutcome::Definitive(None),
2497 );
2498 }
2499 assert_eq!(client.signals.memo().len(), MAX_MEMO_ENTRIES);
2500
2501 client.signals.memo().insert(
2502 MemoKey {
2503 base: "https://api.deps.dev".to_string(),
2504 system: DepsDevSystem::Npm,
2505 name: "overflow".to_string(),
2506 version: "1.0.0".to_string(),
2507 },
2508 CallOutcome::Definitive(None),
2509 );
2510
2511 assert!(
2512 client.signals.memo().len() <= MAX_MEMO_ENTRIES,
2513 "memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
2514 client.signals.memo().len()
2515 );
2516 }
2517
2518 #[tokio::test]
2523 #[ignore = "requires network access"]
2524 async fn trust_signal_real_npm_express_carries_license() {
2525 let client = client();
2526 let signal = client
2527 .trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
2528 .await
2529 .expect("signal expected for a real, well-known package");
2530 assert!(
2531 !signal.licenses.is_empty(),
2532 "expected express@4.19.2 to report a real license"
2533 );
2534 }
2535
2536 #[test]
2539 fn project_memo_evicts_when_max_entries_reached() {
2540 let client = client();
2541 for i in 0..MAX_MEMO_ENTRIES {
2542 client.projects.insert(
2543 ProjectKeyMemo {
2544 base: "https://api.deps.dev".to_string(),
2545 project_key: format!("github.com/org/repo-{i}"),
2546 },
2547 CallOutcome::Definitive(Some(8.0)),
2548 );
2549 }
2550 assert_eq!(client.projects.len(), MAX_MEMO_ENTRIES);
2551
2552 client.projects.insert(
2553 ProjectKeyMemo {
2554 base: "https://api.deps.dev".to_string(),
2555 project_key: "github.com/org/overflow".to_string(),
2556 },
2557 CallOutcome::Definitive(Some(8.0)),
2558 );
2559
2560 assert!(
2561 client.projects.len() <= MAX_MEMO_ENTRIES,
2562 "projects memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
2563 client.projects.len()
2564 );
2565 }
2566
2567 #[tokio::test]
2571 async fn typosquat_signal_positive_case_fires_above_threshold() {
2572 let (mut server, client) = mock_client().await;
2573 let _similarity = server
2574 .mock(
2575 "GET",
2576 "/v3alpha/systems/npm/packages/crossenv:similarlyNamedPackages",
2577 )
2578 .with_status(200)
2579 .with_body(
2580 r#"{"packageKey": {"name": "crossenv"}, "packages": [{"packageKey": {"name": "cross-env"}}]}"#,
2581 )
2582 .create_async()
2583 .await;
2584 let _declared_package = server
2585 .mock("GET", "/v3alpha/systems/npm/packages/crossenv")
2586 .with_status(200)
2587 .with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
2588 .create_async()
2589 .await;
2590 let _declared_dependents = server
2591 .mock(
2592 "GET",
2593 "/v3alpha/systems/npm/packages/crossenv/versions/1.0.0:dependents",
2594 )
2595 .with_status(200)
2596 .with_body(r#"{"dependentCount": 3}"#)
2597 .create_async()
2598 .await;
2599 let _candidate_package = server
2600 .mock("GET", "/v3alpha/systems/npm/packages/cross-env")
2601 .with_status(200)
2602 .with_body(r#"{"versions": [{"versionKey": {"version": "7.0.0"}, "isDefault": true}]}"#)
2603 .create_async()
2604 .await;
2605 let _candidate_dependents = server
2606 .mock(
2607 "GET",
2608 "/v3alpha/systems/npm/packages/cross-env/versions/7.0.0:dependents",
2609 )
2610 .with_status(200)
2611 .with_body(r#"{"dependentCount": 900}"#)
2612 .create_async()
2613 .await;
2614
2615 let (signal, completeness) = client
2616 .typosquat_signal(DepsDevSystem::Npm, "crossenv")
2617 .await;
2618 let signal = signal.expect("300x ratio must fire");
2619 assert_eq!(signal.suspected_name, "cross-env");
2620 assert_eq!(signal.declared_dependent_count, 3);
2621 assert_eq!(signal.suspected_dependent_count, 900);
2622 assert_eq!(completeness, FetchCompleteness::Complete);
2623 }
2624
2625 #[tokio::test]
2628 async fn typosquat_signal_negative_case_below_threshold_returns_none() {
2629 let (mut server, client) = mock_client().await;
2630 let _similarity = server
2631 .mock(
2632 "GET",
2633 "/v3alpha/systems/npm/packages/coffeescript:similarlyNamedPackages",
2634 )
2635 .with_status(200)
2636 .with_body(
2637 r#"{"packageKey": {"name": "coffeescript"}, "packages": [{"packageKey": {"name": "coffee-script"}}]}"#,
2638 )
2639 .create_async()
2640 .await;
2641 let _declared_package = server
2642 .mock("GET", "/v3alpha/systems/npm/packages/coffeescript")
2643 .with_status(200)
2644 .with_body(r#"{"versions": [{"versionKey": {"version": "2.0.0"}, "isDefault": true}]}"#)
2645 .create_async()
2646 .await;
2647 let _declared_dependents = server
2648 .mock(
2649 "GET",
2650 "/v3alpha/systems/npm/packages/coffeescript/versions/2.0.0:dependents",
2651 )
2652 .with_status(200)
2653 .with_body(r#"{"dependentCount": 1213}"#)
2654 .create_async()
2655 .await;
2656 let _candidate_package = server
2657 .mock("GET", "/v3alpha/systems/npm/packages/coffee-script")
2658 .with_status(200)
2659 .with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
2660 .create_async()
2661 .await;
2662 let _candidate_dependents = server
2663 .mock(
2664 "GET",
2665 "/v3alpha/systems/npm/packages/coffee-script/versions/1.0.0:dependents",
2666 )
2667 .with_status(200)
2668 .with_body(r#"{"dependentCount": 8377}"#)
2669 .create_async()
2670 .await;
2671
2672 let (signal, completeness) = client
2673 .typosquat_signal(DepsDevSystem::Npm, "coffeescript")
2674 .await;
2675 assert!(
2676 signal.is_none(),
2677 "a ~6.9x ratio must stay well under the 50x threshold"
2678 );
2679 assert_eq!(completeness, FetchCompleteness::Complete);
2680 }
2681
2682 #[tokio::test]
2683 async fn typosquat_signal_404_on_similarity_returns_none_no_further_calls() {
2684 let (mut server, client) = mock_client().await;
2685 let _similarity = server
2686 .mock(
2687 "GET",
2688 "/v3alpha/systems/npm/packages/missing:similarlyNamedPackages",
2689 )
2690 .with_status(404)
2691 .create_async()
2692 .await;
2693 let package_call = server
2694 .mock(
2695 "GET",
2696 mockito::Matcher::Regex(r"^/v3alpha/systems/npm/packages/missing$".into()),
2697 )
2698 .expect(0)
2699 .create_async()
2700 .await;
2701
2702 let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "missing").await;
2703 assert!(signal.is_none());
2704 assert_eq!(
2705 completeness,
2706 FetchCompleteness::Complete,
2707 "a 404 is deps.dev's authoritative negative, not a failure to retry"
2708 );
2709 package_call.assert_async().await;
2710 }
2711
2712 #[tokio::test]
2718 async fn typosquat_signal_timeout_on_similarity_returns_none() {
2719 let (mut server, client) = mock_client().await;
2723 let _similarity = server
2724 .mock(
2725 "GET",
2726 "/v3alpha/systems/npm/packages/slow:similarlyNamedPackages",
2727 )
2728 .with_status(200)
2729 .with_body_from_request(|_req| {
2730 std::thread::sleep(Duration::from_millis(3200));
2731 br#"{"packageKey": {"name": "slow"}, "packages": []}"#.to_vec()
2732 })
2733 .create_async()
2734 .await;
2735
2736 let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "slow").await;
2737 assert!(signal.is_none());
2738 assert_eq!(completeness, FetchCompleteness::Incomplete);
2739 }
2740
2741 #[tokio::test]
2742 async fn typosquat_signal_malformed_json_on_similarity_returns_none() {
2743 let (mut server, client) = mock_client().await;
2744 let _similarity = server
2745 .mock(
2746 "GET",
2747 "/v3alpha/systems/npm/packages/broken:similarlyNamedPackages",
2748 )
2749 .with_status(200)
2750 .with_body("not json")
2751 .create_async()
2752 .await;
2753
2754 let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "broken").await;
2755 assert!(signal.is_none());
2756 assert_eq!(completeness, FetchCompleteness::Incomplete);
2757 }
2758
2759 #[tokio::test]
2766 async fn popularity_dot_segment_name_rejected_before_request() {
2767 let (mut server, client) = mock_client().await;
2768 let call = server
2769 .mock("GET", mockito::Matcher::Any)
2770 .expect(0)
2771 .create_async()
2772 .await;
2773
2774 assert!(client.popularity(DepsDevSystem::Npm, ".").await.0.is_none());
2775 assert!(
2776 client
2777 .popularity(DepsDevSystem::Npm, "..")
2778 .await
2779 .0
2780 .is_none()
2781 );
2782 call.assert_async().await;
2783 }
2784
2785 #[tokio::test]
2789 async fn popularity_dot_segment_default_version_rejected_before_dependents_request() {
2790 let (mut server, client) = mock_client().await;
2791 let _package = server
2792 .mock("GET", "/v3alpha/systems/npm/packages/evil")
2793 .with_status(200)
2794 .with_body(r#"{"versions": [{"versionKey": {"version": ".."}, "isDefault": true}]}"#)
2795 .create_async()
2796 .await;
2797 let dependents_call = server
2798 .mock(
2799 "GET",
2800 mockito::Matcher::Regex(
2801 r"^/v3alpha/systems/npm/packages/evil/versions/.*:dependents$".into(),
2802 ),
2803 )
2804 .expect(0)
2805 .create_async()
2806 .await;
2807
2808 let (dependent_count, _) = client.popularity(DepsDevSystem::Npm, "evil").await;
2809 assert!(dependent_count.is_none());
2810 dependents_call.assert_async().await;
2811 }
2812
2813 #[tokio::test]
2818 async fn typosquat_signal_dependents_404_memoizes_error_ttl_not_success_ttl() {
2819 let (mut server, client) = mock_client().await;
2820 let _package = server
2821 .mock("GET", "/v3alpha/systems/npm/packages/racy")
2822 .with_status(200)
2823 .with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
2824 .create_async()
2825 .await;
2826 let _dependents = server
2827 .mock(
2828 "GET",
2829 "/v3alpha/systems/npm/packages/racy/versions/1.0.0:dependents",
2830 )
2831 .with_status(404)
2832 .create_async()
2833 .await;
2834
2835 let (dependent_count, completeness) = client.popularity(DepsDevSystem::Npm, "racy").await;
2836 assert!(dependent_count.is_none());
2837 assert_eq!(
2838 completeness,
2839 FetchCompleteness::Incomplete,
2840 "issue #1463: a transient default-version race is not a verified negative and \
2841 must not be reported as a completed check"
2842 );
2843
2844 let key = PopularityMemoKey {
2845 base: client.base_url.clone(),
2846 system: DepsDevSystem::Npm,
2847 name: "racy".to_string(),
2848 };
2849 let entry = client
2850 .popularity
2851 .memo()
2852 .get_fresh(&key)
2853 .expect("popularity memo entry expected");
2854 assert_eq!(
2855 client
2856 .popularity
2857 .memo()
2858 .ttl_of(&key)
2859 .expect("popularity memo entry expected"),
2860 DEPS_DEV_ERROR_TTL,
2861 "a GetDependents 404 must memoize the short error TTL, not the 1h success TTL \
2862 a genuine GetPackage 404 (authoritative absence) uses"
2863 );
2864 assert_eq!(
2865 entry.completeness(),
2866 FetchCompleteness::Incomplete,
2867 "issue #1463: the memoized entry itself must carry the incomplete marker, so a \
2868 retry within the error TTL still reports Incomplete instead of a stale Complete"
2869 );
2870 }
2871
2872 #[tokio::test]
2873 async fn typosquat_signal_empty_packages_returns_none_no_popularity_calls() {
2874 let (mut server, client) = mock_client().await;
2875 let _similarity = server
2876 .mock(
2877 "GET",
2878 "/v3alpha/systems/npm/packages/lonely:similarlyNamedPackages",
2879 )
2880 .with_status(200)
2881 .with_body(r#"{"packageKey": {"name": "lonely"}, "packages": []}"#)
2882 .create_async()
2883 .await;
2884 let package_call = server
2885 .mock(
2886 "GET",
2887 mockito::Matcher::Regex(r"^/v3alpha/systems/npm/packages/lonely$".into()),
2888 )
2889 .expect(0)
2890 .create_async()
2891 .await;
2892
2893 let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "lonely").await;
2894 assert!(signal.is_none());
2895 assert_eq!(completeness, FetchCompleteness::Complete);
2896 package_call.assert_async().await;
2897 }
2898
2899 #[tokio::test]
2903 async fn typosquat_signal_excludes_self_match_candidate() {
2904 let (mut server, client) = mock_client().await;
2914 let similarity = server
2915 .mock(
2916 "GET",
2917 "/v3alpha/systems/npm/packages/self-echo:similarlyNamedPackages",
2918 )
2919 .with_status(200)
2920 .with_body(
2921 r#"{"packageKey": {"name": "self-echo"}, "packages": [{"packageKey": {"name": "self-echo"}}]}"#,
2922 )
2923 .expect(1)
2924 .create_async()
2925 .await;
2926 let declared_package = server
2927 .mock("GET", "/v3alpha/systems/npm/packages/self-echo")
2928 .expect(0)
2929 .create_async()
2930 .await;
2931 let declared_dependents = server
2932 .mock(
2933 "GET",
2934 "/v3alpha/systems/npm/packages/self-echo/versions/1.0.0:dependents",
2935 )
2936 .expect(0)
2937 .create_async()
2938 .await;
2939
2940 let (signal, completeness) = client
2941 .typosquat_signal(DepsDevSystem::Npm, "self-echo")
2942 .await;
2943 assert!(signal.is_none());
2944 assert_eq!(completeness, FetchCompleteness::Complete);
2945 similarity.assert_async().await;
2946 declared_package.assert_async().await;
2947 declared_dependents.assert_async().await;
2948 }
2949
2950 #[tokio::test]
2955 async fn typosquat_signal_caps_candidates_checked_at_five() {
2956 use std::sync::atomic::{AtomicUsize, Ordering};
2957
2958 let (mut server, client) = mock_client().await;
2959 let candidates_json: String = (0..20)
2960 .map(|i| format!(r#"{{"packageKey": {{"name": "cand-{i}"}}}}"#))
2961 .collect::<Vec<_>>()
2962 .join(",");
2963 let _similarity = server
2964 .mock(
2965 "GET",
2966 "/v3alpha/systems/npm/packages/tiny:similarlyNamedPackages",
2967 )
2968 .with_status(200)
2969 .with_body(format!(
2970 r#"{{"packageKey": {{"name": "tiny"}}, "packages": [{candidates_json}]}}"#
2971 ))
2972 .create_async()
2973 .await;
2974 let _declared_package = server
2975 .mock("GET", "/v3alpha/systems/npm/packages/tiny")
2976 .with_status(200)
2977 .with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
2978 .create_async()
2979 .await;
2980 let _declared_dependents = server
2981 .mock(
2982 "GET",
2983 "/v3alpha/systems/npm/packages/tiny/versions/1.0.0:dependents",
2984 )
2985 .with_status(200)
2986 .with_body(r#"{"dependentCount": 1}"#)
2987 .create_async()
2988 .await;
2989
2990 let call_count = Arc::new(AtomicUsize::new(0));
2991 let package_call_count = Arc::clone(&call_count);
2992 let _candidate_packages = server
2993 .mock(
2994 "GET",
2995 mockito::Matcher::Regex(r"^/v3alpha/systems/npm/packages/cand-\d+$".into()),
2996 )
2997 .with_status(200)
2998 .with_body_from_request(move |_req| {
2999 package_call_count.fetch_add(1, Ordering::SeqCst);
3000 br#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#
3001 .to_vec()
3002 })
3003 .create_async()
3004 .await;
3005 let dependents_call_count = Arc::clone(&call_count);
3006 let _candidate_dependents = server
3007 .mock(
3008 "GET",
3009 mockito::Matcher::Regex(
3010 r"^/v3alpha/systems/npm/packages/cand-\d+/versions/1\.0\.0:dependents$".into(),
3011 ),
3012 )
3013 .with_status(200)
3014 .with_body_from_request(move |_req| {
3015 dependents_call_count.fetch_add(1, Ordering::SeqCst);
3016 br#"{"dependentCount": 1000}"#.to_vec()
3017 })
3018 .create_async()
3019 .await;
3020
3021 let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "tiny").await;
3022 assert!(
3023 signal.is_some(),
3024 "at least one of the capped candidates should still qualify"
3025 );
3026 assert_eq!(completeness, FetchCompleteness::Complete);
3027 assert_eq!(
3028 call_count.load(Ordering::SeqCst),
3029 2 * TYPOSQUAT_MAX_CANDIDATES_CHECKED,
3030 "only TYPOSQUAT_MAX_CANDIDATES_CHECKED candidates should ever be resolved, \
3031 regardless of how many packages[] entries the response carries"
3032 );
3033 }
3034
3035 #[tokio::test]
3038 async fn similar_packages_dot_segment_name_rejected_before_request() {
3039 let (mut server, client) = mock_client().await;
3040 let call = server
3041 .mock("GET", mockito::Matcher::Any)
3042 .expect(0)
3043 .create_async()
3044 .await;
3045
3046 assert!(
3047 client
3048 .similar_packages(DepsDevSystem::Npm, ".")
3049 .await
3050 .0
3051 .is_empty()
3052 );
3053 assert!(
3054 client
3055 .similar_packages(DepsDevSystem::Npm, "..")
3056 .await
3057 .0
3058 .is_empty()
3059 );
3060 call.assert_async().await;
3061 }
3062
3063 #[tokio::test]
3069 async fn similar_packages_memo_stores_filtered_and_capped_candidates() {
3070 let (mut server, client) = mock_client().await;
3071 let mut candidates_json: Vec<String> = (0..20)
3072 .map(|i| format!(r#"{{"packageKey": {{"name": "cand-{i}"}}}}"#))
3073 .collect();
3074 candidates_json.push(r#"{"packageKey": {"name": "tiny"}}"#.to_string());
3077 let _similarity = server
3078 .mock(
3079 "GET",
3080 "/v3alpha/systems/npm/packages/tiny:similarlyNamedPackages",
3081 )
3082 .with_status(200)
3083 .with_body(format!(
3084 r#"{{"packageKey": {{"name": "tiny"}}, "packages": [{}]}}"#,
3085 candidates_json.join(",")
3086 ))
3087 .create_async()
3088 .await;
3089
3090 let (candidates, completeness) = client.similar_packages(DepsDevSystem::Npm, "tiny").await;
3091 assert_eq!(completeness, FetchCompleteness::Complete);
3092
3093 assert_eq!(
3094 candidates.len(),
3095 TYPOSQUAT_MAX_CANDIDATES_CHECKED,
3096 "the returned (and thus cached) list must already be capped"
3097 );
3098 assert!(
3099 candidates.iter().all(|c| c.name != "tiny"),
3100 "the self-match must be filtered before caching, not just before the ratio gate"
3101 );
3102
3103 let key = SimilarityMemoKey {
3104 base: client.base_url.clone(),
3105 system: DepsDevSystem::Npm,
3106 name: "tiny".to_string(),
3107 };
3108 let memo_len = client
3109 .similarity
3110 .memo()
3111 .get_fresh(&key)
3112 .expect("similarity memo entry expected")
3113 .into_value()
3114 .len();
3115 assert_eq!(
3116 memo_len, TYPOSQUAT_MAX_CANDIDATES_CHECKED,
3117 "the memo entry itself must hold only the capped set, not the full 21-entry \
3118 response"
3119 );
3120 }
3121
3122 #[tokio::test]
3123 async fn typosquat_signal_second_call_within_ttl_issues_zero_requests() {
3124 let (mut server, client) = mock_client().await;
3125 let similarity = server
3126 .mock(
3127 "GET",
3128 "/v3alpha/systems/npm/packages/crossenv:similarlyNamedPackages",
3129 )
3130 .with_status(200)
3131 .with_body(
3132 r#"{"packageKey": {"name": "crossenv"}, "packages": [{"packageKey": {"name": "cross-env"}}]}"#,
3133 )
3134 .expect(1)
3135 .create_async()
3136 .await;
3137 let declared_package = server
3138 .mock("GET", "/v3alpha/systems/npm/packages/crossenv")
3139 .with_status(200)
3140 .with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
3141 .expect(1)
3142 .create_async()
3143 .await;
3144 let declared_dependents = server
3145 .mock(
3146 "GET",
3147 "/v3alpha/systems/npm/packages/crossenv/versions/1.0.0:dependents",
3148 )
3149 .with_status(200)
3150 .with_body(r#"{"dependentCount": 3}"#)
3151 .expect(1)
3152 .create_async()
3153 .await;
3154 let candidate_package = server
3155 .mock("GET", "/v3alpha/systems/npm/packages/cross-env")
3156 .with_status(200)
3157 .with_body(r#"{"versions": [{"versionKey": {"version": "7.0.0"}, "isDefault": true}]}"#)
3158 .expect(1)
3159 .create_async()
3160 .await;
3161 let candidate_dependents = server
3162 .mock(
3163 "GET",
3164 "/v3alpha/systems/npm/packages/cross-env/versions/7.0.0:dependents",
3165 )
3166 .with_status(200)
3167 .with_body(r#"{"dependentCount": 900}"#)
3168 .expect(1)
3169 .create_async()
3170 .await;
3171
3172 client
3173 .typosquat_signal(DepsDevSystem::Npm, "crossenv")
3174 .await;
3175 client
3176 .typosquat_signal(DepsDevSystem::Npm, "crossenv")
3177 .await;
3178
3179 similarity.assert_async().await;
3180 declared_package.assert_async().await;
3181 declared_dependents.assert_async().await;
3182 candidate_package.assert_async().await;
3183 candidate_dependents.assert_async().await;
3184 }
3185
3186 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3193 async fn typosquat_signal_concurrent_calls_share_one_candidate_popularity_request() {
3194 use std::sync::atomic::{AtomicUsize, Ordering};
3195
3196 let (mut server, client) = mock_client().await;
3197 let client = Arc::new(client);
3198 let call_count = Arc::new(AtomicUsize::new(0));
3199
3200 let _similarity_a = server
3201 .mock(
3202 "GET",
3203 "/v3alpha/systems/npm/packages/pkg-a:similarlyNamedPackages",
3204 )
3205 .with_status(200)
3206 .with_body(
3207 r#"{"packageKey": {"name": "pkg-a"}, "packages": [{"packageKey": {"name": "popular-candidate"}}]}"#,
3208 )
3209 .create_async()
3210 .await;
3211 let _similarity_b = server
3212 .mock(
3213 "GET",
3214 "/v3alpha/systems/npm/packages/pkg-b:similarlyNamedPackages",
3215 )
3216 .with_status(200)
3217 .with_body(
3218 r#"{"packageKey": {"name": "pkg-b"}, "packages": [{"packageKey": {"name": "popular-candidate"}}]}"#,
3219 )
3220 .create_async()
3221 .await;
3222 let _package_a = server
3223 .mock("GET", "/v3alpha/systems/npm/packages/pkg-a")
3224 .with_status(200)
3225 .with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
3226 .create_async()
3227 .await;
3228 let _package_b = server
3229 .mock("GET", "/v3alpha/systems/npm/packages/pkg-b")
3230 .with_status(200)
3231 .with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
3232 .create_async()
3233 .await;
3234 let _dependents_a = server
3235 .mock(
3236 "GET",
3237 "/v3alpha/systems/npm/packages/pkg-a/versions/1.0.0:dependents",
3238 )
3239 .with_status(200)
3240 .with_body(r#"{"dependentCount": 1}"#)
3241 .create_async()
3242 .await;
3243 let _dependents_b = server
3244 .mock(
3245 "GET",
3246 "/v3alpha/systems/npm/packages/pkg-b/versions/1.0.0:dependents",
3247 )
3248 .with_status(200)
3249 .with_body(r#"{"dependentCount": 1}"#)
3250 .create_async()
3251 .await;
3252 let call_count_clone = Arc::clone(&call_count);
3253 let _candidate_package = server
3254 .mock("GET", "/v3alpha/systems/npm/packages/popular-candidate")
3255 .with_status(200)
3256 .with_body_from_request(move |_req| {
3257 call_count_clone.fetch_add(1, Ordering::SeqCst);
3258 std::thread::sleep(Duration::from_millis(50));
3259 br#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#
3260 .to_vec()
3261 })
3262 .create_async()
3263 .await;
3264 let _candidate_dependents = server
3265 .mock(
3266 "GET",
3267 "/v3alpha/systems/npm/packages/popular-candidate/versions/1.0.0:dependents",
3268 )
3269 .with_status(200)
3270 .with_body(r#"{"dependentCount": 900}"#)
3271 .create_async()
3272 .await;
3273
3274 let ((a, a_completeness), (b, b_completeness)) = tokio::join!(
3275 {
3276 let client = Arc::clone(&client);
3277 async move { client.typosquat_signal(DepsDevSystem::Npm, "pkg-a").await }
3278 },
3279 {
3280 let client = Arc::clone(&client);
3281 async move { client.typosquat_signal(DepsDevSystem::Npm, "pkg-b").await }
3282 }
3283 );
3284 assert!(a.is_some(), "pkg-a must see the real typosquat signal");
3288 assert!(b.is_some(), "pkg-b must see the real typosquat signal");
3289 assert_eq!(a_completeness, FetchCompleteness::Complete);
3290 assert_eq!(b_completeness, FetchCompleteness::Complete);
3291 assert_eq!(
3292 call_count.load(Ordering::SeqCst),
3293 1,
3294 "exactly one of the two concurrent calls must fetch the shared candidate's \
3295 GetPackage; the other must await that in-flight fetch's result rather than \
3296 duplicating the request"
3297 );
3298 }
3299
3300 const GOSSIP_BATCH_VITE_AND_LEFT_PAD: &str = r#"{"responses":[
3309 {"request":{"packageKey":{"system":"NPM","name":"vite"}},
3310 "findings":{"packageKey":{"system":"NPM","name":"vite"},"recommendedVersions":[],
3311 "defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3312 "isDefault":true,
3313 "findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
3314 "cooldownContext":{"end":"2026-10-09T12:26:19Z"}}],
3315 "cooldownEnd":"2026-10-09T12:26:19Z"},
3316 "packageFindings":[]}},
3317 {"request":{"packageKey":{"system":"NPM","name":"left-pad"}},
3318 "findings":{"packageKey":{"system":"NPM","name":"left-pad"},
3319 "recommendedVersions":[{"versionKey":{"system":"NPM","name":"left-pad","version":"1.3.0"},
3320 "isDefault":true,
3321 "findings":[{"type":"DEPRECATED","risk":"RISK_MEDIUM",
3322 "deprecatedContext":{"reason":"use String.prototype.padStart()"}}],
3323 "cooldownEnd":"2018-04-24T01:10:45Z"}],
3324 "defaultVersion":{"versionKey":{"system":"NPM","name":"left-pad","version":"1.3.0"},
3325 "isDefault":true,
3326 "findings":[{"type":"DEPRECATED","risk":"RISK_MEDIUM",
3327 "deprecatedContext":{"reason":"use String.prototype.padStart()"}}],
3328 "cooldownEnd":"2018-04-24T01:10:45Z"},
3329 "packageFindings":[{"type":"DEPRECATED","risk":"RISK_MEDIUM"}]}}
3330 ], "nextPageToken":""}"#;
3331
3332 #[tokio::test]
3333 async fn gossip_findings_batch_parses_active_cooldown() {
3334 let (mut server, client) = mock_client().await;
3335 let _batch = server
3336 .mock("POST", "/v3alpha/findingsbatch")
3337 .with_status(200)
3338 .with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
3339 .create_async()
3340 .await;
3341
3342 let result = client
3343 .gossip_findings_batch(
3344 DepsDevSystem::Npm,
3345 &["vite".to_string(), "left-pad".to_string()],
3346 )
3347 .await;
3348
3349 let vite = result.get("vite").expect("vite findings expected");
3350 assert_eq!(vite.version, "8.3.1");
3351 let cooldown = vite.cooldown.expect("active cooldown expected");
3352 assert_eq!(cooldown.risk, GossipRiskLevel::High);
3353 assert!(cooldown.is_active(crate::PublishTime::from_unix_secs(0)));
3354
3355 let left_pad = result.get("left-pad").expect("left-pad findings expected");
3362 let left_pad_cooldown = left_pad
3363 .cooldown
3364 .expect("cooldownEnd fallback must still populate cooldown");
3365 assert!(!left_pad_cooldown.is_active(crate::PublishTime::now()));
3366 assert!(left_pad.low_usage.is_none());
3367 }
3368
3369 #[tokio::test]
3370 async fn gossip_findings_batch_no_default_version_omits_name() {
3371 let (mut server, client) = mock_client().await;
3372 let _batch = server
3373 .mock("POST", "/v3alpha/findingsbatch")
3374 .with_status(200)
3375 .with_body(
3376 r#"{"responses":[{"request":{"packageKey":{"system":"NPM","name":"missing"}},
3377 "findings":{"packageKey":{"system":"NPM","name":"missing"},
3378 "recommendedVersions":[],
3379 "packageFindings":[{"type":"NOT_FOUND","risk":"RISK_CRITICAL"}]}}],
3380 "nextPageToken":""}"#,
3381 )
3382 .create_async()
3383 .await;
3384
3385 let result = client
3386 .gossip_findings_batch(DepsDevSystem::Npm, &["missing".to_string()])
3387 .await;
3388
3389 assert!(
3390 !result.contains_key("missing"),
3391 "NFR-002: a name with no resolvable defaultVersion must be absent, not present \
3392 with an empty GossipFindings"
3393 );
3394 }
3395
3396 #[tokio::test]
3397 async fn gossip_findings_batch_second_call_within_ttl_issues_zero_requests() {
3398 let (mut server, client) = mock_client().await;
3399 let batch = server
3400 .mock("POST", "/v3alpha/findingsbatch")
3401 .with_status(200)
3402 .with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
3403 .expect(1)
3404 .create_async()
3405 .await;
3406
3407 client
3408 .gossip_findings_batch(
3409 DepsDevSystem::Npm,
3410 &["vite".to_string(), "left-pad".to_string()],
3411 )
3412 .await;
3413 client
3414 .gossip_findings_batch(
3415 DepsDevSystem::Npm,
3416 &["vite".to_string(), "left-pad".to_string()],
3417 )
3418 .await;
3419
3420 batch.assert_async().await;
3421 }
3422
3423 #[tokio::test]
3424 async fn gossip_findings_batch_follows_next_page_token() {
3425 use std::sync::atomic::{AtomicUsize, Ordering};
3426
3427 let (mut server, client) = mock_client().await;
3428 let call_count = Arc::new(AtomicUsize::new(0));
3429 let call_count_clone = Arc::clone(&call_count);
3430 let _batch = server
3431 .mock("POST", "/v3alpha/findingsbatch")
3432 .with_status(200)
3433 .with_body_from_request(move |req| {
3434 let body = req.body().map(|b| String::from_utf8_lossy(b).into_owned());
3435 let is_page2 = body.is_ok_and(|b| b.contains("pageToken"));
3436 call_count_clone.fetch_add(1, Ordering::SeqCst);
3437 if is_page2 {
3438 br#"{"responses":[{"request":{"packageKey":{"system":"NPM","name":"left-pad"}},
3439 "findings":{"packageKey":{"system":"NPM","name":"left-pad"},
3440 "recommendedVersions":[],
3441 "defaultVersion":{"versionKey":{"system":"NPM","name":"left-pad","version":"1.3.0"}},
3442 "packageFindings":[]}}],
3443 "nextPageToken":""}"#
3444 .to_vec()
3445 } else {
3446 br#"{"responses":[{"request":{"packageKey":{"system":"NPM","name":"vite"}},
3447 "findings":{"packageKey":{"system":"NPM","name":"vite"},
3448 "recommendedVersions":[],
3449 "defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3450 "isDefault":true,
3451 "findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
3452 "cooldownContext":{"end":"2026-10-09T12:26:19Z"}}]},
3453 "packageFindings":[]}}],
3454 "nextPageToken":"page2"}"#
3455 .to_vec()
3456 }
3457 })
3458 .create_async()
3459 .await;
3460
3461 let result = client
3462 .gossip_findings_batch(
3463 DepsDevSystem::Npm,
3464 &["vite".to_string(), "left-pad".to_string()],
3465 )
3466 .await;
3467
3468 assert!(
3469 result.contains_key("vite"),
3470 "page 1's result must be included"
3471 );
3472 assert!(
3473 result.contains_key("left-pad"),
3474 "page 2's result must be included"
3475 );
3476 assert_eq!(
3477 call_count.load(Ordering::SeqCst),
3478 2,
3479 "exactly two pages must be fetched"
3480 );
3481 }
3482
3483 #[tokio::test]
3484 async fn gossip_findings_batch_oversized_page_returns_empty_and_no_panic() {
3485 let (mut server, client) = mock_client().await;
3486 let _batch = server
3487 .mock("POST", "/v3alpha/findingsbatch")
3488 .with_status(200)
3489 .with_body(format!(
3491 r#"{{"responses":[],"nextPageToken":"","padding":"{}"}}"#,
3492 "x".repeat(DEPS_DEV_BODY_LIMIT + 1)
3493 ))
3494 .create_async()
3495 .await;
3496
3497 let result = client
3498 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3499 .await;
3500 assert!(result.is_empty());
3501 }
3502
3503 #[tokio::test]
3504 async fn gossip_findings_batch_non_2xx_returns_empty_and_memoizes_error_ttl() {
3505 let (mut server, client) = mock_client().await;
3506 let _batch = server
3507 .mock("POST", "/v3alpha/findingsbatch")
3508 .with_status(500)
3509 .create_async()
3510 .await;
3511
3512 let result = client
3513 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3514 .await;
3515 assert!(result.is_empty());
3516
3517 let entry_ttl = client
3518 .gossip
3519 .ttl_of(&GossipMemoKey {
3520 base: client.base_url.clone(),
3521 system: DepsDevSystem::Npm,
3522 name: "vite".to_string(),
3523 })
3524 .expect("memo entry expected even on failure");
3525 assert_eq!(entry_ttl, GOSSIP_ERROR_TTL);
3526 }
3527
3528 #[tokio::test]
3529 async fn gossip_findings_batch_sends_uppercase_system() {
3530 let (mut server, client) = mock_client().await;
3531 let batch = server
3532 .mock("POST", "/v3alpha/findingsbatch")
3533 .match_body(mockito::Matcher::Regex(r#""system":"NPM""#.to_string()))
3534 .with_status(200)
3535 .with_body(r#"{"responses":[],"nextPageToken":""}"#)
3536 .expect(1)
3537 .create_async()
3538 .await;
3539
3540 client
3541 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3542 .await;
3543
3544 batch.assert_async().await;
3545 }
3546
3547 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3553 async fn gossip_findings_batch_concurrent_calls_for_same_name_issue_one_request() {
3554 use std::sync::atomic::{AtomicUsize, Ordering};
3555
3556 let (mut server, client) = mock_client().await;
3557 let client = Arc::new(client);
3558 let call_count = Arc::new(AtomicUsize::new(0));
3559 let call_count_clone = Arc::clone(&call_count);
3560 let _batch = server
3561 .mock("POST", "/v3alpha/findingsbatch")
3562 .with_status(200)
3563 .with_body_from_request(move |_req| {
3564 call_count_clone.fetch_add(1, Ordering::SeqCst);
3565 std::thread::sleep(Duration::from_millis(60));
3566 GOSSIP_BATCH_VITE_AND_LEFT_PAD.as_bytes().to_vec()
3567 })
3568 .create_async()
3569 .await;
3570
3571 let (a, b) = tokio::join!(
3572 {
3573 let client = Arc::clone(&client);
3574 async move {
3575 client
3576 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3577 .await
3578 }
3579 },
3580 {
3581 let client = Arc::clone(&client);
3582 async move {
3583 client
3584 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3585 .await
3586 }
3587 }
3588 );
3589 assert!(a.contains_key("vite") || b.contains_key("vite"));
3593 assert_eq!(
3594 call_count.load(Ordering::SeqCst),
3595 1,
3596 "exactly one of the two concurrent calls must issue the network request; the \
3597 other must see the in-flight claim and skip it rather than duplicate the \
3598 request"
3599 );
3600 }
3601
3602 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3609 async fn gossip_findings_batch_cancellation_releases_in_flight_claim() {
3610 let (mut server, client) = mock_client().await;
3611 let client = Arc::new(client);
3612 let batch = server
3613 .mock("POST", "/v3alpha/findingsbatch")
3614 .with_status(200)
3615 .with_body_from_request(|_req| {
3616 std::thread::sleep(Duration::from_millis(60));
3617 GOSSIP_BATCH_VITE_AND_LEFT_PAD.as_bytes().to_vec()
3618 })
3619 .expect(1)
3620 .create_async()
3621 .await;
3622
3623 let spawn_client = Arc::clone(&client);
3624 let handle = tokio::spawn(async move {
3625 spawn_client
3626 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3627 .await
3628 });
3629 let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
3632 assert!(
3633 outcome.is_err(),
3634 "the artificial budget must elapse before the mock responds"
3635 );
3636
3637 tokio::time::sleep(Duration::from_millis(250)).await;
3642
3643 let second = client
3644 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3645 .await;
3646 assert!(
3647 second.contains_key("vite"),
3648 "a leaked in-flight claim would make this call skip \"vite\" forever — got: \
3649 {second:?}"
3650 );
3651 batch.assert_async().await;
3652 }
3653
3654 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3658 async fn gossip_findings_batch_respects_global_concurrency_semaphore() {
3659 use std::sync::atomic::{AtomicUsize, Ordering};
3660
3661 let (mut server, client) = mock_client().await;
3662 let client = Arc::new(client);
3663 let concurrent = Arc::new(AtomicUsize::new(0));
3664 let max_concurrent = Arc::new(AtomicUsize::new(0));
3665 let concurrent_clone = Arc::clone(&concurrent);
3666 let max_clone = Arc::clone(&max_concurrent);
3667 let _batch = server
3668 .mock("POST", "/v3alpha/findingsbatch")
3669 .with_status(200)
3670 .with_body_from_request(move |_req| {
3671 let now = concurrent_clone.fetch_add(1, Ordering::SeqCst) + 1;
3672 max_clone.fetch_max(now, Ordering::SeqCst);
3673 std::thread::sleep(Duration::from_millis(60));
3674 concurrent_clone.fetch_sub(1, Ordering::SeqCst);
3675 br#"{"responses":[],"nextPageToken":""}"#.to_vec()
3676 })
3677 .create_async()
3678 .await;
3679
3680 let handles: Vec<_> = (0..(GOSSIP_PREFETCH_CONCURRENCY * 2))
3683 .map(|i| {
3684 let client = Arc::clone(&client);
3685 tokio::spawn(async move {
3686 client
3687 .gossip_findings_batch(DepsDevSystem::Npm, &[format!("pkg-{i}")])
3688 .await
3689 })
3690 })
3691 .collect();
3692 for handle in handles {
3693 handle.await.expect("task must not panic");
3694 }
3695
3696 let observed = max_concurrent.load(Ordering::SeqCst);
3697 assert!(
3698 observed <= GOSSIP_PREFETCH_CONCURRENCY,
3699 "observed {observed} concurrent GetFindingsBatch calls, expected at most \
3700 {GOSSIP_PREFETCH_CONCURRENCY}"
3701 );
3702 }
3703
3704 #[tokio::test]
3709 async fn gossip_findings_batch_oversized_second_page_rejects_whole_result() {
3710 use std::sync::atomic::{AtomicUsize, Ordering};
3711
3712 let (mut server, client) = mock_client().await;
3713 let call_count = Arc::new(AtomicUsize::new(0));
3714 let call_count_clone = Arc::clone(&call_count);
3715 let _batch = server
3716 .mock("POST", "/v3alpha/findingsbatch")
3717 .with_status(200)
3718 .with_body_from_request(move |req| {
3719 let body = req.body().map(|b| String::from_utf8_lossy(b).into_owned());
3720 let is_page2 = body.is_ok_and(|b| b.contains("pageToken"));
3721 call_count_clone.fetch_add(1, Ordering::SeqCst);
3722 if is_page2 {
3723 format!(
3724 r#"{{"responses":[],"nextPageToken":"","padding":"{}"}}"#,
3725 "x".repeat(DEPS_DEV_BODY_LIMIT + 1)
3726 )
3727 .into_bytes()
3728 } else {
3729 br#"{"responses":[{"request":{"packageKey":{"system":"NPM","name":"vite"}},
3730 "findings":{"packageKey":{"system":"NPM","name":"vite"},
3731 "recommendedVersions":[],
3732 "defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3733 "isDefault":true,"findings":[]},
3734 "packageFindings":[]}}],"nextPageToken":"page2"}"#
3735 .to_vec()
3736 }
3737 })
3738 .create_async()
3739 .await;
3740
3741 let result = client
3742 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3743 .await;
3744 assert!(
3745 result.is_empty(),
3746 "an oversized second page must reject the whole batch result (M1's accepted \
3747 all-or-nothing parsing) — got: {result:?}"
3748 );
3749 assert_eq!(
3750 call_count.load(Ordering::SeqCst),
3751 2,
3752 "both pages must have actually been requested"
3753 );
3754 }
3755
3756 #[tokio::test]
3761 async fn force_refresh_gossip_findings_bypasses_fresh_memo_entry() {
3762 let (mut server, client) = mock_client().await;
3763 let batch = server
3764 .mock("POST", "/v3alpha/findingsbatch")
3765 .with_status(200)
3766 .with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
3767 .expect(2)
3768 .create_async()
3769 .await;
3770
3771 client
3773 .gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
3774 .await;
3775
3776 let refreshed = client
3777 .force_refresh_gossip_findings(DepsDevSystem::Npm, &["vite".to_string()])
3778 .await;
3779 assert!(
3780 refreshed.contains_key("vite"),
3781 "force refresh must still return a result, not just bypass the memo silently"
3782 );
3783 batch.assert_async().await;
3784 }
3785
3786 #[tokio::test]
3789 async fn force_refresh_gossip_findings_throttles_within_backoff_window() {
3790 let (mut server, client) = mock_client().await;
3791 let batch = server
3792 .mock("POST", "/v3alpha/findingsbatch")
3793 .with_status(200)
3794 .with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
3795 .expect(1)
3796 .create_async()
3797 .await;
3798
3799 let first = client
3800 .force_refresh_gossip_findings(DepsDevSystem::Npm, &["vite".to_string()])
3801 .await;
3802 assert!(first.contains_key("vite"));
3803
3804 let second = client
3805 .force_refresh_gossip_findings(DepsDevSystem::Npm, &["vite".to_string()])
3806 .await;
3807 assert!(
3808 second.is_empty(),
3809 "a second force-refresh within the backoff window must be throttled, not \
3810 fetched again"
3811 );
3812
3813 batch.assert_async().await;
3814 }
3815
3816 #[tokio::test]
3819 async fn force_refresh_gossip_findings_throttle_is_per_package() {
3820 let (mut server, client) = mock_client().await;
3821 let batch = server
3822 .mock("POST", "/v3alpha/findingsbatch")
3823 .with_status(200)
3824 .with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
3825 .expect(2)
3826 .create_async()
3827 .await;
3828
3829 client
3830 .force_refresh_gossip_findings(DepsDevSystem::Npm, &["vite".to_string()])
3831 .await;
3832 client
3833 .force_refresh_gossip_findings(DepsDevSystem::Npm, &["left-pad".to_string()])
3834 .await;
3835
3836 batch.assert_async().await;
3840 }
3841
3842 #[test]
3845 fn gossip_last_refresh_attempt_evicts_when_max_entries_reached() {
3846 let client = client();
3847 let now = Instant::now();
3848 for i in 0..MAX_MEMO_ENTRIES {
3849 client.gossip_last_refresh_attempt.insert(
3850 GossipMemoKey {
3851 base: "https://api.deps.dev".to_string(),
3852 system: DepsDevSystem::Npm,
3853 name: format!("pkg-{i}"),
3854 },
3855 now,
3856 );
3857 }
3858 assert_eq!(client.gossip_last_refresh_attempt.len(), MAX_MEMO_ENTRIES);
3859
3860 crate::cache_policy::evict_expired_then_oldest(
3861 &client.gossip_last_refresh_attempt,
3862 MAX_MEMO_ENTRIES,
3863 |attempt| *attempt,
3864 |_| GOSSIP_REFRESH_BACKOFF,
3865 );
3866 client.gossip_last_refresh_attempt.insert(
3867 GossipMemoKey {
3868 base: "https://api.deps.dev".to_string(),
3869 system: DepsDevSystem::Npm,
3870 name: "overflow".to_string(),
3871 },
3872 now,
3873 );
3874
3875 assert!(
3876 client.gossip_last_refresh_attempt.len() <= MAX_MEMO_ENTRIES,
3877 "backoff map must stay bounded at MAX_MEMO_ENTRIES, got {}",
3878 client.gossip_last_refresh_attempt.len()
3879 );
3880 }
3881
3882 #[tokio::test]
3883 async fn gossip_findings_for_version_returns_requested_version_cooldown() {
3884 let (mut server, client) = mock_client().await;
3885 let _findings = server
3886 .mock("GET", "/v3alpha/systems/npm/packages/vite/versions/8.3.1:findings")
3887 .with_status(200)
3888 .with_body(
3889 r#"{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3890 "recommendedVersions":[],
3891 "requestedVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3892 "isDefault":true,
3893 "findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
3894 "cooldownContext":{"end":"2026-10-09T12:26:19Z"}}]},
3895 "defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3896 "isDefault":true,
3897 "findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
3898 "cooldownContext":{"end":"2026-10-09T12:26:19Z"}}]},
3899 "packageFindings":[]}"#,
3900 )
3901 .create_async()
3902 .await;
3903
3904 let findings = client
3905 .gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
3906 .await
3907 .expect("findings expected");
3908 assert_eq!(findings.version, "8.3.1");
3909 assert!(findings.cooldown.is_some());
3910 }
3911
3912 #[tokio::test]
3913 async fn gossip_findings_for_version_low_usage_context_parses_alternative_packages() {
3914 let (mut server, client) = mock_client().await;
3915 let _findings = server
3916 .mock(
3917 "GET",
3918 "/v3alpha/systems/npm/packages/slopsquat-pkg/versions/1.0.0:findings",
3919 )
3920 .with_status(200)
3921 .with_body(
3922 r#"{"versionKey":{"system":"NPM","name":"slopsquat-pkg","version":"1.0.0"},
3923 "recommendedVersions":[],
3924 "requestedVersion":{"versionKey":{"system":"NPM","name":"slopsquat-pkg","version":"1.0.0"},
3925 "isDefault":true,
3926 "findings":[{"type":"LOW_USAGE","risk":"RISK_MEDIUM",
3927 "lowUsageContext":{"alternativePackages":["popular-pkg"]}}]},
3928 "packageFindings":[]}"#,
3929 )
3930 .create_async()
3931 .await;
3932
3933 let findings = client
3934 .gossip_findings_for_version(DepsDevSystem::Npm, "slopsquat-pkg", "1.0.0")
3935 .await
3936 .expect("findings expected");
3937 let low_usage = findings.low_usage.expect("low-usage finding expected");
3938 assert_eq!(low_usage.risk, GossipRiskLevel::Medium);
3939 assert_eq!(
3940 low_usage.alternative_packages,
3941 vec!["popular-pkg".to_string()]
3942 );
3943 }
3944
3945 #[tokio::test]
3946 async fn gossip_findings_for_version_second_call_within_ttl_issues_zero_requests() {
3947 let (mut server, client) = mock_client().await;
3948 let findings = server
3949 .mock("GET", "/v3alpha/systems/npm/packages/vite/versions/8.3.1:findings")
3950 .with_status(200)
3951 .with_body(
3952 r#"{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3953 "recommendedVersions":[],
3954 "requestedVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3955 "isDefault":true,"findings":[]}}"#,
3956 )
3957 .expect(1)
3958 .create_async()
3959 .await;
3960
3961 client
3962 .gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
3963 .await;
3964 client
3965 .gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
3966 .await;
3967
3968 findings.assert_async().await;
3969 }
3970
3971 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3978 async fn gossip_findings_for_version_survives_dropped_join_handle_and_warms_memo() {
3979 let (mut server, client) = mock_client().await;
3980 let client = Arc::new(client);
3981 let findings = server
3982 .mock("GET", "/v3alpha/systems/npm/packages/vite/versions/8.3.1:findings")
3983 .with_status(200)
3984 .with_body_from_request(|_req| {
3985 std::thread::sleep(Duration::from_millis(60));
3986 br#"{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3987 "recommendedVersions":[],
3988 "requestedVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
3989 "isDefault":true,
3990 "findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
3991 "cooldownContext":{"end":"2026-10-09T12:26:19Z"}}]}}"#
3992 .to_vec()
3993 })
3994 .expect(1)
3995 .create_async()
3996 .await;
3997
3998 let spawn_client = Arc::clone(&client);
3999 let handle = tokio::spawn(async move {
4000 spawn_client
4001 .gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
4002 .await
4003 });
4004 let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
4008 assert!(
4009 outcome.is_err(),
4010 "the artificial budget must elapse before the mock responds"
4011 );
4012
4013 tokio::time::sleep(Duration::from_millis(250)).await;
4016
4017 let second = client
4018 .gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
4019 .await;
4020 assert!(
4021 second.is_some(),
4022 "the memo warmed by the detached task must serve the next call"
4023 );
4024 findings.assert_async().await;
4025 }
4026
4027 #[tokio::test]
4029 async fn gossip_findings_for_version_dot_segment_rejected_before_request() {
4030 let (mut server, client) = mock_client().await;
4031 let call = server
4032 .mock("GET", mockito::Matcher::Any)
4033 .expect(0)
4034 .create_async()
4035 .await;
4036
4037 assert!(
4038 client
4039 .gossip_findings_for_version(DepsDevSystem::Npm, ".", "1.0.0")
4040 .await
4041 .is_none()
4042 );
4043 assert!(
4044 client
4045 .gossip_findings_for_version(DepsDevSystem::Npm, "left-pad", "..")
4046 .await
4047 .is_none()
4048 );
4049 call.assert_async().await;
4050 }
4051
4052 #[tokio::test]
4053 async fn gossip_findings_for_version_404_returns_none_no_panic() {
4054 let (mut server, client) = mock_client().await;
4055 let _findings = server
4056 .mock(
4057 "GET",
4058 "/v3alpha/systems/npm/packages/missing/versions/1.0.0:findings",
4059 )
4060 .with_status(404)
4061 .create_async()
4062 .await;
4063
4064 let findings = client
4065 .gossip_findings_for_version(DepsDevSystem::Npm, "missing", "1.0.0")
4066 .await;
4067 assert!(findings.is_none());
4068 }
4069
4070 #[tokio::test]
4075 async fn gossip_findings_for_version_no_requested_version_does_not_fall_back_to_default() {
4076 let (mut server, client) = mock_client().await;
4077 let _findings = server
4078 .mock(
4079 "GET",
4080 "/v3alpha/systems/npm/packages/vite/versions/7.0.0:findings",
4081 )
4082 .with_status(200)
4083 .with_body(
4084 r#"{"versionKey":{"system":"NPM","name":"vite","version":"7.0.0"},
4085 "recommendedVersions":[],
4086 "defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
4087 "isDefault":true,
4088 "findings":[{"type":"LOW_USAGE","risk":"RISK_MEDIUM"}]},
4089 "packageFindings":[]}"#,
4090 )
4091 .create_async()
4092 .await;
4093
4094 let findings = client
4095 .gossip_findings_for_version(DepsDevSystem::Npm, "vite", "7.0.0")
4096 .await;
4097 assert!(
4098 findings.is_none(),
4099 "must not return defaultVersion's (8.3.1) low-usage finding for the requested \
4100 (7.0.0) version — got: {findings:?}"
4101 );
4102 }
4103}