mod memo;
mod types;
mod typosquat;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use dashmap::DashSet;
use memo::{CallOutcome, CoalescedMemo, DEPS_DEV_TTLS, GOSSIP_TTLS, TtlMemo};
use types::{
DependentsWire, DepsDevProject, DepsDevVersionInfo, GetPackageWire, GossipBatchRequestWire,
GossipFindingType, GossipFindingsBatchRequestWire, GossipFindingsBatchWire, GossipFindingsWire,
GossipPackageKeyRefWire, GossipVersionFindingsWire, ProvenanceEntry, RelatedProject,
SimilarlyNamedPackagesWire,
};
pub use types::{
GossipCooldown, GossipFindings, GossipLowUsage, GossipRiskLevel, ProvenanceStatus,
ScorecardSummary, SupplyChainTrustSignal,
};
pub use typosquat::TyposquatSignal;
use typosquat::{SimilarPackageCandidate, TYPOSQUAT_MAX_CANDIDATES_CHECKED, evaluate_candidates};
use crate::EcosystemId;
use crate::cache::{BodyLimit, HttpCache};
use crate::error::DepsError;
use crate::lsp_helpers::{is_dot_segment, warn_rejected_value};
const DEPS_DEV_API: &str = "https://api.deps.dev";
const DEPS_DEV_CALL_TIMEOUT: Duration = Duration::from_millis(400);
const TYPOSQUAT_CALL_TIMEOUT: Duration = Duration::from_secs(3);
const DEPS_DEV_SUCCESS_TTL: Duration = Duration::from_hours(1);
const DEPS_DEV_ERROR_TTL: Duration = Duration::from_secs(90);
const MAX_MEMO_ENTRIES: usize = 512;
const DEPS_DEV_BODY_LIMIT: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchCompleteness {
Complete,
Incomplete,
}
impl FetchCompleteness {
#[must_use]
pub fn combine(self, other: Self) -> Self {
if self == Self::Incomplete || other == Self::Incomplete {
Self::Incomplete
} else {
Self::Complete
}
}
}
const GOSSIP_CALL_TIMEOUT: Duration = Duration::from_millis(400);
const GOSSIP_BATCH_CALL_TIMEOUT: Duration = Duration::from_secs(5);
const GOSSIP_BATCH_MAX_PAGES: usize = 20;
const GOSSIP_SUCCESS_TTL: Duration = Duration::from_hours(1);
const GOSSIP_ERROR_TTL: Duration = Duration::from_secs(90);
const GOSSIP_PREFETCH_CONCURRENCY: usize = 8;
const GOSSIP_REFRESH_BACKOFF: Duration = Duration::from_mins(15);
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
struct MemoKey {
base: String,
system: DepsDevSystem,
name: String,
version: String,
}
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
struct ProjectKeyMemo {
base: String,
project_key: String,
}
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
struct SimilarityMemoKey {
base: String,
system: DepsDevSystem,
name: String,
}
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
struct PopularityMemoKey {
base: String,
system: DepsDevSystem,
name: String,
}
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
struct GossipMemoKey {
base: String,
system: DepsDevSystem,
name: String,
}
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
struct GossipVersionMemoKey {
base: String,
system: DepsDevSystem,
name: String,
version: String,
}
struct DashSetInFlightGuard<'a, K: std::hash::Hash + Eq> {
set: &'a DashSet<K>,
key: K,
}
impl<K: std::hash::Hash + Eq> Drop for DashSetInFlightGuard<'_, K> {
fn drop(&mut self) {
self.set.remove(&self.key);
}
}
#[must_use]
pub(crate) const fn deps_dev_system(id: EcosystemId) -> Option<DepsDevSystem> {
match id {
EcosystemId::Npm => Some(DepsDevSystem::Npm),
EcosystemId::Cargo => Some(DepsDevSystem::Cargo),
EcosystemId::Go => Some(DepsDevSystem::Go),
EcosystemId::Maven => Some(DepsDevSystem::Maven),
EcosystemId::Pypi => Some(DepsDevSystem::Pypi),
EcosystemId::Bundler => Some(DepsDevSystem::Rubygems),
EcosystemId::NuGet => Some(DepsDevSystem::NuGet),
EcosystemId::Composer
| EcosystemId::Dart
| EcosystemId::Swift
| EcosystemId::Gradle
| EcosystemId::Deno
| EcosystemId::GithubActions
| EcosystemId::GitlabCi => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DepsDevSystem {
Npm,
Cargo,
Go,
Maven,
Pypi,
Rubygems,
NuGet,
}
impl DepsDevSystem {
#[must_use]
const fn as_path_segment(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Cargo => "cargo",
Self::Go => "go",
Self::Maven => "maven",
Self::Pypi => "pypi",
Self::Rubygems => "rubygems",
Self::NuGet => "nuget",
}
}
}
fn is_host_label(segment: &str) -> bool {
!segment.is_empty()
&& !segment.starts_with('-')
&& !segment.ends_with('-')
&& segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
}
fn is_valid_project_key(key: &str) -> bool {
let segments: Vec<&str> = key.split('/').collect();
if !(2..=4).contains(&segments.len()) || segments.iter().any(|s| s.is_empty()) {
return false;
}
if segments.iter().any(|s| is_dot_segment(s)) {
return false;
}
#[expect(
clippy::expect_used,
reason = "split_first cannot return None: the length check above already guarantees \
segments has at least 2 entries"
)]
let (host, rest) = segments
.split_first()
.expect("segments has at least 2 entries");
if !host.contains('.') || !host.split('.').all(is_host_label) {
return false;
}
rest.iter().all(|s| {
s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
})
}
fn classify_provenance(
slsa: &[ProvenanceEntry],
attestations: &[ProvenanceEntry],
) -> ProvenanceStatus {
if slsa.is_empty() && attestations.is_empty() {
ProvenanceStatus::None
} else if slsa.iter().chain(attestations).any(|e| e.verified) {
ProvenanceStatus::Verified
} else {
ProvenanceStatus::Unverified
}
}
fn choose_project_key(projects: &[RelatedProject]) -> Option<(String, bool)> {
let attested = projects
.iter()
.find(|p| p.relation_type == "SOURCE_REPO" && p.relation_provenance == "SLSA_ATTESTATION");
let (chosen, self_reported) = attested.map(|p| (p, false)).or_else(|| {
projects
.iter()
.find(|p| p.relation_type == "SOURCE_REPO")
.map(|p| (p, true))
})?;
is_valid_project_key(&chosen.project_key.id)
.then(|| (chosen.project_key.id.clone(), self_reported))
}
pub struct DepsDevClient {
cache: Arc<HttpCache>,
base_url: String,
trusted_origin: String,
signals: CoalescedMemo<MemoKey, Option<SupplyChainTrustSignal>>,
projects: TtlMemo<ProjectKeyMemo, Option<f32>>,
similarity: CoalescedMemo<SimilarityMemoKey, Vec<SimilarPackageCandidate>>,
popularity: CoalescedMemo<PopularityMemoKey, Option<u64>>,
gossip: TtlMemo<GossipMemoKey, Option<GossipFindings>>,
gossip_in_flight: DashSet<GossipMemoKey>,
gossip_versions: TtlMemo<GossipVersionMemoKey, Option<GossipFindings>>,
gossip_version_in_flight: DashSet<GossipVersionMemoKey>,
gossip_semaphore: tokio::sync::Semaphore,
gossip_last_refresh_attempt: DashMap<GossipMemoKey, Instant>,
gossip_refresh_in_flight: DashSet<GossipMemoKey>,
}
fn gossip_findings_from_entry(entry: &GossipVersionFindingsWire) -> GossipFindings {
let mut cooldown = None;
let mut low_usage = None;
for finding in &entry.findings {
match finding.finding_type {
GossipFindingType::Cooldown => {
if cooldown.is_none()
&& let Some(ctx) = &finding.cooldown_context
&& let Some(end) = crate::PublishTime::parse_rfc3339(&ctx.end)
{
cooldown = Some(GossipCooldown {
end,
risk: finding.risk.into(),
});
}
}
GossipFindingType::LowUsage => {
if low_usage.is_none() {
let alternative_packages = finding
.low_usage_context
.as_ref()
.map(|ctx| ctx.alternative_packages.clone())
.unwrap_or_default();
low_usage = Some(GossipLowUsage {
risk: finding.risk.into(),
alternative_packages,
});
}
}
GossipFindingType::Other => {}
}
}
if cooldown.is_none()
&& let Some(end) = entry
.cooldown_end
.as_deref()
.and_then(crate::PublishTime::parse_rfc3339)
{
cooldown = Some(GossipCooldown {
end,
risk: GossipRiskLevel::Informational,
});
}
GossipFindings {
version: entry.version_key.version.clone(),
cooldown,
low_usage,
}
}
impl std::fmt::Debug for DepsDevClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DepsDevClient").finish_non_exhaustive()
}
}
impl DepsDevClient {
#[must_use]
pub fn new(cache: Arc<HttpCache>) -> Self {
Self::with_base_url(cache, DEPS_DEV_API.to_string())
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub fn for_test(cache: Arc<HttpCache>, base_url: impl Into<String>) -> Self {
Self::with_base_url(cache, base_url.into())
}
fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
let trusted_origin = format!("{base_url}/");
Self {
cache,
base_url,
trusted_origin,
signals: CoalescedMemo::new(DEPS_DEV_TTLS),
projects: TtlMemo::new(DEPS_DEV_TTLS),
similarity: CoalescedMemo::new(DEPS_DEV_TTLS),
popularity: CoalescedMemo::new(DEPS_DEV_TTLS),
gossip: TtlMemo::new(GOSSIP_TTLS),
gossip_in_flight: DashSet::new(),
gossip_versions: TtlMemo::new(GOSSIP_TTLS),
gossip_version_in_flight: DashSet::new(),
gossip_semaphore: tokio::sync::Semaphore::new(GOSSIP_PREFETCH_CONCURRENCY),
gossip_last_refresh_attempt: DashMap::new(),
gossip_refresh_in_flight: DashSet::new(),
}
}
pub async fn trust_signal(
&self,
system: DepsDevSystem,
name: &str,
version: &str,
) -> Option<SupplyChainTrustSignal> {
let key = MemoKey {
base: self.base_url.clone(),
system,
name: name.to_string(),
version: version.to_string(),
};
self.signals
.get_or_fetch(key, || self.fetch(system, name, version))
.await
.into_value()
}
#[tracing::instrument(
skip(self),
fields(url = %crate::redact::RedactedUrl::new(url))
)]
async fn get(&self, url: &str, timeout: Duration) -> Result<bytes::Bytes, DepsDevFetchError> {
match tokio::time::timeout(
timeout,
self.cache
.get_transport_only_with_headers_limited_trusted_origin(
url,
&[],
BodyLimit::new(DEPS_DEV_BODY_LIMIT),
&self.trusted_origin,
),
)
.await
{
Ok(Ok(bytes)) => Ok(bytes),
Ok(Err(e)) if e.is_not_found() => Err(DepsDevFetchError::NotFound),
Ok(Err(e)) => Err(DepsDevFetchError::Failed(e)),
Err(_) => Err(DepsDevFetchError::TimedOut),
}
}
async fn get_json<W: serde::de::DeserializeOwned>(
&self,
url: &str,
timeout: Duration,
call: DepsDevCall<'_>,
) -> Result<W, CallFailure> {
let label = call.label();
match self.get(url, timeout).await {
Ok(bytes) => match crate::parser::parse_json_checked::<W>(&bytes) {
Ok(value) => Ok(value),
Err(e) => {
tracing::debug!(error = %e, "deps.dev {label} response parse failed");
Err(CallFailure::Transient)
}
},
Err(DepsDevFetchError::NotFound) => Err(CallFailure::NotFound),
Err(DepsDevFetchError::Failed(e)) => {
let (status, cause) = e.safe_tracing_summary();
tracing::debug!(status = ?status, cause, "deps.dev {label} fetch failed");
Err(CallFailure::Transient)
}
Err(DepsDevFetchError::TimedOut) => {
if let DepsDevCall::Project { key } = &call {
tracing::debug!(project_key = key, "deps.dev {label} fetch timed out");
} else if let Some(name) = call.timed_out_name() {
tracing::debug!(
package = %crate::redact::redact_declaration_key(name),
"deps.dev {label} fetch timed out"
);
}
Err(CallFailure::Transient)
}
}
}
async fn fetch(
&self,
system: DepsDevSystem,
name: &str,
version: &str,
) -> CallOutcome<Option<SupplyChainTrustSignal>> {
if is_dot_segment(name) {
warn_rejected_value("is_dot_segment", "deps.dev trust-signal request URL", name);
return CallOutcome::Definitive(None);
}
if is_dot_segment(version) {
warn_rejected_value(
"is_dot_segment",
"deps.dev trust-signal request URL",
version,
);
return CallOutcome::Definitive(None);
}
let version_url = format!(
"{}/v3/systems/{}/packages/{}/versions/{}",
self.base_url,
system.as_path_segment(),
urlencoding::encode(name),
urlencoding::encode(version),
);
let (provenance, related_projects, licenses) = match self
.get_json::<DepsDevVersionInfo>(
&version_url,
DEPS_DEV_CALL_TIMEOUT,
DepsDevCall::Version { name },
)
.await
{
Ok(info) => {
let provenance = classify_provenance(&info.slsa_provenances, &info.attestations);
(Some(provenance), info.related_projects, info.licenses)
}
Err(CallFailure::NotFound) => return CallOutcome::Definitive(None),
Err(CallFailure::Transient) => return CallOutcome::Degraded(None),
};
let (scorecard, project_completeness) = match choose_project_key(&related_projects) {
Some((project_key, self_reported)) => {
let (raw_score, completeness) =
self.fetch_scorecard(&project_key).await.into_parts();
let scorecard = raw_score.map(|overall_score| ScorecardSummary {
overall_score,
self_reported,
});
(scorecard, completeness)
}
None => (None, FetchCompleteness::Complete),
};
let signal = SupplyChainTrustSignal {
scorecard,
provenance,
licenses,
};
CallOutcome::with_completeness(Some(signal), project_completeness)
}
async fn fetch_scorecard(&self, project_key: &str) -> CallOutcome<Option<f32>> {
let memo_key = ProjectKeyMemo {
base: self.base_url.clone(),
project_key: project_key.to_string(),
};
if let Some(outcome) = self.projects.get_fresh(&memo_key) {
return outcome;
}
let url = format!(
"{}/v3/projects/{}",
self.base_url,
urlencoding::encode(project_key),
);
let outcome = match self
.get_json::<DepsDevProject>(
&url,
DEPS_DEV_CALL_TIMEOUT,
DepsDevCall::Project { key: project_key },
)
.await
{
Ok(project) => {
let overall_score = project
.scorecard
.and_then(|s| s.overall_score)
.filter(|score| (0.0..=10.0).contains(score));
CallOutcome::Definitive(overall_score)
}
Err(CallFailure::NotFound) => CallOutcome::Definitive(None),
Err(CallFailure::Transient) => CallOutcome::Degraded(None),
};
self.projects.insert(memo_key, outcome.clone());
outcome
}
pub async fn typosquat_signal(
&self,
system: DepsDevSystem,
name: &str,
) -> (Option<TyposquatSignal>, FetchCompleteness) {
let (candidates, similarity_completeness) = self.similar_packages(system, name).await;
if candidates.is_empty() {
return (None, similarity_completeness);
}
let candidate_futures = candidates.iter().map(|candidate| async move {
let (dependent_count, completeness) = self.popularity(system, &candidate.name).await;
(candidate.name.clone(), dependent_count, completeness)
});
let ((declared_dependent_count, declared_completeness), resolved_candidates) =
futures::future::join(
self.popularity(system, name),
futures::future::join_all(candidate_futures),
)
.await;
let completeness = resolved_candidates.iter().fold(
similarity_completeness.combine(declared_completeness),
|acc, (_, _, c)| acc.combine(*c),
);
let Some(declared_dependent_count) = declared_dependent_count else {
return (None, completeness);
};
let resolved: Vec<(String, u64)> = resolved_candidates
.into_iter()
.filter_map(|(name, dependent_count, _)| dependent_count.map(|count| (name, count)))
.collect();
(
evaluate_candidates(name, declared_dependent_count, &resolved),
completeness,
)
}
async fn similar_packages(
&self,
system: DepsDevSystem,
name: &str,
) -> (Vec<SimilarPackageCandidate>, FetchCompleteness) {
if is_dot_segment(name) {
warn_rejected_value(
"is_dot_segment",
"deps.dev similarly-named-packages request URL",
name,
);
return (Vec::new(), FetchCompleteness::Complete);
}
let key = SimilarityMemoKey {
base: self.base_url.clone(),
system,
name: name.to_string(),
};
self.similarity
.get_or_fetch(key, || self.fetch_similar_packages(system, name))
.await
.into_parts()
}
async fn fetch_similar_packages(
&self,
system: DepsDevSystem,
name: &str,
) -> CallOutcome<Vec<SimilarPackageCandidate>> {
let url = format!(
"{}/v3alpha/systems/{}/packages/{}:similarlyNamedPackages",
self.base_url,
system.as_path_segment(),
urlencoding::encode(name),
);
match self
.get_json::<SimilarlyNamedPackagesWire>(
&url,
TYPOSQUAT_CALL_TIMEOUT,
DepsDevCall::SimilarlyNamed { name },
)
.await
{
Ok(wire) => {
let candidates = wire
.packages
.into_iter()
.map(|p| SimilarPackageCandidate {
name: p.package_key.name,
})
.filter(|candidate| candidate.name != name)
.take(TYPOSQUAT_MAX_CANDIDATES_CHECKED)
.collect();
CallOutcome::Definitive(candidates)
}
Err(CallFailure::NotFound) => CallOutcome::Definitive(Vec::new()),
Err(CallFailure::Transient) => CallOutcome::Degraded(Vec::new()),
}
}
async fn popularity(
&self,
system: DepsDevSystem,
name: &str,
) -> (Option<u64>, FetchCompleteness) {
let key = PopularityMemoKey {
base: self.base_url.clone(),
system,
name: name.to_string(),
};
self.popularity
.get_or_fetch(key, || self.fetch_popularity(system, name))
.await
.into_parts()
}
async fn fetch_popularity(
&self,
system: DepsDevSystem,
name: &str,
) -> CallOutcome<Option<u64>> {
if is_dot_segment(name) {
warn_rejected_value("is_dot_segment", "deps.dev package request URL", name);
return CallOutcome::Definitive(None);
}
let package_url = format!(
"{}/v3alpha/systems/{}/packages/{}",
self.base_url,
system.as_path_segment(),
urlencoding::encode(name),
);
let default_version = match self
.get_json::<GetPackageWire>(
&package_url,
TYPOSQUAT_CALL_TIMEOUT,
DepsDevCall::Package { name },
)
.await
{
Ok(package) => match package.versions.into_iter().find(|v| v.is_default) {
Some(v) => v.version_key.version,
None => return CallOutcome::Definitive(None),
},
Err(CallFailure::NotFound) => return CallOutcome::Definitive(None),
Err(CallFailure::Transient) => return CallOutcome::Degraded(None),
};
if is_dot_segment(&default_version) {
warn_rejected_value(
"is_dot_segment",
"deps.dev dependents request URL",
&default_version,
);
return CallOutcome::Definitive(None);
}
let dependents_url = format!(
"{}/v3alpha/systems/{}/packages/{}/versions/{}:dependents",
self.base_url,
system.as_path_segment(),
urlencoding::encode(name),
urlencoding::encode(&default_version),
);
match self
.get_json::<DependentsWire>(
&dependents_url,
TYPOSQUAT_CALL_TIMEOUT,
DepsDevCall::Dependents { name },
)
.await
{
Ok(wire) => CallOutcome::Definitive(Some(wire.dependent_count)),
Err(CallFailure::NotFound | CallFailure::Transient) => CallOutcome::Degraded(None),
}
}
pub async fn gossip_findings_batch(
&self,
system: DepsDevSystem,
names: &[String],
) -> HashMap<String, GossipFindings> {
let mut result = HashMap::new();
let mut misses: Vec<String> = Vec::new();
for name in names {
let key = self.gossip_key(system, name);
if let Some(outcome) = self.gossip.get_fresh(&key) {
if let Some(findings) = outcome.into_value() {
result.insert(name.clone(), findings);
}
continue;
}
misses.push(name.clone());
}
if misses.is_empty() {
return result;
}
let mut claimed: Vec<String> = Vec::with_capacity(misses.len());
let mut guards: Vec<DashSetInFlightGuard<'_, GossipMemoKey>> =
Vec::with_capacity(misses.len());
for name in misses {
let key = self.gossip_key(system, &name);
if self.gossip_in_flight.insert(key.clone()) {
guards.push(DashSetInFlightGuard {
set: &self.gossip_in_flight,
key,
});
claimed.push(name);
}
}
if claimed.is_empty() {
return result;
}
#[expect(
clippy::expect_used,
reason = "gossip_semaphore is never closed for the lifetime of this client"
)]
let _permit = self
.gossip_semaphore
.acquire()
.await
.expect("gossip_semaphore is never closed");
let fetched = self.fetch_gossip_batch(system, &claimed).await;
drop(guards);
for name in claimed {
let outcome = match fetched.get(&name) {
Some(findings) => CallOutcome::Definitive(findings.clone()),
None => CallOutcome::Degraded(None),
};
if let Some(findings) = outcome.clone().into_value() {
result.insert(name.clone(), findings);
}
self.gossip.insert(self.gossip_key(system, &name), outcome);
}
result
}
pub async fn force_refresh_gossip_findings(
&self,
system: DepsDevSystem,
names: &[String],
) -> HashMap<String, GossipFindings> {
let mut eligible: Vec<String> = Vec::with_capacity(names.len());
let mut guards: Vec<DashSetInFlightGuard<'_, GossipMemoKey>> =
Vec::with_capacity(names.len());
let now = Instant::now();
for name in names {
let key = self.gossip_key(system, name);
if !self.gossip_refresh_in_flight.insert(key.clone()) {
continue;
}
let guard = DashSetInFlightGuard {
set: &self.gossip_refresh_in_flight,
key: key.clone(),
};
let throttled = self
.gossip_last_refresh_attempt
.get(&key)
.is_some_and(|last| now.duration_since(*last) < GOSSIP_REFRESH_BACKOFF);
if throttled {
drop(guard);
continue;
}
if !self.gossip_last_refresh_attempt.contains_key(&key) {
crate::cache_policy::evict_expired_then_oldest(
&self.gossip_last_refresh_attempt,
MAX_MEMO_ENTRIES,
|attempt| *attempt,
|_| GOSSIP_REFRESH_BACKOFF,
);
}
self.gossip_last_refresh_attempt.insert(key, now);
eligible.push(name.clone());
guards.push(guard);
}
if eligible.is_empty() {
return HashMap::new();
}
for name in &eligible {
self.gossip.remove(&self.gossip_key(system, name));
}
#[expect(
clippy::expect_used,
reason = "gossip_semaphore is never closed for the lifetime of this client"
)]
let _permit = self
.gossip_semaphore
.acquire()
.await
.expect("gossip_semaphore is never closed");
let fetched = self.fetch_gossip_batch(system, &eligible).await;
drop(guards);
let mut result = HashMap::new();
for name in eligible {
let outcome = match fetched.get(&name) {
Some(findings) => CallOutcome::Definitive(findings.clone()),
None => CallOutcome::Degraded(None),
};
if let Some(findings) = outcome.clone().into_value() {
result.insert(name.clone(), findings);
}
self.gossip.insert(self.gossip_key(system, &name), outcome);
}
result
}
fn gossip_key(&self, system: DepsDevSystem, name: &str) -> GossipMemoKey {
GossipMemoKey {
base: self.base_url.clone(),
system,
name: name.to_string(),
}
}
async fn fetch_gossip_batch(
&self,
system: DepsDevSystem,
names: &[String],
) -> HashMap<String, Option<GossipFindings>> {
let url = format!("{}/v3alpha/findingsbatch", self.base_url);
let system_upper = system.as_path_segment().to_uppercase();
let requests: Vec<GossipBatchRequestWire<'_>> = names
.iter()
.map(|name| GossipBatchRequestWire {
package_key: GossipPackageKeyRefWire {
system: &system_upper,
name,
},
})
.collect();
let mut result = HashMap::new();
let mut page_token: Option<String> = None;
for _ in 0..GOSSIP_BATCH_MAX_PAGES {
let body = GossipFindingsBatchRequestWire {
requests: &requests,
page_token: page_token.as_deref(),
};
let bytes = match tokio::time::timeout(
GOSSIP_BATCH_CALL_TIMEOUT,
self.cache.post_json_limited_trusted_origin(
&url,
&body,
BodyLimit::new(DEPS_DEV_BODY_LIMIT),
&self.trusted_origin,
),
)
.await
{
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
let (status, cause) = e.safe_tracing_summary();
tracing::debug!(
status = ?status,
cause,
"deps.dev GOSSIP findings batch fetch failed"
);
return HashMap::new();
}
Err(_) => {
tracing::debug!("deps.dev GOSSIP findings batch fetch timed out");
return HashMap::new();
}
};
let wire = match crate::parser::parse_json_checked::<GossipFindingsBatchWire>(&bytes) {
Ok(wire) => wire,
Err(e) => {
tracing::debug!(
error = %e,
"deps.dev GOSSIP findings batch response parse failed"
);
return HashMap::new();
}
};
for entry in wire.responses {
let name = entry.request.package_key.name;
let findings = entry
.findings
.default_version
.as_ref()
.map(gossip_findings_from_entry);
result.insert(name, findings);
}
if wire.next_page_token.is_empty() {
break;
}
page_token = Some(wire.next_page_token);
}
result
}
pub async fn gossip_findings_for_version(
&self,
system: DepsDevSystem,
name: &str,
version: &str,
) -> Option<GossipFindings> {
let key = GossipVersionMemoKey {
base: self.base_url.clone(),
system,
name: name.to_string(),
version: version.to_string(),
};
if let Some(outcome) = self.gossip_versions.get_fresh(&key) {
return outcome.into_value();
}
if !self.gossip_version_in_flight.insert(key.clone()) {
return None;
}
let _guard = DashSetInFlightGuard {
set: &self.gossip_version_in_flight,
key: key.clone(),
};
let outcome = self.fetch_gossip_version(system, name, version).await;
let findings = outcome.clone().into_value();
self.gossip_versions.insert(key, outcome);
findings
}
async fn fetch_gossip_version(
&self,
system: DepsDevSystem,
name: &str,
version: &str,
) -> CallOutcome<Option<GossipFindings>> {
if is_dot_segment(name) {
warn_rejected_value(
"is_dot_segment",
"deps.dev GOSSIP findings request URL",
name,
);
return CallOutcome::Definitive(None);
}
if is_dot_segment(version) {
warn_rejected_value(
"is_dot_segment",
"deps.dev GOSSIP findings request URL",
version,
);
return CallOutcome::Definitive(None);
}
let url = format!(
"{}/v3alpha/systems/{}/packages/{}/versions/{}:findings",
self.base_url,
system.as_path_segment(),
urlencoding::encode(name),
urlencoding::encode(version),
);
match self
.get_json::<GossipFindingsWire>(
&url,
GOSSIP_CALL_TIMEOUT,
DepsDevCall::GossipVersionFindings { name },
)
.await
{
Ok(wire) => {
let findings = wire
.requested_version
.as_ref()
.map(gossip_findings_from_entry);
CallOutcome::Definitive(findings)
}
Err(CallFailure::NotFound) => CallOutcome::Definitive(None),
Err(CallFailure::Transient) => CallOutcome::Degraded(None),
}
}
}
enum DepsDevFetchError {
NotFound,
Failed(DepsError),
TimedOut,
}
enum DepsDevCall<'a> {
Version { name: &'a str },
Project { key: &'a str },
SimilarlyNamed { name: &'a str },
Package { name: &'a str },
Dependents { name: &'a str },
GossipVersionFindings { name: &'a str },
}
impl DepsDevCall<'_> {
const fn label(&self) -> &'static str {
match self {
Self::Version { .. } => "version",
Self::Project { .. } => "project",
Self::SimilarlyNamed { .. } => "similarly-named-packages",
Self::Package { .. } => "package",
Self::Dependents { .. } => "dependents",
Self::GossipVersionFindings { .. } => "GOSSIP version findings",
}
}
const fn timed_out_name(&self) -> Option<&str> {
match self {
Self::Version { name }
| Self::SimilarlyNamed { name }
| Self::Package { name }
| Self::Dependents { name }
| Self::GossipVersionFindings { name } => Some(name),
Self::Project { .. } => None,
}
}
}
enum CallFailure {
NotFound,
Transient,
}
#[cfg(test)]
mod tests {
use super::*;
fn client() -> DepsDevClient {
DepsDevClient::new(Arc::new(HttpCache::new()))
}
async fn mock_client() -> (mockito::ServerGuard, DepsDevClient) {
let server = mockito::Server::new_async().await;
let client = DepsDevClient::for_test(Arc::new(HttpCache::new()), server.url());
(server, client)
}
const EXPRESS_VERSION_NO_PROVENANCE: &str = r#"{
"slsaProvenances": [],
"attestations": [],
"relatedProjects": [
{"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"},
{"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]
}"#;
const SIGSTORE_VERSION_VERIFIED: &str = r#"{
"slsaProvenances": [{"verified": true, "sourceRepository": "github.com/sigstore/sigstore-js"}],
"attestations": [],
"relatedProjects": [
{"projectKey": {"id": "github.com/sigstore/sigstore-js"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]
}"#;
const EXPRESS_PROJECT: &str = r#"{"scorecard": {"overallScore": 8.5}}"#;
#[test]
fn deps_dev_system_covers_seven_ecosystems() {
assert_eq!(deps_dev_system(EcosystemId::Npm), Some(DepsDevSystem::Npm));
assert_eq!(
deps_dev_system(EcosystemId::Cargo),
Some(DepsDevSystem::Cargo)
);
assert_eq!(deps_dev_system(EcosystemId::Go), Some(DepsDevSystem::Go));
assert_eq!(
deps_dev_system(EcosystemId::Maven),
Some(DepsDevSystem::Maven)
);
assert_eq!(
deps_dev_system(EcosystemId::Pypi),
Some(DepsDevSystem::Pypi)
);
assert_eq!(
deps_dev_system(EcosystemId::Bundler),
Some(DepsDevSystem::Rubygems)
);
assert_eq!(
deps_dev_system(EcosystemId::NuGet),
Some(DepsDevSystem::NuGet)
);
}
#[test]
fn deps_dev_system_as_path_segment_matches_deps_dev_api() {
assert_eq!(DepsDevSystem::Npm.as_path_segment(), "npm");
assert_eq!(DepsDevSystem::Cargo.as_path_segment(), "cargo");
assert_eq!(DepsDevSystem::Go.as_path_segment(), "go");
assert_eq!(DepsDevSystem::Maven.as_path_segment(), "maven");
assert_eq!(DepsDevSystem::Pypi.as_path_segment(), "pypi");
assert_eq!(DepsDevSystem::Rubygems.as_path_segment(), "rubygems");
assert_eq!(DepsDevSystem::NuGet.as_path_segment(), "nuget");
}
#[test]
fn deps_dev_system_excludes_uncovered_ecosystems() {
assert_eq!(deps_dev_system(EcosystemId::Composer), None);
assert_eq!(deps_dev_system(EcosystemId::Dart), None);
assert_eq!(deps_dev_system(EcosystemId::Swift), None);
assert_eq!(deps_dev_system(EcosystemId::Gradle), None);
assert_eq!(deps_dev_system(EcosystemId::Deno), None);
assert_eq!(deps_dev_system(EcosystemId::GithubActions), None);
}
#[test]
fn is_valid_project_key_accepts_github_style_key() {
assert!(is_valid_project_key("github.com/expressjs/express"));
}
#[test]
fn is_valid_project_key_rejects_traversal() {
assert!(!is_valid_project_key("github.com/../../etc"));
assert!(!is_valid_project_key("github.com/expressjs/.."));
}
#[test]
fn is_valid_project_key_rejects_non_host_first_segment() {
assert!(!is_valid_project_key("not-a-host/expressjs/express"));
}
#[test]
fn is_valid_project_key_rejects_too_few_or_too_many_segments() {
assert!(!is_valid_project_key("github.com"));
assert!(!is_valid_project_key("github.com/a/b/c/d"));
}
#[test]
fn classify_provenance_both_empty_is_none() {
assert_eq!(classify_provenance(&[], &[]), ProvenanceStatus::None);
}
#[test]
fn classify_provenance_any_verified_is_verified() {
let entries = [ProvenanceEntry { verified: true }];
assert_eq!(
classify_provenance(&entries, &[]),
ProvenanceStatus::Verified
);
}
#[test]
fn classify_provenance_nonempty_unverified_is_unverified() {
let entries = [ProvenanceEntry { verified: false }];
assert_eq!(
classify_provenance(&entries, &[]),
ProvenanceStatus::Unverified
);
}
#[tokio::test]
async fn trust_signal_renders_score_and_verified_provenance() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
.with_status(200)
.with_body(SIGSTORE_VERSION_VERIFIED)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 9.1}}"#)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "sigstore", "2.3.1")
.await
.expect("signal expected");
assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
let scorecard = signal.scorecard.expect("scorecard expected");
assert!((scorecard.overall_score - 9.1).abs() < f32::EPSILON);
assert!(!scorecard.self_reported);
}
#[tokio::test]
async fn trust_signal_parses_licenses_from_version_response() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [], "licenses": ["MIT", "Apache-2.0"]}"#,
)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "sigstore", "2.3.1")
.await
.expect("signal expected");
assert_eq!(
signal.licenses,
vec!["MIT".to_string(), "Apache-2.0".to_string()]
);
}
#[tokio::test]
async fn trust_signal_missing_licenses_field_is_empty_vec() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
.expect("signal expected");
assert!(signal.licenses.is_empty());
}
#[tokio::test]
async fn trust_signal_self_reported_relation_is_marked() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/left-pad/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/example/left-pad"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
]}"#,
)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexample%2Fleft-pad")
.with_status(200)
.with_body(EXPRESS_PROJECT)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "left-pad", "1.0.0")
.await
.expect("signal expected");
let scorecard = signal.scorecard.expect("scorecard expected");
assert!(scorecard.self_reported);
assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
}
#[tokio::test]
async fn trust_signal_both_endpoints_fail_returns_none() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(500)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await;
assert!(signal.is_none());
}
#[tokio::test]
async fn trust_signal_project_call_fails_keeps_provenance() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
.with_status(200)
.with_body(SIGSTORE_VERSION_VERIFIED)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
.with_status(500)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "sigstore", "2.3.1")
.await
.expect("signal expected");
assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
assert!(signal.scorecard.is_none());
}
#[tokio::test]
async fn trust_signal_no_source_repo_omits_scorecard_keeps_provenance() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(EXPRESS_PROJECT)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
.expect("signal expected");
assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
let scorecard = signal.scorecard.expect("scorecard expected");
assert!((scorecard.overall_score - 8.5).abs() < f32::EPSILON);
}
#[tokio::test]
async fn trust_signal_malformed_json_returns_none() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body("not json")
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await;
assert!(signal.is_none());
}
#[tokio::test]
async fn trust_signal_404_plaintext_body_returns_none_no_panic() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
.with_status(404)
.with_body("version not found")
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "missing", "1.0.0")
.await;
assert!(signal.is_none());
}
#[tokio::test]
async fn trust_signal_scorecard_overall_score_absent_omits_scorecard_never_zero() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(r#"{"scorecard": {}}"#)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
.expect("signal expected (provenance still present)");
assert!(signal.scorecard.is_none());
}
#[tokio::test]
async fn trust_signal_second_call_within_ttl_issues_zero_requests() {
let (mut server, client) = mock_client().await;
let version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.expect(1)
.create_async()
.await;
let project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(EXPRESS_PROJECT)
.expect(1)
.create_async()
.await;
client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await;
client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await;
version.assert_async().await;
project.assert_async().await;
}
#[tokio::test]
async fn trust_signal_404_is_not_requeried_within_success_ttl() {
let (mut server, client) = mock_client().await;
let version = server
.mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
.with_status(404)
.expect(1)
.create_async()
.await;
client
.trust_signal(DepsDevSystem::Npm, "missing", "1.0.0")
.await;
client
.trust_signal(DepsDevSystem::Npm, "missing", "1.0.0")
.await;
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_two_packages_sharing_project_key_issue_one_project_call() {
let (mut server, client) = mock_client().await;
let _v1 = server
.mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let _v2 = server
.mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let project = server
.mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
.expect(1)
.create_async()
.await;
client
.trust_signal(DepsDevSystem::Npm, "pkg-a", "1.0.0")
.await;
client
.trust_signal(DepsDevSystem::Npm, "pkg-b", "1.0.0")
.await;
project.assert_async().await;
}
#[tokio::test]
async fn trust_signal_percent_encodes_go_module_path() {
let (mut server, client) = mock_client().await;
let version = server
.mock(
"GET",
"/v3/systems/go/packages/golang.org%2Fx%2Ftext/versions/v0.4.0",
)
.with_status(200)
.with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
.expect(1)
.create_async()
.await;
client
.trust_signal(DepsDevSystem::Go, "golang.org/x/text", "v0.4.0")
.await;
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_percent_encodes_scoped_npm_name() {
let (mut server, client) = mock_client().await;
let version = server
.mock(
"GET",
"/v3/systems/npm/packages/%40types%2Fnode/versions/20.0.0",
)
.with_status(200)
.with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
.expect(1)
.create_async()
.await;
client
.trust_signal(DepsDevSystem::Npm, "@types/node", "20.0.0")
.await;
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_percent_encodes_maven_coordinate() {
let (mut server, client) = mock_client().await;
let version = server
.mock(
"GET",
"/v3/systems/maven/packages/com.google.guava%3Aguava/versions/32.0.0",
)
.with_status(200)
.with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
.expect(1)
.create_async()
.await;
client
.trust_signal(DepsDevSystem::Maven, "com.google.guava:guava", "32.0.0")
.await;
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_memo_keys_do_not_alias_on_control_characters() {
let client = client();
client.signals.memo().insert(
MemoKey {
base: "https://api.deps.dev".to_string(),
system: DepsDevSystem::Npm,
name: "a\0b".to_string(),
version: "c".to_string(),
},
CallOutcome::Definitive(Some(SupplyChainTrustSignal::default())),
);
assert!(!client.signals.memo().contains_key(&MemoKey {
base: "https://api.deps.dev".to_string(),
system: DepsDevSystem::Npm,
name: "a".to_string(),
version: "b\0c".to_string(),
}));
}
#[tokio::test]
async fn trust_signal_invalid_project_key_issues_zero_project_requests() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/evil/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/../../etc"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let project = server
.mock("GET", mockito::Matcher::Regex(r"^/v3/projects/.*".into()))
.expect(0)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "evil", "1.0.0")
.await
.expect("signal expected (provenance still present)");
assert!(signal.scorecard.is_none());
project.assert_async().await;
}
#[tokio::test]
async fn trust_signal_dot_segment_name_rejected_before_request() {
let (mut server, client) = mock_client().await;
let call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
assert!(
client
.trust_signal(DepsDevSystem::Npm, ".", "1.0.0")
.await
.is_none()
);
assert!(
client
.trust_signal(DepsDevSystem::Npm, "..", "1.0.0")
.await
.is_none()
);
call.assert_async().await;
}
#[tokio::test]
async fn trust_signal_dot_segment_version_rejected_before_request() {
let (mut server, client) = mock_client().await;
let call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
assert!(
client
.trust_signal(DepsDevSystem::Npm, "left-pad", ".")
.await
.is_none()
);
assert!(
client
.trust_signal(DepsDevSystem::Npm, "left-pad", "..")
.await
.is_none()
);
call.assert_async().await;
}
#[tokio::test]
async fn trust_signal_concurrent_calls_for_same_key_issue_one_request() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (mut server, client) = mock_client().await;
let call_count = Arc::new(AtomicUsize::new(0));
let call_count_clone = Arc::clone(&call_count);
let client = Arc::new(client);
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body_from_request(move |_req| {
call_count_clone.fetch_add(1, Ordering::SeqCst);
EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
})
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(EXPRESS_PROJECT)
.create_async()
.await;
let (a, b) = tokio::join!(
{
let client = Arc::clone(&client);
async move {
client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
}
},
{
let client = Arc::clone(&client);
async move {
client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
}
}
);
assert!(a.is_some());
assert!(b.is_some());
assert_eq!(call_count.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn trust_signal_survives_dropped_join_handle_and_warms_memo() {
let (mut server, client) = mock_client().await;
let client = Arc::new(client);
let version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body_from_request(|_req| {
std::thread::sleep(Duration::from_millis(60));
EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
})
.expect(1)
.create_async()
.await;
let spawn_client = Arc::clone(&client);
let handle = tokio::spawn(async move {
spawn_client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
});
let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
assert!(
outcome.is_err(),
"the artificial budget must elapse before the mock responds"
);
tokio::time::sleep(Duration::from_millis(250)).await;
let second = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await;
assert!(
second.is_some(),
"the memo warmed by the detached task must serve the next call"
);
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_project_memo_never_leaks_self_reported_across_packages() {
let (mut server, client) = mock_client().await;
let _version_a = server
.mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let _version_b = server
.mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
]}"#,
)
.create_async()
.await;
let project = server
.mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
.expect(1)
.create_async()
.await;
let signal_a = client
.trust_signal(DepsDevSystem::Npm, "pkg-a", "1.0.0")
.await
.expect("signal expected");
assert!(
!signal_a
.scorecard
.expect("scorecard expected")
.self_reported,
"A's attested relation must not be marked self-reported"
);
let signal_b = client
.trust_signal(DepsDevSystem::Npm, "pkg-b", "1.0.0")
.await
.expect("signal expected");
assert!(
signal_b
.scorecard
.expect("scorecard expected")
.self_reported,
"B's UNVERIFIED_METADATA relation must be marked self-reported even though A's \
attested fetch warmed the shared project memo first"
);
project.assert_async().await;
}
#[tokio::test]
async fn trust_signal_project_call_failure_downgrades_version_memo_ttl() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(500)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
.expect("signal expected (provenance still present)");
assert!(signal.scorecard.is_none());
let key = MemoKey {
base: client.base_url.clone(),
system: DepsDevSystem::Npm,
name: "express".to_string(),
version: "4.19.2".to_string(),
};
let entry_ttl = client
.signals
.memo()
.ttl_of(&key)
.expect("memo entry expected");
assert_eq!(
entry_ttl, DEPS_DEV_ERROR_TTL,
"a failed project call must downgrade the whole signal's memo TTL to the short \
error TTL, not the 1h success TTL"
);
}
#[tokio::test]
async fn trust_signal_project_overall_score_wrong_type_omits_scorecard_never_panics() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": "not-a-number"}}"#)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
.expect("signal expected (provenance still present)");
assert!(signal.scorecard.is_none());
}
#[tokio::test]
async fn trust_signal_in_flight_claim_released_after_failure() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(500)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await;
assert!(signal.is_none());
let key = MemoKey {
base: client.base_url.clone(),
system: DepsDevSystem::Npm,
name: "express".to_string(),
version: "4.19.2".to_string(),
};
assert!(
!client.signals.in_flight_contains(&key),
"the in-flight claim must be released after a failed fetch, not just a successful one"
);
}
#[tokio::test]
async fn trust_signal_version_call_failure_ttl_is_error_ttl() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(500)
.create_async()
.await;
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await;
assert!(signal.is_none());
let key = MemoKey {
base: client.base_url.clone(),
system: DepsDevSystem::Npm,
name: "express".to_string(),
version: "4.19.2".to_string(),
};
let entry_ttl = client
.signals
.memo()
.ttl_of(&key)
.expect("memo entry expected");
assert_eq!(
entry_ttl, DEPS_DEV_ERROR_TTL,
"a failed version call must memoize the short error TTL, not the 1h success TTL"
);
}
#[test]
fn memo_evicts_when_max_entries_reached() {
let client = client();
for i in 0..MAX_MEMO_ENTRIES {
client.signals.memo().insert(
MemoKey {
base: "https://api.deps.dev".to_string(),
system: DepsDevSystem::Npm,
name: format!("pkg-{i}"),
version: "1.0.0".to_string(),
},
CallOutcome::Definitive(None),
);
}
assert_eq!(client.signals.memo().len(), MAX_MEMO_ENTRIES);
client.signals.memo().insert(
MemoKey {
base: "https://api.deps.dev".to_string(),
system: DepsDevSystem::Npm,
name: "overflow".to_string(),
version: "1.0.0".to_string(),
},
CallOutcome::Definitive(None),
);
assert!(
client.signals.memo().len() <= MAX_MEMO_ENTRIES,
"memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
client.signals.memo().len()
);
}
#[tokio::test]
#[ignore = "requires network access"]
async fn trust_signal_real_npm_express_carries_license() {
let client = client();
let signal = client
.trust_signal(DepsDevSystem::Npm, "express", "4.19.2")
.await
.expect("signal expected for a real, well-known package");
assert!(
!signal.licenses.is_empty(),
"expected express@4.19.2 to report a real license"
);
}
#[test]
fn project_memo_evicts_when_max_entries_reached() {
let client = client();
for i in 0..MAX_MEMO_ENTRIES {
client.projects.insert(
ProjectKeyMemo {
base: "https://api.deps.dev".to_string(),
project_key: format!("github.com/org/repo-{i}"),
},
CallOutcome::Definitive(Some(8.0)),
);
}
assert_eq!(client.projects.len(), MAX_MEMO_ENTRIES);
client.projects.insert(
ProjectKeyMemo {
base: "https://api.deps.dev".to_string(),
project_key: "github.com/org/overflow".to_string(),
},
CallOutcome::Definitive(Some(8.0)),
);
assert!(
client.projects.len() <= MAX_MEMO_ENTRIES,
"projects memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
client.projects.len()
);
}
#[tokio::test]
async fn typosquat_signal_positive_case_fires_above_threshold() {
let (mut server, client) = mock_client().await;
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/crossenv:similarlyNamedPackages",
)
.with_status(200)
.with_body(
r#"{"packageKey": {"name": "crossenv"}, "packages": [{"packageKey": {"name": "cross-env"}}]}"#,
)
.create_async()
.await;
let _declared_package = server
.mock("GET", "/v3alpha/systems/npm/packages/crossenv")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
.create_async()
.await;
let _declared_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/crossenv/versions/1.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 3}"#)
.create_async()
.await;
let _candidate_package = server
.mock("GET", "/v3alpha/systems/npm/packages/cross-env")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "7.0.0"}, "isDefault": true}]}"#)
.create_async()
.await;
let _candidate_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/cross-env/versions/7.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 900}"#)
.create_async()
.await;
let (signal, completeness) = client
.typosquat_signal(DepsDevSystem::Npm, "crossenv")
.await;
let signal = signal.expect("300x ratio must fire");
assert_eq!(signal.suspected_name, "cross-env");
assert_eq!(signal.declared_dependent_count, 3);
assert_eq!(signal.suspected_dependent_count, 900);
assert_eq!(completeness, FetchCompleteness::Complete);
}
#[tokio::test]
async fn typosquat_signal_negative_case_below_threshold_returns_none() {
let (mut server, client) = mock_client().await;
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/coffeescript:similarlyNamedPackages",
)
.with_status(200)
.with_body(
r#"{"packageKey": {"name": "coffeescript"}, "packages": [{"packageKey": {"name": "coffee-script"}}]}"#,
)
.create_async()
.await;
let _declared_package = server
.mock("GET", "/v3alpha/systems/npm/packages/coffeescript")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "2.0.0"}, "isDefault": true}]}"#)
.create_async()
.await;
let _declared_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/coffeescript/versions/2.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 1213}"#)
.create_async()
.await;
let _candidate_package = server
.mock("GET", "/v3alpha/systems/npm/packages/coffee-script")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
.create_async()
.await;
let _candidate_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/coffee-script/versions/1.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 8377}"#)
.create_async()
.await;
let (signal, completeness) = client
.typosquat_signal(DepsDevSystem::Npm, "coffeescript")
.await;
assert!(
signal.is_none(),
"a ~6.9x ratio must stay well under the 50x threshold"
);
assert_eq!(completeness, FetchCompleteness::Complete);
}
#[tokio::test]
async fn typosquat_signal_404_on_similarity_returns_none_no_further_calls() {
let (mut server, client) = mock_client().await;
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/missing:similarlyNamedPackages",
)
.with_status(404)
.create_async()
.await;
let package_call = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v3alpha/systems/npm/packages/missing$".into()),
)
.expect(0)
.create_async()
.await;
let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "missing").await;
assert!(signal.is_none());
assert_eq!(
completeness,
FetchCompleteness::Complete,
"a 404 is deps.dev's authoritative negative, not a failure to retry"
);
package_call.assert_async().await;
}
#[tokio::test]
async fn typosquat_signal_timeout_on_similarity_returns_none() {
let (mut server, client) = mock_client().await;
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/slow:similarlyNamedPackages",
)
.with_status(200)
.with_body_from_request(|_req| {
std::thread::sleep(Duration::from_millis(3200));
br#"{"packageKey": {"name": "slow"}, "packages": []}"#.to_vec()
})
.create_async()
.await;
let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "slow").await;
assert!(signal.is_none());
assert_eq!(completeness, FetchCompleteness::Incomplete);
}
#[tokio::test]
async fn typosquat_signal_malformed_json_on_similarity_returns_none() {
let (mut server, client) = mock_client().await;
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/broken:similarlyNamedPackages",
)
.with_status(200)
.with_body("not json")
.create_async()
.await;
let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "broken").await;
assert!(signal.is_none());
assert_eq!(completeness, FetchCompleteness::Incomplete);
}
#[tokio::test]
async fn popularity_dot_segment_name_rejected_before_request() {
let (mut server, client) = mock_client().await;
let call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
assert!(client.popularity(DepsDevSystem::Npm, ".").await.0.is_none());
assert!(
client
.popularity(DepsDevSystem::Npm, "..")
.await
.0
.is_none()
);
call.assert_async().await;
}
#[tokio::test]
async fn popularity_dot_segment_default_version_rejected_before_dependents_request() {
let (mut server, client) = mock_client().await;
let _package = server
.mock("GET", "/v3alpha/systems/npm/packages/evil")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": ".."}, "isDefault": true}]}"#)
.create_async()
.await;
let dependents_call = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/v3alpha/systems/npm/packages/evil/versions/.*:dependents$".into(),
),
)
.expect(0)
.create_async()
.await;
let (dependent_count, _) = client.popularity(DepsDevSystem::Npm, "evil").await;
assert!(dependent_count.is_none());
dependents_call.assert_async().await;
}
#[tokio::test]
async fn typosquat_signal_dependents_404_memoizes_error_ttl_not_success_ttl() {
let (mut server, client) = mock_client().await;
let _package = server
.mock("GET", "/v3alpha/systems/npm/packages/racy")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
.create_async()
.await;
let _dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/racy/versions/1.0.0:dependents",
)
.with_status(404)
.create_async()
.await;
let (dependent_count, completeness) = client.popularity(DepsDevSystem::Npm, "racy").await;
assert!(dependent_count.is_none());
assert_eq!(
completeness,
FetchCompleteness::Incomplete,
"issue #1463: a transient default-version race is not a verified negative and \
must not be reported as a completed check"
);
let key = PopularityMemoKey {
base: client.base_url.clone(),
system: DepsDevSystem::Npm,
name: "racy".to_string(),
};
let entry = client
.popularity
.memo()
.get_fresh(&key)
.expect("popularity memo entry expected");
assert_eq!(
client
.popularity
.memo()
.ttl_of(&key)
.expect("popularity memo entry expected"),
DEPS_DEV_ERROR_TTL,
"a GetDependents 404 must memoize the short error TTL, not the 1h success TTL \
a genuine GetPackage 404 (authoritative absence) uses"
);
assert_eq!(
entry.completeness(),
FetchCompleteness::Incomplete,
"issue #1463: the memoized entry itself must carry the incomplete marker, so a \
retry within the error TTL still reports Incomplete instead of a stale Complete"
);
}
#[tokio::test]
async fn typosquat_signal_empty_packages_returns_none_no_popularity_calls() {
let (mut server, client) = mock_client().await;
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/lonely:similarlyNamedPackages",
)
.with_status(200)
.with_body(r#"{"packageKey": {"name": "lonely"}, "packages": []}"#)
.create_async()
.await;
let package_call = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v3alpha/systems/npm/packages/lonely$".into()),
)
.expect(0)
.create_async()
.await;
let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "lonely").await;
assert!(signal.is_none());
assert_eq!(completeness, FetchCompleteness::Complete);
package_call.assert_async().await;
}
#[tokio::test]
async fn typosquat_signal_excludes_self_match_candidate() {
let (mut server, client) = mock_client().await;
let similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/self-echo:similarlyNamedPackages",
)
.with_status(200)
.with_body(
r#"{"packageKey": {"name": "self-echo"}, "packages": [{"packageKey": {"name": "self-echo"}}]}"#,
)
.expect(1)
.create_async()
.await;
let declared_package = server
.mock("GET", "/v3alpha/systems/npm/packages/self-echo")
.expect(0)
.create_async()
.await;
let declared_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/self-echo/versions/1.0.0:dependents",
)
.expect(0)
.create_async()
.await;
let (signal, completeness) = client
.typosquat_signal(DepsDevSystem::Npm, "self-echo")
.await;
assert!(signal.is_none());
assert_eq!(completeness, FetchCompleteness::Complete);
similarity.assert_async().await;
declared_package.assert_async().await;
declared_dependents.assert_async().await;
}
#[tokio::test]
async fn typosquat_signal_caps_candidates_checked_at_five() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (mut server, client) = mock_client().await;
let candidates_json: String = (0..20)
.map(|i| format!(r#"{{"packageKey": {{"name": "cand-{i}"}}}}"#))
.collect::<Vec<_>>()
.join(",");
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/tiny:similarlyNamedPackages",
)
.with_status(200)
.with_body(format!(
r#"{{"packageKey": {{"name": "tiny"}}, "packages": [{candidates_json}]}}"#
))
.create_async()
.await;
let _declared_package = server
.mock("GET", "/v3alpha/systems/npm/packages/tiny")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
.create_async()
.await;
let _declared_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/tiny/versions/1.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 1}"#)
.create_async()
.await;
let call_count = Arc::new(AtomicUsize::new(0));
let package_call_count = Arc::clone(&call_count);
let _candidate_packages = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v3alpha/systems/npm/packages/cand-\d+$".into()),
)
.with_status(200)
.with_body_from_request(move |_req| {
package_call_count.fetch_add(1, Ordering::SeqCst);
br#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#
.to_vec()
})
.create_async()
.await;
let dependents_call_count = Arc::clone(&call_count);
let _candidate_dependents = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/v3alpha/systems/npm/packages/cand-\d+/versions/1\.0\.0:dependents$".into(),
),
)
.with_status(200)
.with_body_from_request(move |_req| {
dependents_call_count.fetch_add(1, Ordering::SeqCst);
br#"{"dependentCount": 1000}"#.to_vec()
})
.create_async()
.await;
let (signal, completeness) = client.typosquat_signal(DepsDevSystem::Npm, "tiny").await;
assert!(
signal.is_some(),
"at least one of the capped candidates should still qualify"
);
assert_eq!(completeness, FetchCompleteness::Complete);
assert_eq!(
call_count.load(Ordering::SeqCst),
2 * TYPOSQUAT_MAX_CANDIDATES_CHECKED,
"only TYPOSQUAT_MAX_CANDIDATES_CHECKED candidates should ever be resolved, \
regardless of how many packages[] entries the response carries"
);
}
#[tokio::test]
async fn similar_packages_dot_segment_name_rejected_before_request() {
let (mut server, client) = mock_client().await;
let call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
assert!(
client
.similar_packages(DepsDevSystem::Npm, ".")
.await
.0
.is_empty()
);
assert!(
client
.similar_packages(DepsDevSystem::Npm, "..")
.await
.0
.is_empty()
);
call.assert_async().await;
}
#[tokio::test]
async fn similar_packages_memo_stores_filtered_and_capped_candidates() {
let (mut server, client) = mock_client().await;
let mut candidates_json: Vec<String> = (0..20)
.map(|i| format!(r#"{{"packageKey": {{"name": "cand-{i}"}}}}"#))
.collect();
candidates_json.push(r#"{"packageKey": {"name": "tiny"}}"#.to_string());
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/tiny:similarlyNamedPackages",
)
.with_status(200)
.with_body(format!(
r#"{{"packageKey": {{"name": "tiny"}}, "packages": [{}]}}"#,
candidates_json.join(",")
))
.create_async()
.await;
let (candidates, completeness) = client.similar_packages(DepsDevSystem::Npm, "tiny").await;
assert_eq!(completeness, FetchCompleteness::Complete);
assert_eq!(
candidates.len(),
TYPOSQUAT_MAX_CANDIDATES_CHECKED,
"the returned (and thus cached) list must already be capped"
);
assert!(
candidates.iter().all(|c| c.name != "tiny"),
"the self-match must be filtered before caching, not just before the ratio gate"
);
let key = SimilarityMemoKey {
base: client.base_url.clone(),
system: DepsDevSystem::Npm,
name: "tiny".to_string(),
};
let memo_len = client
.similarity
.memo()
.get_fresh(&key)
.expect("similarity memo entry expected")
.into_value()
.len();
assert_eq!(
memo_len, TYPOSQUAT_MAX_CANDIDATES_CHECKED,
"the memo entry itself must hold only the capped set, not the full 21-entry \
response"
);
}
#[tokio::test]
async fn typosquat_signal_second_call_within_ttl_issues_zero_requests() {
let (mut server, client) = mock_client().await;
let similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/crossenv:similarlyNamedPackages",
)
.with_status(200)
.with_body(
r#"{"packageKey": {"name": "crossenv"}, "packages": [{"packageKey": {"name": "cross-env"}}]}"#,
)
.expect(1)
.create_async()
.await;
let declared_package = server
.mock("GET", "/v3alpha/systems/npm/packages/crossenv")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
.expect(1)
.create_async()
.await;
let declared_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/crossenv/versions/1.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 3}"#)
.expect(1)
.create_async()
.await;
let candidate_package = server
.mock("GET", "/v3alpha/systems/npm/packages/cross-env")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "7.0.0"}, "isDefault": true}]}"#)
.expect(1)
.create_async()
.await;
let candidate_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/cross-env/versions/7.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 900}"#)
.expect(1)
.create_async()
.await;
client
.typosquat_signal(DepsDevSystem::Npm, "crossenv")
.await;
client
.typosquat_signal(DepsDevSystem::Npm, "crossenv")
.await;
similarity.assert_async().await;
declared_package.assert_async().await;
declared_dependents.assert_async().await;
candidate_package.assert_async().await;
candidate_dependents.assert_async().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn typosquat_signal_concurrent_calls_share_one_candidate_popularity_request() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (mut server, client) = mock_client().await;
let client = Arc::new(client);
let call_count = Arc::new(AtomicUsize::new(0));
let _similarity_a = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/pkg-a:similarlyNamedPackages",
)
.with_status(200)
.with_body(
r#"{"packageKey": {"name": "pkg-a"}, "packages": [{"packageKey": {"name": "popular-candidate"}}]}"#,
)
.create_async()
.await;
let _similarity_b = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/pkg-b:similarlyNamedPackages",
)
.with_status(200)
.with_body(
r#"{"packageKey": {"name": "pkg-b"}, "packages": [{"packageKey": {"name": "popular-candidate"}}]}"#,
)
.create_async()
.await;
let _package_a = server
.mock("GET", "/v3alpha/systems/npm/packages/pkg-a")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
.create_async()
.await;
let _package_b = server
.mock("GET", "/v3alpha/systems/npm/packages/pkg-b")
.with_status(200)
.with_body(r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#)
.create_async()
.await;
let _dependents_a = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/pkg-a/versions/1.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 1}"#)
.create_async()
.await;
let _dependents_b = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/pkg-b/versions/1.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 1}"#)
.create_async()
.await;
let call_count_clone = Arc::clone(&call_count);
let _candidate_package = server
.mock("GET", "/v3alpha/systems/npm/packages/popular-candidate")
.with_status(200)
.with_body_from_request(move |_req| {
call_count_clone.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(50));
br#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#
.to_vec()
})
.create_async()
.await;
let _candidate_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/popular-candidate/versions/1.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 900}"#)
.create_async()
.await;
let ((a, a_completeness), (b, b_completeness)) = tokio::join!(
{
let client = Arc::clone(&client);
async move { client.typosquat_signal(DepsDevSystem::Npm, "pkg-a").await }
},
{
let client = Arc::clone(&client);
async move { client.typosquat_signal(DepsDevSystem::Npm, "pkg-b").await }
}
);
assert!(a.is_some(), "pkg-a must see the real typosquat signal");
assert!(b.is_some(), "pkg-b must see the real typosquat signal");
assert_eq!(a_completeness, FetchCompleteness::Complete);
assert_eq!(b_completeness, FetchCompleteness::Complete);
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"exactly one of the two concurrent calls must fetch the shared candidate's \
GetPackage; the other must await that in-flight fetch's result rather than \
duplicating the request"
);
}
const GOSSIP_BATCH_VITE_AND_LEFT_PAD: &str = r#"{"responses":[
{"request":{"packageKey":{"system":"NPM","name":"vite"}},
"findings":{"packageKey":{"system":"NPM","name":"vite"},"recommendedVersions":[],
"defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"isDefault":true,
"findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
"cooldownContext":{"end":"2026-10-09T12:26:19Z"}}],
"cooldownEnd":"2026-10-09T12:26:19Z"},
"packageFindings":[]}},
{"request":{"packageKey":{"system":"NPM","name":"left-pad"}},
"findings":{"packageKey":{"system":"NPM","name":"left-pad"},
"recommendedVersions":[{"versionKey":{"system":"NPM","name":"left-pad","version":"1.3.0"},
"isDefault":true,
"findings":[{"type":"DEPRECATED","risk":"RISK_MEDIUM",
"deprecatedContext":{"reason":"use String.prototype.padStart()"}}],
"cooldownEnd":"2018-04-24T01:10:45Z"}],
"defaultVersion":{"versionKey":{"system":"NPM","name":"left-pad","version":"1.3.0"},
"isDefault":true,
"findings":[{"type":"DEPRECATED","risk":"RISK_MEDIUM",
"deprecatedContext":{"reason":"use String.prototype.padStart()"}}],
"cooldownEnd":"2018-04-24T01:10:45Z"},
"packageFindings":[{"type":"DEPRECATED","risk":"RISK_MEDIUM"}]}}
], "nextPageToken":""}"#;
#[tokio::test]
async fn gossip_findings_batch_parses_active_cooldown() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
.create_async()
.await;
let result = client
.gossip_findings_batch(
DepsDevSystem::Npm,
&["vite".to_string(), "left-pad".to_string()],
)
.await;
let vite = result.get("vite").expect("vite findings expected");
assert_eq!(vite.version, "8.3.1");
let cooldown = vite.cooldown.expect("active cooldown expected");
assert_eq!(cooldown.risk, GossipRiskLevel::High);
assert!(cooldown.is_active(crate::PublishTime::from_unix_secs(0)));
let left_pad = result.get("left-pad").expect("left-pad findings expected");
let left_pad_cooldown = left_pad
.cooldown
.expect("cooldownEnd fallback must still populate cooldown");
assert!(!left_pad_cooldown.is_active(crate::PublishTime::now()));
assert!(left_pad.low_usage.is_none());
}
#[tokio::test]
async fn gossip_findings_batch_no_default_version_omits_name() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body(
r#"{"responses":[{"request":{"packageKey":{"system":"NPM","name":"missing"}},
"findings":{"packageKey":{"system":"NPM","name":"missing"},
"recommendedVersions":[],
"packageFindings":[{"type":"NOT_FOUND","risk":"RISK_CRITICAL"}]}}],
"nextPageToken":""}"#,
)
.create_async()
.await;
let result = client
.gossip_findings_batch(DepsDevSystem::Npm, &["missing".to_string()])
.await;
assert!(
!result.contains_key("missing"),
"NFR-002: a name with no resolvable defaultVersion must be absent, not present \
with an empty GossipFindings"
);
}
#[tokio::test]
async fn gossip_findings_batch_second_call_within_ttl_issues_zero_requests() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
.expect(1)
.create_async()
.await;
client
.gossip_findings_batch(
DepsDevSystem::Npm,
&["vite".to_string(), "left-pad".to_string()],
)
.await;
client
.gossip_findings_batch(
DepsDevSystem::Npm,
&["vite".to_string(), "left-pad".to_string()],
)
.await;
batch.assert_async().await;
}
#[tokio::test]
async fn gossip_findings_batch_follows_next_page_token() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (mut server, client) = mock_client().await;
let call_count = Arc::new(AtomicUsize::new(0));
let call_count_clone = Arc::clone(&call_count);
let _batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body_from_request(move |req| {
let body = req.body().map(|b| String::from_utf8_lossy(b).into_owned());
let is_page2 = body.is_ok_and(|b| b.contains("pageToken"));
call_count_clone.fetch_add(1, Ordering::SeqCst);
if is_page2 {
br#"{"responses":[{"request":{"packageKey":{"system":"NPM","name":"left-pad"}},
"findings":{"packageKey":{"system":"NPM","name":"left-pad"},
"recommendedVersions":[],
"defaultVersion":{"versionKey":{"system":"NPM","name":"left-pad","version":"1.3.0"}},
"packageFindings":[]}}],
"nextPageToken":""}"#
.to_vec()
} else {
br#"{"responses":[{"request":{"packageKey":{"system":"NPM","name":"vite"}},
"findings":{"packageKey":{"system":"NPM","name":"vite"},
"recommendedVersions":[],
"defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"isDefault":true,
"findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
"cooldownContext":{"end":"2026-10-09T12:26:19Z"}}]},
"packageFindings":[]}}],
"nextPageToken":"page2"}"#
.to_vec()
}
})
.create_async()
.await;
let result = client
.gossip_findings_batch(
DepsDevSystem::Npm,
&["vite".to_string(), "left-pad".to_string()],
)
.await;
assert!(
result.contains_key("vite"),
"page 1's result must be included"
);
assert!(
result.contains_key("left-pad"),
"page 2's result must be included"
);
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"exactly two pages must be fetched"
);
}
#[tokio::test]
async fn gossip_findings_batch_oversized_page_returns_empty_and_no_panic() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body(format!(
r#"{{"responses":[],"nextPageToken":"","padding":"{}"}}"#,
"x".repeat(DEPS_DEV_BODY_LIMIT + 1)
))
.create_async()
.await;
let result = client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await;
assert!(result.is_empty());
}
#[tokio::test]
async fn gossip_findings_batch_non_2xx_returns_empty_and_memoizes_error_ttl() {
let (mut server, client) = mock_client().await;
let _batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(500)
.create_async()
.await;
let result = client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await;
assert!(result.is_empty());
let entry_ttl = client
.gossip
.ttl_of(&GossipMemoKey {
base: client.base_url.clone(),
system: DepsDevSystem::Npm,
name: "vite".to_string(),
})
.expect("memo entry expected even on failure");
assert_eq!(entry_ttl, GOSSIP_ERROR_TTL);
}
#[tokio::test]
async fn gossip_findings_batch_sends_uppercase_system() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v3alpha/findingsbatch")
.match_body(mockito::Matcher::Regex(r#""system":"NPM""#.to_string()))
.with_status(200)
.with_body(r#"{"responses":[],"nextPageToken":""}"#)
.expect(1)
.create_async()
.await;
client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await;
batch.assert_async().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gossip_findings_batch_concurrent_calls_for_same_name_issue_one_request() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (mut server, client) = mock_client().await;
let client = Arc::new(client);
let call_count = Arc::new(AtomicUsize::new(0));
let call_count_clone = Arc::clone(&call_count);
let _batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body_from_request(move |_req| {
call_count_clone.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(60));
GOSSIP_BATCH_VITE_AND_LEFT_PAD.as_bytes().to_vec()
})
.create_async()
.await;
let (a, b) = tokio::join!(
{
let client = Arc::clone(&client);
async move {
client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await
}
},
{
let client = Arc::clone(&client);
async move {
client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await
}
}
);
assert!(a.contains_key("vite") || b.contains_key("vite"));
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"exactly one of the two concurrent calls must issue the network request; the \
other must see the in-flight claim and skip it rather than duplicate the \
request"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gossip_findings_batch_cancellation_releases_in_flight_claim() {
let (mut server, client) = mock_client().await;
let client = Arc::new(client);
let batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body_from_request(|_req| {
std::thread::sleep(Duration::from_millis(60));
GOSSIP_BATCH_VITE_AND_LEFT_PAD.as_bytes().to_vec()
})
.expect(1)
.create_async()
.await;
let spawn_client = Arc::clone(&client);
let handle = tokio::spawn(async move {
spawn_client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await
});
let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
assert!(
outcome.is_err(),
"the artificial budget must elapse before the mock responds"
);
tokio::time::sleep(Duration::from_millis(250)).await;
let second = client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await;
assert!(
second.contains_key("vite"),
"a leaked in-flight claim would make this call skip \"vite\" forever — got: \
{second:?}"
);
batch.assert_async().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn gossip_findings_batch_respects_global_concurrency_semaphore() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (mut server, client) = mock_client().await;
let client = Arc::new(client);
let concurrent = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let concurrent_clone = Arc::clone(&concurrent);
let max_clone = Arc::clone(&max_concurrent);
let _batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body_from_request(move |_req| {
let now = concurrent_clone.fetch_add(1, Ordering::SeqCst) + 1;
max_clone.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(60));
concurrent_clone.fetch_sub(1, Ordering::SeqCst);
br#"{"responses":[],"nextPageToken":""}"#.to_vec()
})
.create_async()
.await;
let handles: Vec<_> = (0..(GOSSIP_PREFETCH_CONCURRENCY * 2))
.map(|i| {
let client = Arc::clone(&client);
tokio::spawn(async move {
client
.gossip_findings_batch(DepsDevSystem::Npm, &[format!("pkg-{i}")])
.await
})
})
.collect();
for handle in handles {
handle.await.expect("task must not panic");
}
let observed = max_concurrent.load(Ordering::SeqCst);
assert!(
observed <= GOSSIP_PREFETCH_CONCURRENCY,
"observed {observed} concurrent GetFindingsBatch calls, expected at most \
{GOSSIP_PREFETCH_CONCURRENCY}"
);
}
#[tokio::test]
async fn gossip_findings_batch_oversized_second_page_rejects_whole_result() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (mut server, client) = mock_client().await;
let call_count = Arc::new(AtomicUsize::new(0));
let call_count_clone = Arc::clone(&call_count);
let _batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body_from_request(move |req| {
let body = req.body().map(|b| String::from_utf8_lossy(b).into_owned());
let is_page2 = body.is_ok_and(|b| b.contains("pageToken"));
call_count_clone.fetch_add(1, Ordering::SeqCst);
if is_page2 {
format!(
r#"{{"responses":[],"nextPageToken":"","padding":"{}"}}"#,
"x".repeat(DEPS_DEV_BODY_LIMIT + 1)
)
.into_bytes()
} else {
br#"{"responses":[{"request":{"packageKey":{"system":"NPM","name":"vite"}},
"findings":{"packageKey":{"system":"NPM","name":"vite"},
"recommendedVersions":[],
"defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"isDefault":true,"findings":[]},
"packageFindings":[]}}],"nextPageToken":"page2"}"#
.to_vec()
}
})
.create_async()
.await;
let result = client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await;
assert!(
result.is_empty(),
"an oversized second page must reject the whole batch result (M1's accepted \
all-or-nothing parsing) — got: {result:?}"
);
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"both pages must have actually been requested"
);
}
#[tokio::test]
async fn force_refresh_gossip_findings_bypasses_fresh_memo_entry() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
.expect(2)
.create_async()
.await;
client
.gossip_findings_batch(DepsDevSystem::Npm, &["vite".to_string()])
.await;
let refreshed = client
.force_refresh_gossip_findings(DepsDevSystem::Npm, &["vite".to_string()])
.await;
assert!(
refreshed.contains_key("vite"),
"force refresh must still return a result, not just bypass the memo silently"
);
batch.assert_async().await;
}
#[tokio::test]
async fn force_refresh_gossip_findings_throttles_within_backoff_window() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
.expect(1)
.create_async()
.await;
let first = client
.force_refresh_gossip_findings(DepsDevSystem::Npm, &["vite".to_string()])
.await;
assert!(first.contains_key("vite"));
let second = client
.force_refresh_gossip_findings(DepsDevSystem::Npm, &["vite".to_string()])
.await;
assert!(
second.is_empty(),
"a second force-refresh within the backoff window must be throttled, not \
fetched again"
);
batch.assert_async().await;
}
#[tokio::test]
async fn force_refresh_gossip_findings_throttle_is_per_package() {
let (mut server, client) = mock_client().await;
let batch = server
.mock("POST", "/v3alpha/findingsbatch")
.with_status(200)
.with_body(GOSSIP_BATCH_VITE_AND_LEFT_PAD)
.expect(2)
.create_async()
.await;
client
.force_refresh_gossip_findings(DepsDevSystem::Npm, &["vite".to_string()])
.await;
client
.force_refresh_gossip_findings(DepsDevSystem::Npm, &["left-pad".to_string()])
.await;
batch.assert_async().await;
}
#[test]
fn gossip_last_refresh_attempt_evicts_when_max_entries_reached() {
let client = client();
let now = Instant::now();
for i in 0..MAX_MEMO_ENTRIES {
client.gossip_last_refresh_attempt.insert(
GossipMemoKey {
base: "https://api.deps.dev".to_string(),
system: DepsDevSystem::Npm,
name: format!("pkg-{i}"),
},
now,
);
}
assert_eq!(client.gossip_last_refresh_attempt.len(), MAX_MEMO_ENTRIES);
crate::cache_policy::evict_expired_then_oldest(
&client.gossip_last_refresh_attempt,
MAX_MEMO_ENTRIES,
|attempt| *attempt,
|_| GOSSIP_REFRESH_BACKOFF,
);
client.gossip_last_refresh_attempt.insert(
GossipMemoKey {
base: "https://api.deps.dev".to_string(),
system: DepsDevSystem::Npm,
name: "overflow".to_string(),
},
now,
);
assert!(
client.gossip_last_refresh_attempt.len() <= MAX_MEMO_ENTRIES,
"backoff map must stay bounded at MAX_MEMO_ENTRIES, got {}",
client.gossip_last_refresh_attempt.len()
);
}
#[tokio::test]
async fn gossip_findings_for_version_returns_requested_version_cooldown() {
let (mut server, client) = mock_client().await;
let _findings = server
.mock("GET", "/v3alpha/systems/npm/packages/vite/versions/8.3.1:findings")
.with_status(200)
.with_body(
r#"{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"recommendedVersions":[],
"requestedVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"isDefault":true,
"findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
"cooldownContext":{"end":"2026-10-09T12:26:19Z"}}]},
"defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"isDefault":true,
"findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
"cooldownContext":{"end":"2026-10-09T12:26:19Z"}}]},
"packageFindings":[]}"#,
)
.create_async()
.await;
let findings = client
.gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
.await
.expect("findings expected");
assert_eq!(findings.version, "8.3.1");
assert!(findings.cooldown.is_some());
}
#[tokio::test]
async fn gossip_findings_for_version_low_usage_context_parses_alternative_packages() {
let (mut server, client) = mock_client().await;
let _findings = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/slopsquat-pkg/versions/1.0.0:findings",
)
.with_status(200)
.with_body(
r#"{"versionKey":{"system":"NPM","name":"slopsquat-pkg","version":"1.0.0"},
"recommendedVersions":[],
"requestedVersion":{"versionKey":{"system":"NPM","name":"slopsquat-pkg","version":"1.0.0"},
"isDefault":true,
"findings":[{"type":"LOW_USAGE","risk":"RISK_MEDIUM",
"lowUsageContext":{"alternativePackages":["popular-pkg"]}}]},
"packageFindings":[]}"#,
)
.create_async()
.await;
let findings = client
.gossip_findings_for_version(DepsDevSystem::Npm, "slopsquat-pkg", "1.0.0")
.await
.expect("findings expected");
let low_usage = findings.low_usage.expect("low-usage finding expected");
assert_eq!(low_usage.risk, GossipRiskLevel::Medium);
assert_eq!(
low_usage.alternative_packages,
vec!["popular-pkg".to_string()]
);
}
#[tokio::test]
async fn gossip_findings_for_version_second_call_within_ttl_issues_zero_requests() {
let (mut server, client) = mock_client().await;
let findings = server
.mock("GET", "/v3alpha/systems/npm/packages/vite/versions/8.3.1:findings")
.with_status(200)
.with_body(
r#"{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"recommendedVersions":[],
"requestedVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"isDefault":true,"findings":[]}}"#,
)
.expect(1)
.create_async()
.await;
client
.gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
.await;
client
.gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
.await;
findings.assert_async().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gossip_findings_for_version_survives_dropped_join_handle_and_warms_memo() {
let (mut server, client) = mock_client().await;
let client = Arc::new(client);
let findings = server
.mock("GET", "/v3alpha/systems/npm/packages/vite/versions/8.3.1:findings")
.with_status(200)
.with_body_from_request(|_req| {
std::thread::sleep(Duration::from_millis(60));
br#"{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"recommendedVersions":[],
"requestedVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"isDefault":true,
"findings":[{"type":"COOLDOWN","risk":"RISK_HIGH",
"cooldownContext":{"end":"2026-10-09T12:26:19Z"}}]}}"#
.to_vec()
})
.expect(1)
.create_async()
.await;
let spawn_client = Arc::clone(&client);
let handle = tokio::spawn(async move {
spawn_client
.gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
.await
});
let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
assert!(
outcome.is_err(),
"the artificial budget must elapse before the mock responds"
);
tokio::time::sleep(Duration::from_millis(250)).await;
let second = client
.gossip_findings_for_version(DepsDevSystem::Npm, "vite", "8.3.1")
.await;
assert!(
second.is_some(),
"the memo warmed by the detached task must serve the next call"
);
findings.assert_async().await;
}
#[tokio::test]
async fn gossip_findings_for_version_dot_segment_rejected_before_request() {
let (mut server, client) = mock_client().await;
let call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
assert!(
client
.gossip_findings_for_version(DepsDevSystem::Npm, ".", "1.0.0")
.await
.is_none()
);
assert!(
client
.gossip_findings_for_version(DepsDevSystem::Npm, "left-pad", "..")
.await
.is_none()
);
call.assert_async().await;
}
#[tokio::test]
async fn gossip_findings_for_version_404_returns_none_no_panic() {
let (mut server, client) = mock_client().await;
let _findings = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/missing/versions/1.0.0:findings",
)
.with_status(404)
.create_async()
.await;
let findings = client
.gossip_findings_for_version(DepsDevSystem::Npm, "missing", "1.0.0")
.await;
assert!(findings.is_none());
}
#[tokio::test]
async fn gossip_findings_for_version_no_requested_version_does_not_fall_back_to_default() {
let (mut server, client) = mock_client().await;
let _findings = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/vite/versions/7.0.0:findings",
)
.with_status(200)
.with_body(
r#"{"versionKey":{"system":"NPM","name":"vite","version":"7.0.0"},
"recommendedVersions":[],
"defaultVersion":{"versionKey":{"system":"NPM","name":"vite","version":"8.3.1"},
"isDefault":true,
"findings":[{"type":"LOW_USAGE","risk":"RISK_MEDIUM"}]},
"packageFindings":[]}"#,
)
.create_async()
.await;
let findings = client
.gossip_findings_for_version(DepsDevSystem::Npm, "vite", "7.0.0")
.await;
assert!(
findings.is_none(),
"must not return defaultVersion's (8.3.1) low-usage finding for the requested \
(7.0.0) version — got: {findings:?}"
);
}
}