Skip to main content

deps_core/deps_dev/
mod.rs

1//! Supply-chain trust signal via [deps.dev API v3](https://docs.deps.dev/api/v3/).
2//!
3//! [`DepsDevClient::trust_signal`] assembles an [`SupplyChainTrustSignal`]
4//! (OpenSSF Scorecard + SLSA/attestation provenance) for one resolved
5//! `(system, name, version)` from two sequential deps.dev calls, and is
6//! infallible by construction: every failure — network, timeout, non-2xx,
7//! malformed JSON, no linked source repository — degrades to `None` rather
8//! than propagating an error into hover (FR-006), mirroring
9//! [`crate::osv::OsvClient::scan`] and `github::ReleaseDatesCache::fetch`.
10//!
11//! deps.dev sends no `ETag`/`Last-Modified` on either endpoint (live-verified
12//! 2026-09-03), so [`crate::cache::HttpCache`]'s conditional-GET entry cache
13//! cannot apply here — this client reuses only `HttpCache`'s transport
14//! (HTTPS enforcement, DNS guard, body cap, origin-pinned redirects) via
15//! [`crate::cache::HttpCache::get_transport_only_with_headers_limited_trusted_origin`]
16//! and layers its own TTL memo over the *assembled* signal instead, the same
17//! deviation `crate::osv` already documents for OSV.dev's identical
18//! missing-validators case.
19//!
20//! ## `SOURCE_REPO` selection and the self-reported disclosure
21//!
22//! A package's `relatedProjects[]` commonly carries several `SOURCE_REPO`
23//! entries, differing only in `relationProvenance`. `choose_project_key`
24//! prefers an `SLSA_ATTESTATION`-backed entry over an `UNVERIFIED_METADATA`
25//! (package-self-reported) one: the latter is derived from the package's own
26//! manifest metadata, so an unranked pick would let a hostile package point
27//! its repository field at a reputable, high-scoring repo and inherit that
28//! repo's Scorecard. When only a self-reported relation exists,
29//! [`ScorecardSummary::self_reported`] carries that fact to the hover
30//! renderer, which discloses it rather than presenting the score with the
31//! same confidence as an attested relation.
32
33mod 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
65/// Per-call timeout inside [`DepsDevClient::trust_signal`]'s two-call
66/// sequence. Deliberately shorter than the hover-side wait budget
67/// (`DEPS_DEV_WAIT_BUDGET` in `lsp_helpers::hover`) so a hung version call
68/// can never by itself consume the whole budget and starve the project call
69/// of any chance to return within it.
70const DEPS_DEV_CALL_TIMEOUT: Duration = Duration::from_millis(400);
71
72/// Per-call timeout for the typosquat client's deps.dev calls (issue #1437, impl-critic
73/// S4) — deliberately more generous than [`DEPS_DEV_CALL_TIMEOUT`]. That constant was
74/// tuned for `trust_signal`'s synchronous, hover-request-path budget
75/// (`DEPS_DEV_WAIT_BUDGET = 700ms`, itself tight for two sequential 400ms calls);
76/// typosquat resolution instead runs from a background document-lifecycle prefetch (not
77/// on any live-request path — see `deps-lsp::document::typosquat::run_typosquat_prefetch`),
78/// so there is no reason to keep the same tight per-call budget. Live measurement found
79/// `GetPackage` for popular packages (`react`, `typescript`, `next`, `aws-sdk`) routinely
80/// takes 0.4-0.52s — i.e. these would silently time out (and, per FR-005's graceful
81/// degradation, produce no signal at all) under the tighter constant, dropping exactly
82/// the popular candidates the ratio math most needs.
83const TYPOSQUAT_CALL_TIMEOUT: Duration = Duration::from_secs(3);
84
85/// TTL for a successfully assembled signal, or a definitive HTTP 404 —
86/// matches deps.dev's own declared `cache-control: max-age=3600`. A 404 gets
87/// this same positive TTL, not the shorter error TTL: it is deps.dev
88/// authoritatively saying "no record", not a transient fault, and treating
89/// it as transient would re-fire 1-2 requests every error-TTL window for
90/// every hover of any private/internal/brand-new package.
91const DEPS_DEV_SUCCESS_TTL: Duration = Duration::from_hours(1);
92
93/// TTL for a network error, timeout, 5xx, or malformed response — short
94/// enough that a transient outage self-heals within a couple of minutes of
95/// hovering, matching `github::RELEASE_DATES_ERROR_TTL`'s reasoning.
96const DEPS_DEV_ERROR_TTL: Duration = Duration::from_secs(90);
97
98/// Entry-count bound shared by both memos, mirroring
99/// `github::MAX_RELEASE_DATES_MEMO_ENTRIES`'s reasoning: comfortably above
100/// the distinct-package count of any realistic workspace.
101const MAX_MEMO_ENTRIES: usize = 512;
102
103/// Response body size cap for both deps.dev endpoints — their bodies are a
104/// few KB at most; this is defense-in-depth, not a tuned budget.
105const DEPS_DEV_BODY_LIMIT: usize = 1024 * 1024;
106
107/// Whether a typosquat-signal evaluation's deps.dev calls all returned a definitive answer,
108/// or at least one degraded to a failure (issue #1463).
109///
110/// `similar_packages`, `popularity` and [`DepsDevClient::typosquat_signal`] all report this
111/// alongside their usual `None`/empty-on-any-failure degradation (FR-005/FR-006), because a
112/// caller deciding whether a "no signal" result may be trusted as a completed check (versus
113/// retried later) needs to tell "genuinely nothing found" apart from "couldn't ask". Threaded
114/// through the similarity/popularity memos themselves (not just the fresh-fetch path), so a
115/// cache hit inside `DEPS_DEV_ERROR_TTL`'s retry window still reports [`Self::Incomplete`]
116/// instead of silently reporting [`Self::Complete`] for a result that was never actually
117/// verified.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum FetchCompleteness {
120    /// Every call this evaluation needed returned a definitive answer — a successfully
121    /// parsed response or an authoritative negative (e.g. a `GetPackage` 404), fresh or
122    /// served from a success-TTL memo hit.
123    Complete,
124    /// At least one call failed, timed out, or returned unparseable data — fresh, or served
125    /// from an error-TTL memo hit still within its retry window. A `None`/empty result under
126    /// this variant is not a verified negative; the check simply never completed.
127    Incomplete,
128}
129
130impl FetchCompleteness {
131    /// Combines two completeness readings from calls that jointly determine one evaluation —
132    /// `Incomplete` if either input is, matching how a chain of dependent or concurrent calls
133    /// is only as complete as its least complete member.
134    #[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
144/// Per-call timeout for [`DepsDevClient::gossip_findings_for_version`] (issue #1456, spec
145/// 072) — hover's live, version-scoped low-usage fetch. Mirrors [`DEPS_DEV_CALL_TIMEOUT`]'s
146/// exact rationale: this call is awaited under hover's own `GOSSIP_WAIT_BUDGET`
147/// (`lsp_helpers::hover`), a synchronous-request-path budget, not a background one.
148const GOSSIP_CALL_TIMEOUT: Duration = Duration::from_millis(400);
149
150/// Per-call timeout for [`DepsDevClient::gossip_findings_batch`] (issue #1456, spec 072) —
151/// the per-document prefetch's `GetFindingsBatch` POST. Mirrors [`TYPOSQUAT_CALL_TIMEOUT`]'s
152/// rationale: this runs from a background document-lifecycle prefetch, never on any live
153/// hover/diagnostics request path, so it can afford a more generous budget than
154/// [`GOSSIP_CALL_TIMEOUT`] — a batch covering an entire document's dependencies is expected
155/// to take longer than one single-package call.
156const GOSSIP_BATCH_CALL_TIMEOUT: Duration = Duration::from_secs(5);
157
158/// Upper bound on how many `nextPageToken` pages [`DepsDevClient::gossip_findings_batch`]
159/// will follow for one batch call, so a misbehaving or adversarial server cannot keep this
160/// background prefetch looping indefinitely by never returning an empty `nextPageToken`.
161const GOSSIP_BATCH_MAX_PAGES: usize = 20;
162
163/// TTL for a successfully resolved GOSSIP result (batch or version-scoped), matching
164/// [`DEPS_DEV_SUCCESS_TTL`]'s reasoning — plan.md §0 round 4 (N5) restores this memo
165/// specifically so a document's 100ms-debounced re-prefetch trigger does not re-issue a
166/// network call for a package already resolved within the last hour.
167const GOSSIP_SUCCESS_TTL: Duration = Duration::from_hours(1);
168
169/// TTL for a failed GOSSIP fetch (network error, timeout, non-2xx, malformed response),
170/// matching [`DEPS_DEV_ERROR_TTL`]'s reasoning.
171const GOSSIP_ERROR_TTL: Duration = Duration::from_secs(90);
172
173/// Global cap on concurrent `GetFindingsBatch` calls across every open document (issue
174/// #1456, spec 072 FR-012/N8) — mirrors `deps-lsp::document::state::FETCH_PERMITS`'s
175/// server-wide-document-concurrency pattern, bounding the `disabled->enabled` config
176/// transition (one batch call per already-open document, fired simultaneously) and a
177/// cold-start multi-manifest workspace load, neither of which any within-document cap
178/// (there isn't one — this is one POST per document, not a per-package fan-out) would
179/// otherwise bound.
180const GOSSIP_PREFETCH_CONCURRENCY: usize = 8;
181
182/// Minimum interval between two [`DepsDevClient::force_refresh_gossip_findings`] calls for
183/// the same package (issue #1456, spec 072 FR-011/M21a) — deps.dev's own ingestion lag
184/// makes retrying a version-mismatch refetch more often than this pointless, since a newly
185/// published release is unlikely to be re-indexed within minutes.
186const GOSSIP_REFRESH_BACKOFF: Duration = Duration::from_mins(15);
187
188/// Key for [`DepsDevClient`]'s version-level memo.
189///
190/// A typed struct, not a `\0`-joined string: `name` comes from a manifest
191/// and `version` from `resolve_in_use_version` (whose lockfile `ConcreteVersion`
192/// branch is never charset-validated), so a joined-string key could let
193/// `("a\0b", "c")` and `("a", "b\0c")` collide and serve another package's
194/// trust signal. A derived `Hash`/`Eq` over four fields cannot collide by
195/// construction. `base` is included for the same reason
196/// `github::ReleaseDatesCache` keys on `(api_base, name)`: a mock-server hit
197/// in tests must never serve a real-API read from a shared client instance.
198#[derive(Debug, Hash, PartialEq, Eq, Clone)]
199struct MemoKey {
200    base: String,
201    system: DepsDevSystem,
202    name: String,
203    version: String,
204}
205
206/// Key for [`DepsDevClient`]'s project-level memo — the Scorecard is a
207/// property of the *project*, not the version, so this is keyed separately
208/// from [`MemoKey`] to avoid one project call per version of a package a
209/// user hovers repeatedly (e.g. several `@babel/*` packages sharing one
210/// project).
211#[derive(Debug, Hash, PartialEq, Eq, Clone)]
212struct ProjectKeyMemo {
213    base: String,
214    /// Already validated by [`is_valid_project_key`] before it reaches here.
215    project_key: String,
216}
217
218/// Stores the raw score only — **never** a [`ScorecardSummary`]. The
219/// `self_reported` disclosure is a property of the *hovering package's own
220/// relation* to the project (spec §6, plan D5/O5), not of the project
221/// itself, so it must never be cached alongside the score: two packages
222/// sharing one `project_key` can have different `self_reported` values, and
223/// caching a resolved `ScorecardSummary` here would let whichever package
224/// warms the entry first silently fix the disclosure marker for every later
225/// package sharing that key (security M1/critic C1).
226/// Key for [`DepsDevClient`]'s similarity memo (issue #1437) — package-level, not
227/// version-level: `GetSimilarlyNamedPackages` has no version parameter, mirroring
228/// [`ProjectKeyMemo`]'s precedent for a memo scoped narrower than [`MemoKey`].
229#[derive(Debug, Hash, PartialEq, Eq, Clone)]
230struct SimilarityMemoKey {
231    base: String,
232    system: DepsDevSystem,
233    name: String,
234}
235
236/// Key for [`DepsDevClient`]'s popularity memo (issue #1437) — likewise package-level: a
237/// package's `GetDependents`-derived popularity is a property of its own *default* version
238/// (resolved internally via `GetPackage`), not of whatever version a caller happens to ask
239/// about.
240#[derive(Debug, Hash, PartialEq, Eq, Clone)]
241struct PopularityMemoKey {
242    base: String,
243    system: DepsDevSystem,
244    name: String,
245}
246
247/// Key for [`DepsDevClient`]'s package-level GOSSIP memo (issue #1456, spec 072) — mirrors
248/// [`PopularityMemoKey`]'s exact shape: `default_version`'s cooldown/low-usage data is a
249/// property of the package's own current default version, not of whatever version a
250/// caller happens to ask about, so this is keyed package-level, not version-level.
251#[derive(Debug, Hash, PartialEq, Eq, Clone)]
252struct GossipMemoKey {
253    base: String,
254    system: DepsDevSystem,
255    name: String,
256}
257
258/// Key for [`DepsDevClient`]'s version-scoped GOSSIP memo (issue #1456, spec 072) — backs
259/// [`DepsDevClient::gossip_findings_for_version`], hover's live low-usage fetch for the
260/// pinned/resolved version, which is not necessarily the package's `defaultVersion` and so
261/// cannot share [`GossipMemoKey`]'s package-level scope.
262#[derive(Debug, Hash, PartialEq, Eq, Clone)]
263struct GossipVersionMemoKey {
264    base: String,
265    system: DepsDevSystem,
266    name: String,
267    version: String,
268}
269
270/// Releases a `DashSet`-backed in-flight claim on drop — including on panic — for the
271/// skip-and-defer (not coalesce-joined) GOSSIP dedup sets (issue #1456, spec 072 C1/M21d): a
272/// name already claimed elsewhere is skipped this round, not awaited, so this guard's cleanup is
273/// the minimal mechanism that design needs, independent of [`memo::CoalescedMemo`]'s true-join
274/// in-flight tracking.
275struct 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/// Maps a deps-lsp [`EcosystemId`] to deps.dev's `system` path segment.
287///
288/// Exhaustive, with **no wildcard arm**: the six ecosystems deps.dev does
289/// not cover (FR-011: Composer, Dart, Swift; plus Gradle, Deno, and GitHub
290/// Actions, out of this spec's enumerated seven — plan.md §7 D8) are named
291/// explicitly rather than falling through a `_ => None`. Adding a
292/// fourteenth [`EcosystemId`] variant is therefore a compile error until
293/// someone decides which side it belongs on — stronger than a trait default
294/// that would silently opt a new ecosystem out, and this is what makes
295/// FR-005/FR-011 hold by construction rather than by convention.
296///
297/// No longer `lsp-responses`-gated (issue #1437): [`DepsDevClient::typosquat_signal`]'s
298/// diagnostics-path caller (`lsp_helpers::diagnostics::fetch_typosquat_signals`, reached
299/// through `Ecosystem::generate_diagnostics`'s default impl) must compile without that
300/// feature — diagnostics generation is deliberately available to `deps-cli`, which does not
301/// enable `lsp-responses` (see that feature's own doc comment in `Cargo.toml`).
302#[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/// deps.dev's `system` URL path segment (issue #1455 batch item 2).
323///
324/// Exhaustively covers the seven ecosystems `deps_dev_system` maps to it — replaces a raw
325/// `&'static str` threaded through every deps.dev call site and memo key, so a typo'd or
326/// copy-pasted-wrong system string is a compile error instead of a silently wrong URL or a
327/// memo key that aliases the wrong ecosystem's cache entry.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
329pub enum DepsDevSystem {
330    /// npm.
331    Npm,
332    /// crates.io, via Cargo.
333    Cargo,
334    /// Go modules.
335    Go,
336    /// Maven Central and other Maven-coordinate repositories.
337    Maven,
338    /// PyPI.
339    Pypi,
340    /// RubyGems, via Bundler — deps.dev's own name for this ecosystem.
341    Rubygems,
342    /// NuGet.
343    NuGet,
344}
345
346impl DepsDevSystem {
347    /// The exact `system` path segment deps.dev's `v3`/`v3alpha` APIs expect.
348    #[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
362/// Whether `segment` is a bare, `[A-Za-z0-9-]`-only DNS label, neither
363/// empty nor starting/ending with `-`.
364fn 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
373/// Validates `key` (deps.dev's `projectKey.id`, e.g.
374/// `github.com/expressjs/express`) before it is interpolated into a request
375/// path, per plan.md §5.
376///
377/// Encoding the key as one path segment already defeats traversal on its
378/// own (`evil/../secret` percent-encodes to `evil%2F..%2Fsecret`, which
379/// contains no `..` *segment*); this validation's real value is rejecting
380/// junk — a malformed third-party id — before it costs a request. The
381/// host-shape rule on the first segment deliberately over-rejects
382/// non-ASCII repository names, accepted as the cost of not hand-auditing
383/// Unicode in a URL path.
384fn 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
409/// Classifies FR-004's three-state provenance verdict from a version's
410/// `slsaProvenances[]`/`attestations[]` arrays.
411fn 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
424/// Picks the `SOURCE_REPO` project key to fetch a Scorecard for, per
425/// plan.md §5's ranked selection. Returns the chosen, **validated**
426/// project key and whether the pick fell back to a self-reported
427/// (`UNVERIFIED_METADATA`) relation.
428fn 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
443/// Assembles [`SupplyChainTrustSignal`]s from deps.dev's two-call sequence.
444///
445/// Layers its own TTL memo over [`HttpCache`]'s reused transport — see the module
446/// docs for the caching/failure-handling rationale.
447pub struct DepsDevClient {
448    cache: Arc<HttpCache>,
449    base_url: String,
450    trusted_origin: String,
451    /// The trust-signal memo, coalesced (issue #1454): a losing concurrent caller awaits the
452    /// leader's result via [`memo::CoalescedMemo::get_or_fetch`] rather than returning `None`
453    /// immediately.
454    signals: CoalescedMemo<MemoKey, Option<SupplyChainTrustSignal>>,
455    /// Deliberately **not** coalesced, unlike [`Self::signals`]: two concurrent trust-signal
456    /// fetches for different packages that share a `project_key` do still issue duplicate
457    /// project GETs today, and coalescing would dedup that — but doing so is out of scope for
458    /// issue #1467 (a storage-layer refactor, not a behavior change), so this memo is left
459    /// exactly as uncoalesced as it was before this refactor.
460    projects: TtlMemo<ProjectKeyMemo, Option<f32>>,
461    /// Issue #1437: `GetSimilarlyNamedPackages` results, keyed package-level (see
462    /// [`SimilarityMemoKey`]), coalesced for the same reason as [`Self::signals`] —
463    /// `fetch_typosquat_signals`'s concurrent fan-out across a document's dependencies can
464    /// otherwise issue duplicate requests for two dependencies that happen to share a raw name
465    /// before either write lands in the memo.
466    similarity: CoalescedMemo<SimilarityMemoKey, Vec<SimilarPackageCandidate>>,
467    /// Issue #1437: `GetPackage` + `GetDependents`-derived popularity, keyed package-level (see
468    /// [`PopularityMemoKey`]), coalesced for the same reason as [`Self::signals`] — the more
469    /// valuable of the coalesced memos, since a popular typosquat target (e.g. `lodash`) is
470    /// exactly the kind of candidate multiple concurrently-resolved declared dependencies are
471    /// likely to share.
472    popularity: CoalescedMemo<PopularityMemoKey, Option<u64>>,
473    /// Issue #1456: package-level GOSSIP findings memo, backing [`Self::gossip_findings_batch`]
474    /// (see [`GossipMemoKey`]'s docs for why this is package-, not version-, scoped).
475    /// Deliberately not coalesced — [`Self::gossip_in_flight`] already dedups concurrent batch
476    /// calls at the skip-and-defer level.
477    gossip: TtlMemo<GossipMemoKey, Option<GossipFindings>>,
478    /// Issue #1456: in-flight claims for [`Self::gossip`] — a name already claimed by another
479    /// concurrent batch call is skipped by this call rather than re-fetched; the claiming
480    /// call's own memo write is what a losing caller's *next* prefetch trigger will see.
481    gossip_in_flight: DashSet<GossipMemoKey>,
482    /// Issue #1456: version-scoped GOSSIP findings memo, backing
483    /// [`Self::gossip_findings_for_version`] (hover's live low-usage fetch). Deliberately not
484    /// coalesced, like [`Self::gossip`] — converting it would change this memo's existing,
485    /// intentional skip-and-defer semantics.
486    gossip_versions: TtlMemo<GossipVersionMemoKey, Option<GossipFindings>>,
487    /// Issue #1456: in-flight claims for [`Self::gossip_versions`].
488    gossip_version_in_flight: DashSet<GossipVersionMemoKey>,
489    /// Issue #1456, spec 072 FR-012/N8: bounds concurrent `GetFindingsBatch` calls across
490    /// every open document server-wide — see [`GOSSIP_PREFETCH_CONCURRENCY`]'s doc.
491    gossip_semaphore: tokio::sync::Semaphore,
492    /// Issue #1456, spec 072 FR-011/M21: per-package last-attempt timestamp backing
493    /// [`Self::force_refresh_gossip_findings`]'s >=15-minute backoff — bounded by the same
494    /// [`crate::cache_policy::evict_expired_then_oldest`] discipline every other memo in
495    /// this client uses (M21d), so a workspace touching many distinct packages over a long
496    /// server lifetime cannot grow this map unboundedly.
497    gossip_last_refresh_attempt: DashMap<GossipMemoKey, Instant>,
498    /// Code-review finding #4: claims a key for the whole duration of
499    /// [`Self::force_refresh_gossip_findings`]'s check-backoff/evict-memo/fetch/store-memo
500    /// sequence for that name — without this, the backoff read
501    /// (`gossip_last_refresh_attempt.get`) and write (`.insert`) are two separate,
502    /// non-atomic `DashMap` operations, so two concurrent force-refresh calls for the same
503    /// package can both observe "not throttled" before either writes, firing duplicate
504    /// fetches. Mirrors [`Self::gossip_in_flight`]'s identical claim-before-fetch shape
505    /// (the same fix already applied for [`Self::gossip_in_flight`] itself, code-review C1)
506    /// — a name already claimed here is skipped this round rather than raced on.
507    gossip_refresh_in_flight: DashSet<GossipMemoKey>,
508}
509
510/// Parses one `defaultVersion`/`requestedVersion` object into the consumer-facing
511/// [`GossipFindings`] (issue #1456, spec 072). Shared by
512/// [`DepsDevClient::gossip_findings_batch`] (via `defaultVersion`) and
513/// [`DepsDevClient::gossip_findings_for_version`] (via `requestedVersion`, falling back to
514/// `defaultVersion`).
515///
516/// Only the *first* `Cooldown`/`LowUsage` finding of each type is kept — deps.dev's schema
517/// does not document more than one of the same type ever appearing in `findings[]` for one
518/// version, so this is a defensive "first wins" rather than a documented merge rule.
519fn 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    // Issue #1456 security/impl-critic review, S2: no `COOLDOWN` finding was present, but
554    // the version wrapper's own sibling `cooldownEnd` field is live-verified to always be
555    // present regardless (a historical timestamp, not itself the active-cooldown signal —
556    // see that field's own doc). Falling back to it here means `cooldown` ends up `Some`
557    // for *any* version-matched response that carries end-date information at all — which
558    // is exactly what lets `gossip_cooldown_for` (`lsp_helpers::mod`) tell "GOSSIP
559    // authoritatively says not in cooldown" (`cooldown.is_active(now) == false`) apart from
560    // "no GOSSIP data for this version at all" (`cooldown_prefetch` has no matching entry).
561    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
580/// Manual, non-exhaustive impl: `VersionData` derives `Debug` and holds this behind
581/// `Option<&Arc<DepsDevClient>>`, but the memo maps' entry types have no reason to
582/// derive `Debug` of their own.
583impl 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    /// Creates a client that reuses `cache`'s HTTP transport for both
591    /// deps.dev calls, pointed at the real deps.dev API.
592    #[must_use]
593    pub fn new(cache: Arc<HttpCache>) -> Self {
594        Self::with_base_url(cache, DEPS_DEV_API.to_string())
595    }
596
597    /// Creates a client pointed at `base_url` instead of the real deps.dev
598    /// API, for `mockito`-backed tests.
599    #[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    /// Returns the supply-chain trust signal for one resolved
626    /// `(system, name, version)`, or `None` when nothing is available to
627    /// render.
628    ///
629    /// Infallible by construction (FR-006) — every failure degrades to
630    /// `None`, memoized under the short error TTL so a transient outage
631    /// does not re-fire on every hover. A call for a key another concurrent
632    /// call is already fetching awaits that leader's result via `coalesce`
633    /// rather than returning `None` immediately (issue #1454).
634    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    /// One GET through the shared, transport-only, origin-pinned call site —
654    /// no entry-map caching (this client's own memos own that), bounded by
655    /// `timeout`. Callers on the synchronous hover wait-budget pass
656    /// [`DEPS_DEV_CALL_TIMEOUT`]; the typosquat background prefetch (issue #1437, not on
657    /// any live-request path since impl-critic S4) passes the more generous
658    /// [`TYPOSQUAT_CALL_TIMEOUT`] — live measurement found popular packages (`react`,
659    /// `typescript`, `next`, `aws-sdk`) routinely take 0.4-0.52s to resolve, which the
660    /// original single shared 400ms timeout dropped as silent (spec-compliant, but
661    /// self-defeating) degradation.
662    #[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    /// One [`Self::get`] plus its JSON parse, collapsing the identical three-way
687    /// parse-failure/fetch-failure/timeout tracing and error mapping every deps.dev call site
688    /// used to duplicate. `NotFound` is reported as [`CallFailure::NotFound`] rather than
689    /// mapped here — whether a 404 is authoritative absence or a transient race is a
690    /// per-call-site decision (e.g. `fetch_popularity`'s dependents call treats it as the
691    /// latter), so only the call site can pick the right [`CallOutcome`] for it.
692    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            // #756: never interpolate `e`'s `Display` (`DepsError::safe_tracing_summary`).
709            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    /// The two-call sequence (plan.md §4): the version call first, then —
729    /// only if it yields a usable project key — the project call. Each step
730    /// fails independently: a project-call failure keeps the provenance
731    /// already resolved from the version call (spec §6).
732    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        // `project_completeness` is `Incomplete` only when the project call genuinely failed
776        // (review C2/critic C2), so a successful version call can't paper over a transient
777        // project-call failure with a full hour of "no Scorecard".
778        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    /// Fetches (or serves from the project memo) the raw Scorecard score for a single,
800    /// already-validated `project_key`.
801    ///
802    /// Returns the raw score only, **not** a [`ScorecardSummary`] — the `self_reported`
803    /// disclosure is applied by the caller from its own per-relation knowledge, never cached
804    /// here (security M1/critic C1). Not coalesced, unlike [`Self::signals`] — see
805    /// [`Self::projects`]'s field doc.
806    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    /// Returns a typosquat-suspect signal for one declared dependency (issue #1437, spec
846    /// 071), or `None` when nothing clears the ratio gate, paired with whether every
847    /// deps.dev call this evaluation needed actually completed (issue #1463) — see
848    /// [`FetchCompleteness`].
849    ///
850    /// Infallible by construction (NFR-001/FR-005), exactly like [`Self::trust_signal`]:
851    /// every failure at any stage — a below-threshold ratio included — degrades to `None`,
852    /// never a propagated error. [`FetchCompleteness::Incomplete`] is the caller-facing signal
853    /// that a `None`/empty degradation of *this specific* kind happened; it does not itself
854    /// change what this function returns as the signal. Does **not** itself check
855    /// `system`/ecosystem coverage or any config opt-in switch; callers
856    /// (`lsp_helpers::diagnostics::fetch_typosquat_signals`) are responsible for only calling
857    /// this for a `system` `deps_dev_system` actually maps to, and only when the feature is
858    /// enabled (FR-002/FR-009) — mirroring how [`Self::trust_signal`] itself never checks
859    /// ecosystem coverage either.
860    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        // `candidates` is already self-match-filtered and capped at
871        // `TYPOSQUAT_MAX_CANDIDATES_CHECKED` by `Self::similar_packages` itself (issue
872        // #1437 security review M1/N2) — filtered and capped *before* the memo caches it,
873        // not just here at read time, so a large `packages[]` response never sits resident
874        // in the memo either.
875        //
876        // The declared package's own popularity and every candidate's are resolved
877        // concurrently, not sequentially (issue #1437 impl-critic N2): none depends on any
878        // other's result, only the final `evaluate_candidates` call below does. Sequential
879        // resolution could cost up to `2 * (1 + TYPOSQUAT_MAX_CANDIDATES_CHECKED)` deps.dev
880        // round trips end-to-end for one dependency — concurrent resolution collapses that
881        // to roughly the cost of the single slowest branch.
882        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        // Issue #1463: a candidate's popularity call failing is just as much an incomplete
894        // evaluation as the similarity/declared-popularity calls failing — that candidate
895        // might have been the qualifying suspect, so its absence from `resolved` below must
896        // not be reported as a verified "nothing found".
897        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    /// Fetches (or serves from the similarity memo) `GetSimilarlyNamedPackages`'s
918    /// `packages[]` for `name` — identity only, no popularity (plan.md §1) — paired with
919    /// whether this result reflects a completed fetch (issue #1463; see
920    /// [`FetchCompleteness`]).
921    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            // Rejected before any request, and before the memo: retrying can never succeed
933            // for this name, so there is nothing left "incomplete" about it.
934            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    /// `GetSimilarlyNamedPackages` call backing [`Self::similar_packages`].
950    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                // Filtered and capped *before* caching (issue #1437 security review N2), not
972                // just at read time in `typosquat_signal`: `GetSimilarlyNamedPackages`
973                // documents no upper bound on `packages[]` (up to ~30k entries under the 1
974                // MiB body cap), so storing the full, uncapped list in the memo would keep
975                // that worst case resident in memory across every memo entry.
976                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    /// Resolves (or serves from the popularity memo) `name`'s `GetDependents`-derived
993    /// `dependentCount` for its default version, via `GetPackage` (plan.md §1) — paired with
994    /// whether this result reflects a completed fetch (issue #1463; see
995    /// [`FetchCompleteness`]).
996    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    /// The `GetPackage` -> default version -> `GetDependents` sequence (plan.md §1). Each step
1014    /// fails independently, mirroring [`Self::fetch`]'s per-step degradation.
1015    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            // Error TTL, not success (impl-critic addendum): unlike `GetPackage`'s own 404 (a
1075            // genuine "package doesn't exist"), a 404 here can be a transient race — the
1076            // package's default version changed between the `GetPackage` call above and this
1077            // one — not authoritative absence, so a short retry window is correct, and (issue
1078            // #1463) it is an incomplete result, not a verified negative.
1079            Err(CallFailure::NotFound | CallFailure::Transient) => CallOutcome::Degraded(None),
1080        }
1081    }
1082
1083    /// Returns GOSSIP cooldown/low-usage findings for every name in `names` whose
1084    /// package-level `defaultVersion` carries a resolvable result (issue #1456, spec 072)
1085    /// — one `POST /v3alpha/findingsbatch` call for the memo-misses, never one call per
1086    /// name.
1087    ///
1088    /// A name already served from `Self::gossip`'s memo (within `GOSSIP_SUCCESS_TTL`/
1089    /// `GOSSIP_ERROR_TTL`) contributes nothing to the outbound request and lands directly
1090    /// in the returned map. A name already claimed by another concurrent call to this
1091    /// method is skipped by this call rather than fetched a second time (mirrors
1092    /// `Self::similar_packages`'s identical in-flight trade-off) — the claiming call's own
1093    /// memo write is what the losing caller's *next* prefetch trigger will see, rather than
1094    /// this call joining that in-flight fetch directly.
1095    ///
1096    /// The returned map only ever contains a name when GOSSIP reported a resolvable
1097    /// `defaultVersion` for it — a name with no resolvable default version (e.g. a
1098    /// package-level `NOT_FOUND`) is simply absent, not present with an empty
1099    /// [`GossipFindings`] (NFR-002).
1100    ///
1101    /// Does **not** itself check `system`/ecosystem coverage or any config opt-in switch —
1102    /// mirrors every other public method on this client (see [`Self::typosquat_signal`]'s
1103    /// doc for why that's the caller's responsibility).
1104    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        // Security review C1/impl-critic C1: held across the whole fetch below (including
1129        // the semaphore wait), not removed manually only on the normal-return path — a
1130        // `tokio::time::timeout` around this call (as `run_gossip_prefetch` uses) can drop
1131        // this future mid-`.await`, and a manual `self.gossip_in_flight.remove(..)` placed
1132        // after the fetch would then never run, permanently leaking the claim for that
1133        // package (every later call sees it as still in-flight and skips it forever, and
1134        // the memo is never written either). `DashSetInFlightGuard`'s `Drop` impl runs on
1135        // cancellation too, exactly like every other in-flight tracker in this client relies
1136        // on — `memo::InFlightGuard` for `Self::signals`/`Self::similarity`/`Self::popularity`
1137        // (each a [`memo::CoalescedMemo`] wrapping its own `DashMap`-backed in-flight join),
1138        // and this same `DashSetInFlightGuard` for `Self::gossip_version_in_flight`.
1139        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        // N8: bounds concurrent batch calls across every open document, not just within
1156        // this one call. `self.gossip_semaphore` is a plain in-process `Semaphore` this
1157        // client owns exclusively and never closes, so `acquire` cannot return `Closed`.
1158        #[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        // The fetch completed normally (a cancellation before this point would have
1171        // dropped `guards` already, releasing every claim) — release them explicitly here
1172        // too, rather than waiting for this whole method's stack frame to unwind, so a
1173        // concurrent caller waiting on the same key doesn't wait longer than necessary.
1174        drop(guards);
1175
1176        for name in claimed {
1177            // A name absent from `fetched` (the whole call failed, or this specific name
1178            // was missing from `responses[]`) memoizes as `Degraded` so it is retried soon;
1179            // a name present — even as `None`, a resolvable "no cooldown/low-usage finding"
1180            // answer — memoizes as `Definitive`.
1181            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    /// Force-refetches GOSSIP findings for `names`, bypassing whatever the per-package memo
1195    /// currently holds (issue #1456, spec 072 FR-011/M21a) — the counterpart to
1196    /// [`Self::gossip_findings_batch`]'s normal, memo-respecting path, for a caller that has
1197    /// already detected a genuine staleness signal (a version-equality mismatch, FR-008) and
1198    /// knows a memo-respecting call would just return the same stale answer.
1199    ///
1200    /// Each name is independently throttled to at most once every `GOSSIP_REFRESH_BACKOFF`
1201    /// (M21a) via `Self::gossip_last_refresh_attempt` — a name still within its backoff
1202    /// window is silently skipped (absent from the returned map, exactly like a name with no
1203    /// resolvable `defaultVersion`), so a caller does not need its own throttling logic on
1204    /// top of this. Does **not** itself decide which packages need refreshing, which
1205    /// documents to republish for, or filter for privacy — see
1206    /// `deps-lsp::document::gossip_prefetch`'s mismatch-detection function (M21b/M21c), the
1207    /// sole intended caller, which owns the `source_is_public_registry_content` gate.
1208    ///
1209    /// Code-review C1-mirroring fix (finding #4): the backoff check-then-write is not two
1210    /// independent `DashMap` operations here — `Self::gossip_refresh_in_flight` claims each
1211    /// name for the whole check/evict/fetch/store sequence, so two concurrent calls for the
1212    /// same package can never both observe "not throttled" before either writes; the loser
1213    /// simply skips that name this round (mirrors `Self::gossip_in_flight`'s identical
1214    /// claim-before-fetch shape). Also acquires `Self::gossip_semaphore` before fetching
1215    /// (finding #3) — without it, this path bypassed FR-012/N8's global concurrency cap
1216    /// entirely, since it calls `Self::fetch_gossip_batch` directly rather than going
1217    /// through [`Self::gossip_findings_batch`] (which already acquires it).
1218    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        // M21a: evict the main memo entry before fetching, so this call cannot serve the
1264        // same stale `defaultVersion` it was triggered to correct.
1265        for name in &eligible {
1266            self.gossip.remove(&self.gossip_key(system, name));
1267        }
1268
1269        // Finding #3: same global cap `gossip_findings_batch` already applies.
1270        #[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        // Release every refresh claim now that the fetch completed (a cancellation before
1283        // this point would have dropped `guards` already, same as `gossip_findings_batch`'s
1284        // C1 fix).
1285        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    /// One (possibly paginated) `POST /v3alpha/findingsbatch` call for `names`
1310    /// (live-verified request/response shape 2026-09-26, `npm/vite` + `npm/left-pad`).
1311    ///
1312    /// Maps each resolved name to `Some(findings)`, or `None` when GOSSIP reported no
1313    /// resolvable `defaultVersion` for it. The whole call failing (network, timeout,
1314    /// non-2xx, malformed response, or a page exceeding [`DEPS_DEV_BODY_LIMIT`] — N8, this
1315    /// guard applies to *every* page, not only the first) yields an empty map, so every
1316    /// name [`Self::gossip_findings_batch`] asked for falls through to [`GOSSIP_ERROR_TTL`]
1317    /// there rather than this method needing its own per-name failure bookkeeping.
1318    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    /// Returns GOSSIP findings for one exact, resolved `(system, name, version)` — hover's
1402    /// only live, per-request GOSSIP fetch (issue #1456, spec 072 FR-005): the
1403    /// pinned/resolved version may not be the package's `defaultVersion`, so
1404    /// [`Self::gossip_findings_batch`]'s package-level result cannot cover it.
1405    ///
1406    /// Infallible by construction, exactly like [`Self::trust_signal`]: every failure
1407    /// degrades to `None`, memoized under `GOSSIP_ERROR_TTL` so a transient outage does
1408    /// not re-fire on every hover. Backed by `Self::gossip_versions` (version-keyed, not
1409    /// `Self::gossip`'s package-level memo) so a response landing past hover's
1410    /// `GOSSIP_WAIT_BUDGET` (`lsp_helpers::hover`) still warms something usable by the next
1411    /// hover on the same pinned version, mirroring [`Self::trust_signal`]'s
1412    /// spawn-and-warm design.
1413    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    /// Version-scoped `GET .../versions/{version}:findings` call backing
1445    /// [`Self::gossip_findings_for_version`]. Reads only `requestedVersion` (the exact
1446    /// version asked about) — an absent `requestedVersion` means nothing to report for this
1447    /// exact version, never falls back to `defaultVersion`, which would misattribute a
1448    /// different (default/resolved) version's findings to the requested one (code-review
1449    /// finding M2).
1450    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                // Security/impl-critic review M2: must NOT fall back to `default_version` —
1491                // this call asked for `version` specifically (a pinned/resolved version that
1492                // may differ from the package's default), so falling back would attribute
1493                // `defaultVersion`'s low-usage/cooldown data to a version it was never
1494                // computed for. An absent `requestedVersion` means "nothing to report for
1495                // this exact version".
1496                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
1508/// Internal classification of a single deps.dev call's failure, so
1509/// [`DepsDevClient::fetch`]/[`DepsDevClient::fetch_scorecard`] can pick the
1510/// right memo TTL without duplicating the match at every call site.
1511enum DepsDevFetchError {
1512    NotFound,
1513    Failed(DepsError),
1514    TimedOut,
1515}
1516
1517/// Which deps.dev call [`DepsDevClient::get_json`] is making — carries just enough (a
1518/// `'static` label plus the identifying name/key) to produce the exact per-call tracing every
1519/// call site used to write out by hand.
1520enum DepsDevCall<'a> {
1521    /// `GET .../versions/{version}` (the trust-signal client's version call).
1522    Version { name: &'a str },
1523    /// `GET /v3/projects/{key}` (the Scorecard project call).
1524    Project { key: &'a str },
1525    /// `GET .../{name}:similarlyNamedPackages`.
1526    SimilarlyNamed { name: &'a str },
1527    /// `GET .../packages/{name}` (`GetPackage`).
1528    Package { name: &'a str },
1529    /// `GET .../versions/{version}:dependents` (`GetDependents`).
1530    Dependents { name: &'a str },
1531    /// `GET .../versions/{version}:findings` (GOSSIP's version-scoped findings call).
1532    GossipVersionFindings { name: &'a str },
1533}
1534
1535impl DepsDevCall<'_> {
1536    /// The exact label every `"deps.dev {label} ..."` tracing message used to hard-code.
1537    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    /// The declaration-key name to redact for a timeout's tracing, for every variant except
1549    /// [`Self::Project`] (which logs its own `project_key` field instead, unredacted — see
1550    /// [`DepsDevClient::get_json`]'s `TimedOut` arm).
1551    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
1563/// [`DepsDevClient::get_json`]'s error shape — the two-way split every deps.dev call site
1564/// actually branches on: whether a 404 was returned (a per-call-site decision on whether that
1565/// means authoritative absence or a transient race), or everything else (parse failure,
1566/// non-2xx, or timeout — all three degrade a [`CallOutcome`] the same way).
1567enum CallFailure {
1568    /// The server returned 404.
1569    NotFound,
1570    /// A parse failure, non-2xx response, or timeout.
1571    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    /// Issue #204: `licenses[]` on the same version-call response `provenance` is
1726    /// parsed from is threaded into `SupplyChainTrustSignal.licenses`, with no new
1727    /// deps.dev endpoint or call.
1728    #[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    /// A version response with no `licenses` key must degrade to an empty `Vec`,
1751    /// never a panic or a default `None`-then-unwrap.
1752    #[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    /// #1452: `name` of exactly `.`/`..` must be rejected before it reaches the
2117    /// `/v3/systems/{system}/packages/{name}/versions/{v}` fetch, mirroring every other
2118    /// registry client's `is_dot_segment` fetch-sink guard (#341/#349/#365).
2119    #[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    /// #1452: `version` of exactly `.`/`..` must be rejected the same way — it interpolates
2144    /// into the same request path as `name`.
2145    #[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        // Exactly one of the two concurrent calls does the fetch; the other awaits the
2213        // leader's result via `coalesce` instead of returning `None` immediately (issue
2214        // #1454), so both must see the real signal.
2215        assert!(a.is_some());
2216        assert!(b.is_some());
2217        assert_eq!(call_count.load(Ordering::SeqCst), 1);
2218    }
2219
2220    /// `lsp_helpers::hover::generate_hover` wraps a `tokio::spawn`ed
2221    /// `trust_signal` call in `tokio::time::timeout(DEPS_DEV_WAIT_BUDGET, ..)`
2222    /// and drops the `JoinHandle` when that elapses (plan.md §8's
2223    /// "spawn-and-warm" design, critic S1/N1). Dropping a `JoinHandle` does
2224    /// *not* abort the underlying task in tokio, so the fetch must keep
2225    /// running and still write the memo — this is what makes an over-budget
2226    /// hover's *next* hover on the same dependency a memo hit rather than a
2227    /// repeated fetch. Modelled here with a small artificial "budget"
2228    /// (5ms) against a slower (60ms) mock response, rather than the real
2229    /// 700ms/400ms production constants, to keep the test fast and
2230    /// non-flaky while exercising the identical mechanism.
2231    #[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        // Deliberately much shorter than the mock's 60ms response — this must
2253        // reliably elapse first.
2254        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        // `outcome`'s `Err` (elapsed) drops the `JoinHandle` here; the spawned
2260        // task keeps running regardless.
2261
2262        // Give the detached task ample real time to finish (60ms response +
2263        // scheduling slack) and write the memo.
2264        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    /// Regression for security M1 / critic C1: the project memo must never
2277    /// let one package's `self_reported` value leak into another package
2278    /// sharing the same project key. Package A resolves the project via an
2279    /// `SLSA_ATTESTATION` relation (warming the memo first); package B's
2280    /// only relation to the same project is `UNVERIFIED_METADATA` — B's
2281    /// score must still be marked self-reported even though A's fetch (or
2282    /// memo write) happened first, and vice versa for a same-key call made
2283    /// in the other order.
2284    #[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        // A first (attested), warming the shared project memo.
2316        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        // B second, hitting the now-warm project memo, but with its own
2329        // (self-reported) relation.
2330        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        // Exactly one project call for both packages sharing the key.
2344        project.assert_async().await;
2345    }
2346
2347    /// Regression for review C2 / critic C2: a transient failure on the
2348    /// *project* call must not blank the Scorecard for the full 1h success
2349    /// TTL — the version-level memo entry's own TTL must be downgraded to
2350    /// the short error TTL whenever the project call is what failed.
2351    #[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    /// S3 variant (tester gap): the project response's `overallScore` can be
2391    /// present but the wrong JSON type (a schema drift, not merely absent),
2392    /// which fails to deserialize `DepsDevScorecardWire` at all — must
2393    /// degrade exactly like a project-call failure (scorecard omitted,
2394    /// provenance kept, never rendered as a defaulted `0`).
2395    #[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    /// N1 variant (tester gap): the in-flight claim must be released even
2419    /// when the fetch itself fails, not only on the success path already
2420    /// covered by `trust_signal_concurrent_calls_for_same_key_issue_one_request`.
2421    #[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    /// Tester gap #2: directly asserts the memoized TTL, rather than only
2448    /// the resulting `None` value, for a *version*-call failure — the
2449    /// counterpart to `trust_signal_project_call_failure_downgrades_version_memo_ttl`,
2450    /// which covers the project-call side.
2451    #[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    /// Tester gap #6 (perf's finding): the 512-entry cap on the *version*
2483    /// memo must actually bound `self.memo`'s size under sustained inserts,
2484    /// mirroring `github::evict_release_dates_if_full`'s own boundary tests.
2485    #[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    /// Issue #204, live-verified against the real deps.dev API (curl-equivalent
2519    /// checks during implementation confirmed `licenses[]` on all 7 covered
2520    /// systems): the real npm `express` version response carries a non-empty
2521    /// `licenses[]` array, threaded through into `SupplyChainTrustSignal.licenses`.
2522    #[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    /// Same boundary guarantee for the project-level memo (`self.projects`),
2537    /// which has its own independent cap enforcement.
2538    #[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    /// Live-verified evidence pair (plan.md §1): `crossenv` (3 dependents) vs `cross-env`
2568    /// (900 dependents) — a ~300x ratio, well past both the 50x threshold and the
2569    /// 50-dependent floor.
2570    #[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    /// Negative counterpart: a legitimate similarly-named pair whose ratio never clears
2626    /// `TYPOSQUAT_RATIO_THRESHOLD` must not fire (spec NFR-003).
2627    #[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    /// Issue #1463: a per-call timeout must report [`FetchCompleteness::Incomplete`], not
2713    /// silently the same [`FetchCompleteness::Complete`] a genuine "no similar packages"
2714    /// result would report — this is exactly the distinction
2715    /// `deps-lsp::document::typosquat::run_typosquat_prefetch`'s gate relies on to know a
2716    /// failed attempt must be retried.
2717    #[tokio::test]
2718    async fn typosquat_signal_timeout_on_similarity_returns_none() {
2719        // Sleeps past `TYPOSQUAT_CALL_TIMEOUT` (3s, not `DEPS_DEV_CALL_TIMEOUT`'s 400ms —
2720        // see that constant's doc for why the typosquat client uses a more generous
2721        // per-call budget, issue #1437 impl-critic S4).
2722        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    /// #1452 review M1: `fetch_popularity`'s *name* guard, reached via `popularity`, is
2760    /// the only guard `typosquat_signal` (mod.rs) exercises with a server-supplied (not
2761    /// declared) name — `typosquat_signal` feeds `candidate.name` from
2762    /// `GetSimilarlyNamedPackages`'s response straight into `popularity`, so a declared
2763    /// dot-segment name never reaches it (`similar_packages`'s own guard stops that
2764    /// earlier), but a malicious candidate name from the response can.
2765    #[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    /// #1452: `GetPackage`'s server-supplied default version can itself be a `.`/`..`
2786    /// segment — it must be rejected before it reaches the
2787    /// `.../versions/{v}:dependents` fetch, the same as a caller-supplied dot-segment name.
2788    #[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    /// Impl-critic addendum: a 404 from `GetDependents` can be a transient default-version
2814    /// race (the package's default version changed between the `GetPackage` call and this
2815    /// one), not authoritative absence like a `GetPackage` 404 — it must memoize the short
2816    /// [`DEPS_DEV_ERROR_TTL`], not the 1h [`DEPS_DEV_SUCCESS_TTL`], so a retry happens soon.
2817    #[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    /// FR-006, at the client-integration level: a candidate that exactly matches the
2900    /// declared package's own name must never be treated as its own suspect, even if
2901    /// `GetSimilarlyNamedPackages` echoes it back.
2902    #[tokio::test]
2903    async fn typosquat_signal_excludes_self_match_candidate() {
2904        // `Self::similar_packages` filters a self-match out *before* caching (issue #1437
2905        // security review N2), so with the response's only candidate being a self-match,
2906        // the candidate list is empty by the time `typosquat_signal` sees it — it returns
2907        // `None` before ever resolving the declared package's own popularity, hence
2908        // `.expect(0)` on both package/dependents mocks below (not `.expect(1)`: with
2909        // filtering this early, resolving the declared package's popularity would be
2910        // wasted work with no candidate left to compare it against). The pure-gate
2911        // regression guard for FR-006 itself is
2912        // `typosquat::tests::evaluate_candidates_excludes_exact_name_match`.
2913        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    /// Issue #1437 security review M1: `GetSimilarlyNamedPackages` documents no upper bound
2951    /// on `packages[]`. A 20-candidate response must still only cost
2952    /// `TYPOSQUAT_MAX_CANDIDATES_CHECKED` (5) candidate-popularity resolutions (10 requests:
2953    /// `GetPackage` + `GetDependents` per candidate), not 20.
2954    #[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    /// #1452: `name` of exactly `.`/`..` must be rejected before it reaches the
3036    /// `.../packages/{name}:similarlyNamedPackages` fetch.
3037    #[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    /// Issue #1437 security review N2: the similarity *memo* itself must hold only the
3064    /// filtered, capped set, not the full `packages[]` list `GetSimilarlyNamedPackages` can
3065    /// return (up to ~30k entries under the 1 MiB body cap) — otherwise a large response
3066    /// would sit resident in memory for the entire memo TTL regardless of the read-time cap
3067    /// in `typosquat_signal`.
3068    #[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        // A self-match mixed in among the 20 — must be filtered, not just excluded from the
3075        // ratio gate later.
3076        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    /// Issue #1437: the in-flight dedup on `Self::popularity` — two declared packages that
3187    /// happen to share a candidate (a realistic shape: a popular package like `lodash` is a
3188    /// typosquat target for more than one misspelling in the same document) resolved
3189    /// concurrently via `fetch_typosquat_signals`'s fan-out must issue exactly one
3190    /// `GetPackage` request for that shared candidate, mirroring
3191    /// `trust_signal_concurrent_calls_for_same_key_issue_one_request`.
3192    #[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        // Both concurrent calls must see the real, non-degraded candidate popularity
3285        // (issue #1454): the loser of the in-flight race awaits the leader's result via
3286        // `coalesce` instead of degrading to `None`.
3287        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    // --- GOSSIP (issue #1456, spec 072) ---
3301
3302    /// Live-captured response shape (2026-09-26, `POST /v3alpha/findingsbatch` against
3303    /// `npm/vite` + `npm/left-pad`) — the regression fixture every batch test below reuses.
3304    /// Also the fixture that caught a real bug during implementation: `GossipRiskWire`'s
3305    /// variants must deserialize `"RISK_HIGH"`/`"RISK_MEDIUM"`, not a bare `"HIGH"`/
3306    /// `"MEDIUM"` (a `#[serde(rename_all = "SCREAMING_SNAKE_CASE")]` alone produces the
3307    /// latter, silently failing on every real deps.dev risk value).
3308    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        // `left-pad`'s only real finding is `DEPRECATED`, which collapses into
3356        // `GossipFindingType::Other` and is never surfaced as a cooldown/low-usage finding
3357        // — but S2's fix means `cooldown` is still `Some`, sourced from the sibling
3358        // `cooldownEnd` field (2018, long past) rather than a `COOLDOWN` finding — this is
3359        // exactly the tri-state fix: GOSSIP data is present and authoritatively says "not
3360        // active", distinguishable from "no GOSSIP data at all".
3361        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            // `DEPS_DEV_BODY_LIMIT` (1 MiB) — comfortably exceeded by this padded body.
3490            .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    /// Security/impl-critic review C1 regression, positive-behavior half: two concurrent
3548    /// `gossip_findings_batch` calls for the *same* name must issue exactly one network
3549    /// request — the loser sees the name already claimed and skips it (skip-and-defer),
3550    /// mirroring `typosquat_signal_concurrent_calls_share_one_candidate_popularity_request`'s
3551    /// identical in-flight-dedup pattern for the sibling `popularity` memo.
3552    #[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        // The claim winner gets the real result; the loser sees the name already claimed
3590        // and returns without it this round (its own next prefetch trigger gets it from
3591        // the memo the winner warms).
3592        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    /// Security/impl-critic review C1 regression, negative-behavior half — the actual bug:
3603    /// after a `gossip_findings_batch` call is cancelled mid-flight (its future dropped
3604    /// while `.await`ing the fetch, exactly like `run_gossip_prefetch`'s
3605    /// `tokio::time::timeout` wrapper can do under realistic load), the claimed name must
3606    /// NOT stay permanently in-flight — a later call for the same name must be able to
3607    /// claim and fetch it.
3608    #[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        // Deliberately shorter than the mock's 60ms response — cancels (drops) the
3630        // spawned future's `JoinHandle` before the fetch completes.
3631        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        // Give the cancelled task's own future time to actually finish unwinding (the
3638        // `tokio::spawn`ed task itself is NOT aborted by the timeout above — only this
3639        // test's `JoinHandle` await was — so the task and its `InFlightGuard`s still run
3640        // to completion in the background).
3641        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    /// Issue #1456, spec 072 FR-012/N8 regression: the global `GOSSIP_PREFETCH_CONCURRENCY`
3655    /// semaphore must actually cap how many `GetFindingsBatch` calls are in flight at once
3656    /// across documents, not just be wired without effect.
3657    #[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        // Distinct names, so none of these dedupe against each other via the in-flight
3681        // set — every one of them must reach the network and contend for the semaphore.
3682        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    /// Issue #1456, spec 072 FR-012/N8 regression: `DEPS_DEV_BODY_LIMIT` must reject an
3705    /// oversized *second* page too, not only the first — the existing
3706    /// `gossip_findings_batch_oversized_page_returns_empty_and_no_panic` test only covers
3707    /// an oversized first page.
3708    #[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    /// Issue #1456, spec 072 FR-011/M21a regression: the whole point of
3757    /// `force_refresh_gossip_findings` is to reach the network even when
3758    /// `gossip_findings_batch` would have served a fresh, non-expired memo entry — a
3759    /// literal "just call the normal path again" would be a no-op.
3760    #[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        // Warms the memo with a fresh (well within `GOSSIP_SUCCESS_TTL`) entry.
3772        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    /// A second `force_refresh_gossip_findings` call for the same package within
3787    /// `GOSSIP_REFRESH_BACKOFF` must be throttled — no second network call.
3788    #[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    /// Two distinct packages are throttled independently — refreshing one must not
3817    /// throttle the other.
3818    #[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        // The real proof: the mock's `.expect(2)` above only passes if refreshing
3837        // "left-pad" actually reached the network — a per-package (not global) backoff
3838        // would have let it through; a global one would have wrongly throttled it.
3839        batch.assert_async().await;
3840    }
3841
3842    /// Issue #1456, spec 072 FR-011/M21d: the backoff map must stay bounded at
3843    /// `MAX_MEMO_ENTRIES`, mirroring every other memo's boundary test in this module.
3844    #[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    /// Test gap (impl-critic review, item 5): the version-scoped counterpart of
3972    /// `trust_signal_survives_dropped_join_handle_and_warms_memo` — a late
3973    /// `gossip_findings_for_version` response arriving after `GOSSIP_WAIT_BUDGET` has
3974    /// elapsed (hover's own budget, `lsp_helpers::hover`) must still land in the
3975    /// version-keyed memo, so the *next* hover on the same pinned version is a memo hit
3976    /// rather than a repeated fetch.
3977    #[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        // Deliberately much shorter than the mock's 60ms response — this must reliably
4005        // elapse first, mirroring `trust_signal_survives_dropped_join_handle_and_warms_memo`'s
4006        // identical artificial-budget technique.
4007        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        // Give the detached task ample real time to finish (60ms response + scheduling
4014        // slack) and write the version-keyed memo.
4015        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    /// #1452-style guard: a `.`/`..` name or version must never reach the request URL.
4028    #[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    /// Security/impl-critic review M2 regression: when `requestedVersion` is absent from a
4071    /// version-scoped `GetFindings` response, `defaultVersion`'s data must NOT be returned
4072    /// as if it applied to the requested version — that would misattribute one version's
4073    /// low-usage/cooldown finding to a different (pinned/resolved) version.
4074    #[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}