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 types;
34
35use std::sync::Arc;
36use std::time::{Duration, Instant};
37
38use dashmap::{DashMap, DashSet};
39
40use types::{DepsDevProject, DepsDevVersionInfo, ProvenanceEntry, RelatedProject};
41pub use types::{ProvenanceStatus, ScorecardSummary, SupplyChainTrustSignal};
42
43use crate::EcosystemId;
44use crate::cache::{BodyLimit, HttpCache};
45use crate::error::DepsError;
46
47const DEPS_DEV_API: &str = "https://api.deps.dev";
48
49/// Per-call timeout inside [`DepsDevClient::trust_signal`]'s two-call
50/// sequence. Deliberately shorter than the hover-side wait budget
51/// (`DEPS_DEV_WAIT_BUDGET` in `lsp_helpers::hover`) so a hung version call
52/// can never by itself consume the whole budget and starve the project call
53/// of any chance to return within it.
54const DEPS_DEV_CALL_TIMEOUT: Duration = Duration::from_millis(400);
55
56/// TTL for a successfully assembled signal, or a definitive HTTP 404 —
57/// matches deps.dev's own declared `cache-control: max-age=3600`. A 404 gets
58/// this same positive TTL, not the shorter error TTL: it is deps.dev
59/// authoritatively saying "no record", not a transient fault, and treating
60/// it as transient would re-fire 1-2 requests every error-TTL window for
61/// every hover of any private/internal/brand-new package.
62const DEPS_DEV_SUCCESS_TTL: Duration = Duration::from_hours(1);
63
64/// TTL for a network error, timeout, 5xx, or malformed response — short
65/// enough that a transient outage self-heals within a couple of minutes of
66/// hovering, matching `github::RELEASE_DATES_ERROR_TTL`'s reasoning.
67const DEPS_DEV_ERROR_TTL: Duration = Duration::from_secs(90);
68
69/// Entry-count bound shared by both memos, mirroring
70/// `github::MAX_RELEASE_DATES_MEMO_ENTRIES`'s reasoning: comfortably above
71/// the distinct-package count of any realistic workspace.
72const MAX_MEMO_ENTRIES: usize = 512;
73
74/// Response body size cap for both deps.dev endpoints — their bodies are a
75/// few KB at most; this is defense-in-depth, not a tuned budget.
76const DEPS_DEV_BODY_LIMIT: usize = 1024 * 1024;
77
78/// Key for [`DepsDevClient`]'s version-level memo.
79///
80/// A typed struct, not a `\0`-joined string: `name` comes from a manifest
81/// and `version` from `in_use_version` (whose lockfile `ConcreteVersion`
82/// branch is never charset-validated), so a joined-string key could let
83/// `("a\0b", "c")` and `("a", "b\0c")` collide and serve another package's
84/// trust signal. A derived `Hash`/`Eq` over four fields cannot collide by
85/// construction. `base` is included for the same reason
86/// `github::ReleaseDatesCache` keys on `(api_base, name)`: a mock-server hit
87/// in tests must never serve a real-API read from a shared client instance.
88#[derive(Debug, Hash, PartialEq, Eq, Clone)]
89struct MemoKey {
90    base: String,
91    system: &'static str,
92    name: String,
93    version: String,
94}
95
96struct MemoEntry {
97    fetched_at: Instant,
98    ttl: Duration,
99    /// The outcome, negative results included — memoizing `None` is what
100    /// makes "zero requests on a repeat call" hold on the failure path too.
101    signal: Option<SupplyChainTrustSignal>,
102}
103
104/// Key for [`DepsDevClient`]'s project-level memo — the Scorecard is a
105/// property of the *project*, not the version, so this is keyed separately
106/// from [`MemoKey`] to avoid one project call per version of a package a
107/// user hovers repeatedly (e.g. several `@babel/*` packages sharing one
108/// project).
109#[derive(Debug, Hash, PartialEq, Eq, Clone)]
110struct ProjectKeyMemo {
111    base: String,
112    /// Already validated by [`is_valid_project_key`] before it reaches here.
113    project_key: String,
114}
115
116/// Stores the raw score only — **never** a [`ScorecardSummary`]. The
117/// `self_reported` disclosure is a property of the *hovering package's own
118/// relation* to the project (spec §6, plan D5/O5), not of the project
119/// itself, so it must never be cached alongside the score: two packages
120/// sharing one `project_key` can have different `self_reported` values, and
121/// caching a resolved `ScorecardSummary` here would let whichever package
122/// warms the entry first silently fix the disclosure marker for every later
123/// package sharing that key (security M1/critic C1).
124struct ProjectMemoEntry {
125    fetched_at: Instant,
126    ttl: Duration,
127    overall_score: Option<f32>,
128}
129
130/// Releases an in-flight claim on drop — including on panic — so a claim can
131/// never leak and permanently block later calls for the same key.
132struct InFlightGuard<'a> {
133    set: &'a DashSet<MemoKey>,
134    key: MemoKey,
135}
136
137impl Drop for InFlightGuard<'_> {
138    fn drop(&mut self) {
139        self.set.remove(&self.key);
140    }
141}
142
143/// Evicts entries from `map` when it is already at `max_entries`, ahead of
144/// an insert that would otherwise grow it further: first every entry expired
145/// against its own TTL, then — only if that freed nothing — the single
146/// oldest entry by `fetched_at`. Mirrors
147/// `github::evict_release_dates_if_full`'s policy, generalized over both of
148/// this module's memo maps.
149fn evict_if_full<K, V>(
150    map: &DashMap<K, V>,
151    max_entries: usize,
152    fetched_at: impl Fn(&V) -> Instant,
153    ttl: impl Fn(&V) -> Duration,
154) where
155    K: Eq + std::hash::Hash + Clone,
156{
157    if map.len() < max_entries {
158        return;
159    }
160    let now = Instant::now();
161    map.retain(|_, v| now.duration_since(fetched_at(v)) < ttl(v));
162    if map.len() >= max_entries
163        && let Some(oldest) = map
164            .iter()
165            .min_by_key(|e| fetched_at(e.value()))
166            .map(|e| e.key().clone())
167    {
168        map.remove(&oldest);
169    }
170}
171
172/// Maps a deps-lsp [`EcosystemId`] to deps.dev's `system` path segment.
173///
174/// Exhaustive, with **no wildcard arm**: the six ecosystems deps.dev does
175/// not cover (FR-011: Composer, Dart, Swift; plus Gradle, Deno, and GitHub
176/// Actions, out of this spec's enumerated seven — plan.md §7 D8) are named
177/// explicitly rather than falling through a `_ => None`. Adding a
178/// fourteenth [`EcosystemId`] variant is therefore a compile error until
179/// someone decides which side it belongs on — stronger than a trait default
180/// that would silently opt a new ecosystem out, and this is what makes
181/// FR-005/FR-011 hold by construction rather than by convention.
182#[must_use]
183pub(crate) const fn deps_dev_system(id: EcosystemId) -> Option<&'static str> {
184    match id {
185        EcosystemId::Npm => Some("npm"),
186        EcosystemId::Cargo => Some("cargo"),
187        EcosystemId::Go => Some("go"),
188        EcosystemId::Maven => Some("maven"),
189        EcosystemId::Pypi => Some("pypi"),
190        EcosystemId::Bundler => Some("rubygems"),
191        EcosystemId::NuGet => Some("nuget"),
192        EcosystemId::Composer
193        | EcosystemId::Dart
194        | EcosystemId::Swift
195        | EcosystemId::Gradle
196        | EcosystemId::Deno
197        | EcosystemId::GithubActions
198        | EcosystemId::GitlabCi => None,
199    }
200}
201
202/// Whether `segment` is a bare, `[A-Za-z0-9-]`-only DNS label, neither
203/// empty nor starting/ending with `-`.
204fn is_host_label(segment: &str) -> bool {
205    !segment.is_empty()
206        && !segment.starts_with('-')
207        && !segment.ends_with('-')
208        && segment
209            .bytes()
210            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
211}
212
213/// Validates `key` (deps.dev's `projectKey.id`, e.g.
214/// `github.com/expressjs/express`) before it is interpolated into a request
215/// path, per plan.md §5.
216///
217/// Encoding the key as one path segment already defeats traversal on its
218/// own (`evil/../secret` percent-encodes to `evil%2F..%2Fsecret`, which
219/// contains no `..` *segment*); this validation's real value is rejecting
220/// junk — a malformed third-party id — before it costs a request. The
221/// host-shape rule on the first segment deliberately over-rejects
222/// non-ASCII repository names, accepted as the cost of not hand-auditing
223/// Unicode in a URL path.
224fn is_valid_project_key(key: &str) -> bool {
225    let segments: Vec<&str> = key.split('/').collect();
226    if !(2..=4).contains(&segments.len()) || segments.iter().any(|s| s.is_empty()) {
227        return false;
228    }
229    if segments
230        .iter()
231        .any(|s| crate::lsp_helpers::is_dot_segment(s))
232    {
233        return false;
234    }
235    // `split_first` cannot return `None`: the length check above already guarantees
236    // `segments` has at least 2 entries.
237    let (host, rest) = segments
238        .split_first()
239        .expect("segments has at least 2 entries");
240    if !host.contains('.') || !host.split('.').all(is_host_label) {
241        return false;
242    }
243    rest.iter().all(|s| {
244        s.bytes()
245            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
246    })
247}
248
249/// Classifies FR-004's three-state provenance verdict from a version's
250/// `slsaProvenances[]`/`attestations[]` arrays.
251fn classify_provenance(
252    slsa: &[ProvenanceEntry],
253    attestations: &[ProvenanceEntry],
254) -> ProvenanceStatus {
255    if slsa.is_empty() && attestations.is_empty() {
256        ProvenanceStatus::None
257    } else if slsa.iter().chain(attestations).any(|e| e.verified) {
258        ProvenanceStatus::Verified
259    } else {
260        ProvenanceStatus::Unverified
261    }
262}
263
264/// Picks the `SOURCE_REPO` project key to fetch a Scorecard for, per
265/// plan.md §5's ranked selection. Returns the chosen, **validated**
266/// project key and whether the pick fell back to a self-reported
267/// (`UNVERIFIED_METADATA`) relation.
268fn choose_project_key(projects: &[RelatedProject]) -> Option<(String, bool)> {
269    let attested = projects
270        .iter()
271        .find(|p| p.relation_type == "SOURCE_REPO" && p.relation_provenance == "SLSA_ATTESTATION");
272    let (chosen, self_reported) = attested.map(|p| (p, false)).or_else(|| {
273        projects
274            .iter()
275            .find(|p| p.relation_type == "SOURCE_REPO")
276            .map(|p| (p, true))
277    })?;
278
279    is_valid_project_key(&chosen.project_key.id)
280        .then(|| (chosen.project_key.id.clone(), self_reported))
281}
282
283/// Assembles [`SupplyChainTrustSignal`]s from deps.dev's two-call sequence.
284///
285/// Layers its own TTL memo over [`HttpCache`]'s reused transport — see the module
286/// docs for the caching/failure-handling rationale.
287pub struct DepsDevClient {
288    cache: Arc<HttpCache>,
289    base_url: String,
290    trusted_origin: String,
291    memo: DashMap<MemoKey, MemoEntry>,
292    projects: DashMap<ProjectKeyMemo, ProjectMemoEntry>,
293    in_flight: DashSet<MemoKey>,
294}
295
296/// Manual, non-exhaustive impl: `VersionData` derives `Debug` and holds this behind
297/// `Option<&Arc<DepsDevClient>>`, but the memo maps' entry types have no reason to
298/// derive `Debug` of their own.
299impl std::fmt::Debug for DepsDevClient {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        f.debug_struct("DepsDevClient").finish_non_exhaustive()
302    }
303}
304
305impl DepsDevClient {
306    /// Creates a client that reuses `cache`'s HTTP transport for both
307    /// deps.dev calls, pointed at the real deps.dev API.
308    #[must_use]
309    pub fn new(cache: Arc<HttpCache>) -> Self {
310        Self::with_base_url(cache, DEPS_DEV_API.to_string())
311    }
312
313    /// Creates a client pointed at `base_url` instead of the real deps.dev
314    /// API, for `mockito`-backed tests.
315    #[cfg(any(test, feature = "test-util"))]
316    #[must_use]
317    pub fn for_test(cache: Arc<HttpCache>, base_url: impl Into<String>) -> Self {
318        Self::with_base_url(cache, base_url.into())
319    }
320
321    fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
322        let trusted_origin = format!("{base_url}/");
323        Self {
324            cache,
325            base_url,
326            trusted_origin,
327            memo: DashMap::new(),
328            projects: DashMap::new(),
329            in_flight: DashSet::new(),
330        }
331    }
332
333    /// Returns the supply-chain trust signal for one resolved
334    /// `(system, name, version)`, or `None` when nothing is available to
335    /// render.
336    ///
337    /// Infallible by construction (FR-006) — every failure degrades to
338    /// `None`, memoized under the short error TTL so a transient outage
339    /// does not re-fire on every hover. A call for a key another concurrent
340    /// call is already fetching returns `None` immediately rather than
341    /// duplicating the fetch (N1) — the caller's own memo read on its next
342    /// call picks up the result once the in-flight fetch completes and
343    /// writes it.
344    pub async fn trust_signal(
345        &self,
346        system: &'static str,
347        name: &str,
348        version: &str,
349    ) -> Option<SupplyChainTrustSignal> {
350        let key = MemoKey {
351            base: self.base_url.clone(),
352            system,
353            name: name.to_string(),
354            version: version.to_string(),
355        };
356
357        if let Some(entry) = self.memo.get(&key)
358            && entry.fetched_at.elapsed() < entry.ttl
359        {
360            return entry.signal.clone();
361        }
362
363        if !self.in_flight.insert(key.clone()) {
364            return None;
365        }
366        let _guard = InFlightGuard {
367            set: &self.in_flight,
368            key: key.clone(),
369        };
370
371        let (signal, ttl) = self.fetch(system, name, version).await;
372        self.store_memo(key, signal.clone(), ttl);
373        signal
374    }
375
376    fn store_memo(&self, key: MemoKey, signal: Option<SupplyChainTrustSignal>, ttl: Duration) {
377        if !self.memo.contains_key(&key) {
378            evict_if_full(&self.memo, MAX_MEMO_ENTRIES, |e| e.fetched_at, |e| e.ttl);
379        }
380        self.memo.insert(
381            key,
382            MemoEntry {
383                fetched_at: Instant::now(),
384                ttl,
385                signal,
386            },
387        );
388    }
389
390    fn store_project_memo(&self, key: ProjectKeyMemo, overall_score: Option<f32>, ttl: Duration) {
391        if !self.projects.contains_key(&key) {
392            evict_if_full(
393                &self.projects,
394                MAX_MEMO_ENTRIES,
395                |e| e.fetched_at,
396                |e| e.ttl,
397            );
398        }
399        self.projects.insert(
400            key,
401            ProjectMemoEntry {
402                fetched_at: Instant::now(),
403                ttl,
404                overall_score,
405            },
406        );
407    }
408
409    /// One GET through the shared, transport-only, origin-pinned call site —
410    /// no entry-map caching (this client's own memos own that), bounded by
411    /// [`DEPS_DEV_CALL_TIMEOUT`].
412    async fn get(&self, url: &str) -> Result<bytes::Bytes, DepsDevFetchError> {
413        match tokio::time::timeout(
414            DEPS_DEV_CALL_TIMEOUT,
415            self.cache
416                .get_transport_only_with_headers_limited_trusted_origin(
417                    url,
418                    &[],
419                    BodyLimit::new(DEPS_DEV_BODY_LIMIT),
420                    &self.trusted_origin,
421                ),
422        )
423        .await
424        {
425            Ok(Ok(bytes)) => Ok(bytes),
426            Ok(Err(e)) if e.is_not_found() => Err(DepsDevFetchError::NotFound),
427            Ok(Err(e)) => Err(DepsDevFetchError::Failed(e)),
428            Err(_) => Err(DepsDevFetchError::TimedOut),
429        }
430    }
431
432    /// The two-call sequence (plan.md §4): the version call first, then —
433    /// only if it yields a usable project key — the project call. Each step
434    /// fails independently: a project-call failure keeps the provenance
435    /// already resolved from the version call (spec §6).
436    async fn fetch(
437        &self,
438        system: &'static str,
439        name: &str,
440        version: &str,
441    ) -> (Option<SupplyChainTrustSignal>, Duration) {
442        let version_url = format!(
443            "{}/v3/systems/{system}/packages/{}/versions/{}",
444            self.base_url,
445            urlencoding::encode(name),
446            urlencoding::encode(version),
447        );
448
449        let (provenance, related_projects) = match self.get(&version_url).await {
450            Ok(bytes) => match crate::parser::parse_json_checked::<DepsDevVersionInfo>(&bytes) {
451                Ok(info) => {
452                    let provenance =
453                        classify_provenance(&info.slsa_provenances, &info.attestations);
454                    (Some(provenance), info.related_projects)
455                }
456                Err(e) => {
457                    tracing::debug!(error = %e, "deps.dev version response parse failed");
458                    return (None, DEPS_DEV_ERROR_TTL);
459                }
460            },
461            Err(DepsDevFetchError::NotFound) => return (None, DEPS_DEV_SUCCESS_TTL),
462            Err(DepsDevFetchError::Failed(e)) => {
463                tracing::debug!(error = %e, "deps.dev version fetch failed");
464                return (None, DEPS_DEV_ERROR_TTL);
465            }
466            Err(DepsDevFetchError::TimedOut) => {
467                tracing::debug!(package = name, "deps.dev version fetch timed out");
468                return (None, DEPS_DEV_ERROR_TTL);
469            }
470        };
471
472        // `project_ttl` is `DEPS_DEV_SUCCESS_TTL` when no project key exists at all (nothing
473        // to downgrade for) or the project call succeeded/404'd, and `DEPS_DEV_ERROR_TTL`
474        // when it genuinely failed — `.min` below then downgrades the *whole signal's* memo
475        // TTL whenever the project call was the thing that failed (review C2/critic C2): a
476        // successful version call must not paper over a transient project-call failure with
477        // a full hour of "no Scorecard".
478        let (scorecard, project_ttl) = match choose_project_key(&related_projects) {
479            Some((project_key, self_reported)) => {
480                let (raw_score, ttl) = self.fetch_scorecard(&project_key).await;
481                let scorecard = raw_score.map(|overall_score| ScorecardSummary {
482                    overall_score,
483                    self_reported,
484                });
485                (scorecard, ttl)
486            }
487            None => (None, DEPS_DEV_SUCCESS_TTL),
488        };
489
490        let signal = SupplyChainTrustSignal {
491            scorecard,
492            provenance,
493        };
494        (Some(signal), DEPS_DEV_SUCCESS_TTL.min(project_ttl))
495    }
496
497    /// Fetches (or serves from the project memo) the raw Scorecard score for
498    /// a single, already-validated `project_key`, plus the TTL this outcome
499    /// should be cached under.
500    ///
501    /// Returns the raw score only, **not** a [`ScorecardSummary`] — the
502    /// `self_reported` disclosure is applied by the caller from its own
503    /// per-relation knowledge, never cached here (see [`ProjectMemoEntry`]'s
504    /// docs; security M1/critic C1).
505    async fn fetch_scorecard(&self, project_key: &str) -> (Option<f32>, Duration) {
506        let memo_key = ProjectKeyMemo {
507            base: self.base_url.clone(),
508            project_key: project_key.to_string(),
509        };
510
511        if let Some(entry) = self.projects.get(&memo_key)
512            && entry.fetched_at.elapsed() < entry.ttl
513        {
514            return (entry.overall_score, entry.ttl);
515        }
516
517        let url = format!(
518            "{}/v3/projects/{}",
519            self.base_url,
520            urlencoding::encode(project_key),
521        );
522
523        let (overall_score, ttl) = match self.get(&url).await {
524            Ok(bytes) => match crate::parser::parse_json_checked::<DepsDevProject>(&bytes) {
525                Ok(project) => {
526                    let overall_score = project
527                        .scorecard
528                        .and_then(|s| s.overall_score)
529                        .filter(|score| (0.0..=10.0).contains(score));
530                    (overall_score, DEPS_DEV_SUCCESS_TTL)
531                }
532                Err(e) => {
533                    tracing::debug!(error = %e, "deps.dev project response parse failed");
534                    (None, DEPS_DEV_ERROR_TTL)
535                }
536            },
537            Err(DepsDevFetchError::NotFound) => (None, DEPS_DEV_SUCCESS_TTL),
538            Err(DepsDevFetchError::Failed(e)) => {
539                tracing::debug!(error = %e, "deps.dev project fetch failed");
540                (None, DEPS_DEV_ERROR_TTL)
541            }
542            Err(DepsDevFetchError::TimedOut) => {
543                tracing::debug!(project_key, "deps.dev project fetch timed out");
544                (None, DEPS_DEV_ERROR_TTL)
545            }
546        };
547
548        self.store_project_memo(memo_key, overall_score, ttl);
549        (overall_score, ttl)
550    }
551}
552
553/// Internal classification of a single deps.dev call's failure, so
554/// [`DepsDevClient::fetch`]/[`DepsDevClient::fetch_scorecard`] can pick the
555/// right memo TTL without duplicating the match at every call site.
556enum DepsDevFetchError {
557    NotFound,
558    Failed(DepsError),
559    TimedOut,
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    fn client() -> DepsDevClient {
567        DepsDevClient::new(Arc::new(HttpCache::new()))
568    }
569
570    async fn mock_client() -> (mockito::ServerGuard, DepsDevClient) {
571        let server = mockito::Server::new_async().await;
572        let client = DepsDevClient::for_test(Arc::new(HttpCache::new()), server.url());
573        (server, client)
574    }
575
576    const EXPRESS_VERSION_NO_PROVENANCE: &str = r#"{
577        "slsaProvenances": [],
578        "attestations": [],
579        "relatedProjects": [
580            {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"},
581            {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
582        ]
583    }"#;
584
585    const SIGSTORE_VERSION_VERIFIED: &str = r#"{
586        "slsaProvenances": [{"verified": true, "sourceRepository": "github.com/sigstore/sigstore-js"}],
587        "attestations": [],
588        "relatedProjects": [
589            {"projectKey": {"id": "github.com/sigstore/sigstore-js"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
590        ]
591    }"#;
592
593    const EXPRESS_PROJECT: &str = r#"{"scorecard": {"overallScore": 8.5}}"#;
594
595    // --- deps_dev_system ---
596
597    #[test]
598    fn deps_dev_system_covers_seven_ecosystems() {
599        assert_eq!(deps_dev_system(EcosystemId::Npm), Some("npm"));
600        assert_eq!(deps_dev_system(EcosystemId::Cargo), Some("cargo"));
601        assert_eq!(deps_dev_system(EcosystemId::Go), Some("go"));
602        assert_eq!(deps_dev_system(EcosystemId::Maven), Some("maven"));
603        assert_eq!(deps_dev_system(EcosystemId::Pypi), Some("pypi"));
604        assert_eq!(deps_dev_system(EcosystemId::Bundler), Some("rubygems"));
605        assert_eq!(deps_dev_system(EcosystemId::NuGet), Some("nuget"));
606    }
607
608    #[test]
609    fn deps_dev_system_excludes_uncovered_ecosystems() {
610        assert_eq!(deps_dev_system(EcosystemId::Composer), None);
611        assert_eq!(deps_dev_system(EcosystemId::Dart), None);
612        assert_eq!(deps_dev_system(EcosystemId::Swift), None);
613        assert_eq!(deps_dev_system(EcosystemId::Gradle), None);
614        assert_eq!(deps_dev_system(EcosystemId::Deno), None);
615        assert_eq!(deps_dev_system(EcosystemId::GithubActions), None);
616    }
617
618    // --- is_valid_project_key ---
619
620    #[test]
621    fn is_valid_project_key_accepts_github_style_key() {
622        assert!(is_valid_project_key("github.com/expressjs/express"));
623    }
624
625    #[test]
626    fn is_valid_project_key_rejects_traversal() {
627        assert!(!is_valid_project_key("github.com/../../etc"));
628        assert!(!is_valid_project_key("github.com/expressjs/.."));
629    }
630
631    #[test]
632    fn is_valid_project_key_rejects_non_host_first_segment() {
633        assert!(!is_valid_project_key("not-a-host/expressjs/express"));
634    }
635
636    #[test]
637    fn is_valid_project_key_rejects_too_few_or_too_many_segments() {
638        assert!(!is_valid_project_key("github.com"));
639        assert!(!is_valid_project_key("github.com/a/b/c/d"));
640    }
641
642    // --- classify_provenance ---
643
644    #[test]
645    fn classify_provenance_both_empty_is_none() {
646        assert_eq!(classify_provenance(&[], &[]), ProvenanceStatus::None);
647    }
648
649    #[test]
650    fn classify_provenance_any_verified_is_verified() {
651        let entries = [ProvenanceEntry { verified: true }];
652        assert_eq!(
653            classify_provenance(&entries, &[]),
654            ProvenanceStatus::Verified
655        );
656    }
657
658    #[test]
659    fn classify_provenance_nonempty_unverified_is_unverified() {
660        let entries = [ProvenanceEntry { verified: false }];
661        assert_eq!(
662            classify_provenance(&entries, &[]),
663            ProvenanceStatus::Unverified
664        );
665    }
666
667    // --- trust_signal: end-to-end against mockito ---
668
669    #[tokio::test]
670    async fn trust_signal_renders_score_and_verified_provenance() {
671        let (mut server, client) = mock_client().await;
672        let _version = server
673            .mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
674            .with_status(200)
675            .with_body(SIGSTORE_VERSION_VERIFIED)
676            .create_async()
677            .await;
678        let _project = server
679            .mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
680            .with_status(200)
681            .with_body(r#"{"scorecard": {"overallScore": 9.1}}"#)
682            .create_async()
683            .await;
684
685        let signal = client
686            .trust_signal("npm", "sigstore", "2.3.1")
687            .await
688            .expect("signal expected");
689        assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
690        let scorecard = signal.scorecard.expect("scorecard expected");
691        assert!((scorecard.overall_score - 9.1).abs() < f32::EPSILON);
692        assert!(!scorecard.self_reported);
693    }
694
695    #[tokio::test]
696    async fn trust_signal_self_reported_relation_is_marked() {
697        let (mut server, client) = mock_client().await;
698        let _version = server
699            .mock("GET", "/v3/systems/npm/packages/left-pad/versions/1.0.0")
700            .with_status(200)
701            .with_body(
702                r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
703                    {"projectKey": {"id": "github.com/example/left-pad"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
704                ]}"#,
705            )
706            .create_async()
707            .await;
708        let _project = server
709            .mock("GET", "/v3/projects/github.com%2Fexample%2Fleft-pad")
710            .with_status(200)
711            .with_body(EXPRESS_PROJECT)
712            .create_async()
713            .await;
714
715        let signal = client
716            .trust_signal("npm", "left-pad", "1.0.0")
717            .await
718            .expect("signal expected");
719        let scorecard = signal.scorecard.expect("scorecard expected");
720        assert!(scorecard.self_reported);
721        assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
722    }
723
724    #[tokio::test]
725    async fn trust_signal_both_endpoints_fail_returns_none() {
726        let (mut server, client) = mock_client().await;
727        let _version = server
728            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
729            .with_status(500)
730            .create_async()
731            .await;
732
733        let signal = client.trust_signal("npm", "express", "4.19.2").await;
734        assert!(signal.is_none());
735    }
736
737    #[tokio::test]
738    async fn trust_signal_project_call_fails_keeps_provenance() {
739        let (mut server, client) = mock_client().await;
740        let _version = server
741            .mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
742            .with_status(200)
743            .with_body(SIGSTORE_VERSION_VERIFIED)
744            .create_async()
745            .await;
746        let _project = server
747            .mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
748            .with_status(500)
749            .create_async()
750            .await;
751
752        let signal = client
753            .trust_signal("npm", "sigstore", "2.3.1")
754            .await
755            .expect("signal expected");
756        assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
757        assert!(signal.scorecard.is_none());
758    }
759
760    #[tokio::test]
761    async fn trust_signal_no_source_repo_omits_scorecard_keeps_provenance() {
762        let (mut server, client) = mock_client().await;
763        let _version = server
764            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
765            .with_status(200)
766            .with_body(EXPRESS_VERSION_NO_PROVENANCE)
767            .create_async()
768            .await;
769        let _project = server
770            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
771            .with_status(200)
772            .with_body(EXPRESS_PROJECT)
773            .create_async()
774            .await;
775
776        let signal = client
777            .trust_signal("npm", "express", "4.19.2")
778            .await
779            .expect("signal expected");
780        assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
781        let scorecard = signal.scorecard.expect("scorecard expected");
782        assert!((scorecard.overall_score - 8.5).abs() < f32::EPSILON);
783    }
784
785    #[tokio::test]
786    async fn trust_signal_malformed_json_returns_none() {
787        let (mut server, client) = mock_client().await;
788        let _version = server
789            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
790            .with_status(200)
791            .with_body("not json")
792            .create_async()
793            .await;
794
795        let signal = client.trust_signal("npm", "express", "4.19.2").await;
796        assert!(signal.is_none());
797    }
798
799    #[tokio::test]
800    async fn trust_signal_404_plaintext_body_returns_none_no_panic() {
801        let (mut server, client) = mock_client().await;
802        let _version = server
803            .mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
804            .with_status(404)
805            .with_body("version not found")
806            .create_async()
807            .await;
808
809        let signal = client.trust_signal("npm", "missing", "1.0.0").await;
810        assert!(signal.is_none());
811    }
812
813    #[tokio::test]
814    async fn trust_signal_scorecard_overall_score_absent_omits_scorecard_never_zero() {
815        let (mut server, client) = mock_client().await;
816        let _version = server
817            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
818            .with_status(200)
819            .with_body(EXPRESS_VERSION_NO_PROVENANCE)
820            .create_async()
821            .await;
822        let _project = server
823            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
824            .with_status(200)
825            .with_body(r#"{"scorecard": {}}"#)
826            .create_async()
827            .await;
828
829        let signal = client
830            .trust_signal("npm", "express", "4.19.2")
831            .await
832            .expect("signal expected (provenance still present)");
833        assert!(signal.scorecard.is_none());
834    }
835
836    #[tokio::test]
837    async fn trust_signal_second_call_within_ttl_issues_zero_requests() {
838        let (mut server, client) = mock_client().await;
839        let version = server
840            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
841            .with_status(200)
842            .with_body(EXPRESS_VERSION_NO_PROVENANCE)
843            .expect(1)
844            .create_async()
845            .await;
846        let project = server
847            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
848            .with_status(200)
849            .with_body(EXPRESS_PROJECT)
850            .expect(1)
851            .create_async()
852            .await;
853
854        client.trust_signal("npm", "express", "4.19.2").await;
855        client.trust_signal("npm", "express", "4.19.2").await;
856
857        version.assert_async().await;
858        project.assert_async().await;
859    }
860
861    #[tokio::test]
862    async fn trust_signal_404_is_not_requeried_within_success_ttl() {
863        let (mut server, client) = mock_client().await;
864        let version = server
865            .mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
866            .with_status(404)
867            .expect(1)
868            .create_async()
869            .await;
870
871        client.trust_signal("npm", "missing", "1.0.0").await;
872        client.trust_signal("npm", "missing", "1.0.0").await;
873
874        version.assert_async().await;
875    }
876
877    #[tokio::test]
878    async fn trust_signal_two_packages_sharing_project_key_issue_one_project_call() {
879        let (mut server, client) = mock_client().await;
880        let _v1 = server
881            .mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
882            .with_status(200)
883            .with_body(
884                r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
885                    {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
886                ]}"#,
887            )
888            .create_async()
889            .await;
890        let _v2 = server
891            .mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
892            .with_status(200)
893            .with_body(
894                r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
895                    {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
896                ]}"#,
897            )
898            .create_async()
899            .await;
900        let project = server
901            .mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
902            .with_status(200)
903            .with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
904            .expect(1)
905            .create_async()
906            .await;
907
908        client.trust_signal("npm", "pkg-a", "1.0.0").await;
909        client.trust_signal("npm", "pkg-b", "1.0.0").await;
910
911        project.assert_async().await;
912    }
913
914    #[tokio::test]
915    async fn trust_signal_percent_encodes_go_module_path() {
916        let (mut server, client) = mock_client().await;
917        let version = server
918            .mock(
919                "GET",
920                "/v3/systems/go/packages/golang.org%2Fx%2Ftext/versions/v0.4.0",
921            )
922            .with_status(200)
923            .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
924            .expect(1)
925            .create_async()
926            .await;
927
928        client
929            .trust_signal("go", "golang.org/x/text", "v0.4.0")
930            .await;
931
932        version.assert_async().await;
933    }
934
935    #[tokio::test]
936    async fn trust_signal_percent_encodes_scoped_npm_name() {
937        let (mut server, client) = mock_client().await;
938        let version = server
939            .mock(
940                "GET",
941                "/v3/systems/npm/packages/%40types%2Fnode/versions/20.0.0",
942            )
943            .with_status(200)
944            .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
945            .expect(1)
946            .create_async()
947            .await;
948
949        client.trust_signal("npm", "@types/node", "20.0.0").await;
950
951        version.assert_async().await;
952    }
953
954    #[tokio::test]
955    async fn trust_signal_percent_encodes_maven_coordinate() {
956        let (mut server, client) = mock_client().await;
957        let version = server
958            .mock(
959                "GET",
960                "/v3/systems/maven/packages/com.google.guava%3Aguava/versions/32.0.0",
961            )
962            .with_status(200)
963            .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
964            .expect(1)
965            .create_async()
966            .await;
967
968        client
969            .trust_signal("maven", "com.google.guava:guava", "32.0.0")
970            .await;
971
972        version.assert_async().await;
973    }
974
975    #[tokio::test]
976    async fn trust_signal_memo_keys_do_not_alias_on_control_characters() {
977        let client = client();
978        client.store_memo(
979            MemoKey {
980                base: "https://api.deps.dev".to_string(),
981                system: "npm",
982                name: "a\0b".to_string(),
983                version: "c".to_string(),
984            },
985            Some(SupplyChainTrustSignal::default()),
986            DEPS_DEV_SUCCESS_TTL,
987        );
988        assert!(!client.memo.contains_key(&MemoKey {
989            base: "https://api.deps.dev".to_string(),
990            system: "npm",
991            name: "a".to_string(),
992            version: "b\0c".to_string(),
993        }));
994    }
995
996    #[tokio::test]
997    async fn trust_signal_invalid_project_key_issues_zero_project_requests() {
998        let (mut server, client) = mock_client().await;
999        let _version = server
1000            .mock("GET", "/v3/systems/npm/packages/evil/versions/1.0.0")
1001            .with_status(200)
1002            .with_body(
1003                r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1004                    {"projectKey": {"id": "github.com/../../etc"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
1005                ]}"#,
1006            )
1007            .create_async()
1008            .await;
1009        let project = server
1010            .mock("GET", mockito::Matcher::Regex(r"^/v3/projects/.*".into()))
1011            .expect(0)
1012            .create_async()
1013            .await;
1014
1015        let signal = client
1016            .trust_signal("npm", "evil", "1.0.0")
1017            .await
1018            .expect("signal expected (provenance still present)");
1019        assert!(signal.scorecard.is_none());
1020        project.assert_async().await;
1021    }
1022
1023    #[tokio::test]
1024    async fn trust_signal_concurrent_calls_for_same_key_issue_one_request() {
1025        use std::sync::atomic::{AtomicUsize, Ordering};
1026
1027        let (mut server, client) = mock_client().await;
1028        let call_count = Arc::new(AtomicUsize::new(0));
1029        let call_count_clone = Arc::clone(&call_count);
1030        let client = Arc::new(client);
1031
1032        let _version = server
1033            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1034            .with_status(200)
1035            .with_body_from_request(move |_req| {
1036                call_count_clone.fetch_add(1, Ordering::SeqCst);
1037                EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
1038            })
1039            .create_async()
1040            .await;
1041        let _project = server
1042            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1043            .with_status(200)
1044            .with_body(EXPRESS_PROJECT)
1045            .create_async()
1046            .await;
1047
1048        let (a, b) = tokio::join!(
1049            {
1050                let client = Arc::clone(&client);
1051                async move { client.trust_signal("npm", "express", "4.19.2").await }
1052            },
1053            {
1054                let client = Arc::clone(&client);
1055                async move { client.trust_signal("npm", "express", "4.19.2").await }
1056            }
1057        );
1058        // Exactly one of the two concurrent calls does the fetch; the other
1059        // sees the key already claimed and returns `None` immediately.
1060        assert!(a.is_some() || b.is_some());
1061        assert_eq!(call_count.load(Ordering::SeqCst), 1);
1062    }
1063
1064    /// `lsp_helpers::hover::generate_hover` wraps a `tokio::spawn`ed
1065    /// `trust_signal` call in `tokio::time::timeout(DEPS_DEV_WAIT_BUDGET, ..)`
1066    /// and drops the `JoinHandle` when that elapses (plan.md §8's
1067    /// "spawn-and-warm" design, critic S1/N1). Dropping a `JoinHandle` does
1068    /// *not* abort the underlying task in tokio, so the fetch must keep
1069    /// running and still write the memo — this is what makes an over-budget
1070    /// hover's *next* hover on the same dependency a memo hit rather than a
1071    /// repeated fetch. Modelled here with a small artificial "budget"
1072    /// (5ms) against a slower (60ms) mock response, rather than the real
1073    /// 700ms/400ms production constants, to keep the test fast and
1074    /// non-flaky while exercising the identical mechanism.
1075    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1076    async fn trust_signal_survives_dropped_join_handle_and_warms_memo() {
1077        let (mut server, client) = mock_client().await;
1078        let client = Arc::new(client);
1079        let version = server
1080            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1081            .with_status(200)
1082            .with_body_from_request(|_req| {
1083                std::thread::sleep(Duration::from_millis(60));
1084                EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
1085            })
1086            .expect(1)
1087            .create_async()
1088            .await;
1089
1090        let spawn_client = Arc::clone(&client);
1091        let handle =
1092            tokio::spawn(
1093                async move { spawn_client.trust_signal("npm", "express", "4.19.2").await },
1094            );
1095        // Deliberately much shorter than the mock's 60ms response — this must
1096        // reliably elapse first.
1097        let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
1098        assert!(
1099            outcome.is_err(),
1100            "the artificial budget must elapse before the mock responds"
1101        );
1102        // `outcome`'s `Err` (elapsed) drops the `JoinHandle` here; the spawned
1103        // task keeps running regardless.
1104
1105        // Give the detached task ample real time to finish (60ms response +
1106        // scheduling slack) and write the memo.
1107        tokio::time::sleep(Duration::from_millis(250)).await;
1108
1109        let second = client.trust_signal("npm", "express", "4.19.2").await;
1110        assert!(
1111            second.is_some(),
1112            "the memo warmed by the detached task must serve the next call"
1113        );
1114        version.assert_async().await;
1115    }
1116
1117    /// Regression for security M1 / critic C1: the project memo must never
1118    /// let one package's `self_reported` value leak into another package
1119    /// sharing the same project key. Package A resolves the project via an
1120    /// `SLSA_ATTESTATION` relation (warming the memo first); package B's
1121    /// only relation to the same project is `UNVERIFIED_METADATA` — B's
1122    /// score must still be marked self-reported even though A's fetch (or
1123    /// memo write) happened first, and vice versa for a same-key call made
1124    /// in the other order.
1125    #[tokio::test]
1126    async fn trust_signal_project_memo_never_leaks_self_reported_across_packages() {
1127        let (mut server, client) = mock_client().await;
1128        let _version_a = server
1129            .mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
1130            .with_status(200)
1131            .with_body(
1132                r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1133                    {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
1134                ]}"#,
1135            )
1136            .create_async()
1137            .await;
1138        let _version_b = server
1139            .mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
1140            .with_status(200)
1141            .with_body(
1142                r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1143                    {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
1144                ]}"#,
1145            )
1146            .create_async()
1147            .await;
1148        let project = server
1149            .mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
1150            .with_status(200)
1151            .with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
1152            .expect(1)
1153            .create_async()
1154            .await;
1155
1156        // A first (attested), warming the shared project memo.
1157        let signal_a = client
1158            .trust_signal("npm", "pkg-a", "1.0.0")
1159            .await
1160            .expect("signal expected");
1161        assert!(
1162            !signal_a
1163                .scorecard
1164                .expect("scorecard expected")
1165                .self_reported,
1166            "A's attested relation must not be marked self-reported"
1167        );
1168
1169        // B second, hitting the now-warm project memo, but with its own
1170        // (self-reported) relation.
1171        let signal_b = client
1172            .trust_signal("npm", "pkg-b", "1.0.0")
1173            .await
1174            .expect("signal expected");
1175        assert!(
1176            signal_b
1177                .scorecard
1178                .expect("scorecard expected")
1179                .self_reported,
1180            "B's UNVERIFIED_METADATA relation must be marked self-reported even though A's \
1181             attested fetch warmed the shared project memo first"
1182        );
1183
1184        // Exactly one project call for both packages sharing the key.
1185        project.assert_async().await;
1186    }
1187
1188    /// Regression for review C2 / critic C2: a transient failure on the
1189    /// *project* call must not blank the Scorecard for the full 1h success
1190    /// TTL — the version-level memo entry's own TTL must be downgraded to
1191    /// the short error TTL whenever the project call is what failed.
1192    #[tokio::test]
1193    async fn trust_signal_project_call_failure_downgrades_version_memo_ttl() {
1194        let (mut server, client) = mock_client().await;
1195        let _version = server
1196            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1197            .with_status(200)
1198            .with_body(EXPRESS_VERSION_NO_PROVENANCE)
1199            .create_async()
1200            .await;
1201        let _project = server
1202            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1203            .with_status(500)
1204            .create_async()
1205            .await;
1206
1207        let signal = client
1208            .trust_signal("npm", "express", "4.19.2")
1209            .await
1210            .expect("signal expected (provenance still present)");
1211        assert!(signal.scorecard.is_none());
1212
1213        let key = MemoKey {
1214            base: client.base_url.clone(),
1215            system: "npm",
1216            name: "express".to_string(),
1217            version: "4.19.2".to_string(),
1218        };
1219        let entry_ttl = client.memo.get(&key).expect("memo entry expected").ttl;
1220        assert_eq!(
1221            entry_ttl, DEPS_DEV_ERROR_TTL,
1222            "a failed project call must downgrade the whole signal's memo TTL to the short \
1223             error TTL, not the 1h success TTL"
1224        );
1225    }
1226
1227    /// S3 variant (tester gap): the project response's `overallScore` can be
1228    /// present but the wrong JSON type (a schema drift, not merely absent),
1229    /// which fails to deserialize `DepsDevScorecardWire` at all — must
1230    /// degrade exactly like a project-call failure (scorecard omitted,
1231    /// provenance kept, never rendered as a defaulted `0`).
1232    #[tokio::test]
1233    async fn trust_signal_project_overall_score_wrong_type_omits_scorecard_never_panics() {
1234        let (mut server, client) = mock_client().await;
1235        let _version = server
1236            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1237            .with_status(200)
1238            .with_body(EXPRESS_VERSION_NO_PROVENANCE)
1239            .create_async()
1240            .await;
1241        let _project = server
1242            .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1243            .with_status(200)
1244            .with_body(r#"{"scorecard": {"overallScore": "not-a-number"}}"#)
1245            .create_async()
1246            .await;
1247
1248        let signal = client
1249            .trust_signal("npm", "express", "4.19.2")
1250            .await
1251            .expect("signal expected (provenance still present)");
1252        assert!(signal.scorecard.is_none());
1253    }
1254
1255    /// N1 variant (tester gap): the in-flight claim must be released even
1256    /// when the fetch itself fails, not only on the success path already
1257    /// covered by `trust_signal_concurrent_calls_for_same_key_issue_one_request`.
1258    #[tokio::test]
1259    async fn trust_signal_in_flight_claim_released_after_failure() {
1260        let (mut server, client) = mock_client().await;
1261        let _version = server
1262            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1263            .with_status(500)
1264            .create_async()
1265            .await;
1266
1267        let signal = client.trust_signal("npm", "express", "4.19.2").await;
1268        assert!(signal.is_none());
1269
1270        let key = MemoKey {
1271            base: client.base_url.clone(),
1272            system: "npm",
1273            name: "express".to_string(),
1274            version: "4.19.2".to_string(),
1275        };
1276        assert!(
1277            !client.in_flight.contains(&key),
1278            "the in-flight claim must be released after a failed fetch, not just a successful one"
1279        );
1280    }
1281
1282    /// Tester gap #2: directly asserts the memoized TTL, rather than only
1283    /// the resulting `None` value, for a *version*-call failure — the
1284    /// counterpart to `trust_signal_project_call_failure_downgrades_version_memo_ttl`,
1285    /// which covers the project-call side.
1286    #[tokio::test]
1287    async fn trust_signal_version_call_failure_ttl_is_error_ttl() {
1288        let (mut server, client) = mock_client().await;
1289        let _version = server
1290            .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1291            .with_status(500)
1292            .create_async()
1293            .await;
1294
1295        let signal = client.trust_signal("npm", "express", "4.19.2").await;
1296        assert!(signal.is_none());
1297
1298        let key = MemoKey {
1299            base: client.base_url.clone(),
1300            system: "npm",
1301            name: "express".to_string(),
1302            version: "4.19.2".to_string(),
1303        };
1304        let entry_ttl = client.memo.get(&key).expect("memo entry expected").ttl;
1305        assert_eq!(
1306            entry_ttl, DEPS_DEV_ERROR_TTL,
1307            "a failed version call must memoize the short error TTL, not the 1h success TTL"
1308        );
1309    }
1310
1311    /// Tester gap #6 (perf's finding): the 512-entry cap on the *version*
1312    /// memo must actually bound `self.memo`'s size under sustained inserts,
1313    /// mirroring `github::evict_release_dates_if_full`'s own boundary tests.
1314    #[test]
1315    fn memo_evicts_when_max_entries_reached() {
1316        let client = client();
1317        for i in 0..MAX_MEMO_ENTRIES {
1318            client.store_memo(
1319                MemoKey {
1320                    base: "https://api.deps.dev".to_string(),
1321                    system: "npm",
1322                    name: format!("pkg-{i}"),
1323                    version: "1.0.0".to_string(),
1324                },
1325                None,
1326                DEPS_DEV_SUCCESS_TTL,
1327            );
1328        }
1329        assert_eq!(client.memo.len(), MAX_MEMO_ENTRIES);
1330
1331        client.store_memo(
1332            MemoKey {
1333                base: "https://api.deps.dev".to_string(),
1334                system: "npm",
1335                name: "overflow".to_string(),
1336                version: "1.0.0".to_string(),
1337            },
1338            None,
1339            DEPS_DEV_SUCCESS_TTL,
1340        );
1341
1342        assert!(
1343            client.memo.len() <= MAX_MEMO_ENTRIES,
1344            "memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
1345            client.memo.len()
1346        );
1347    }
1348
1349    /// Same boundary guarantee for the project-level memo (`self.projects`),
1350    /// which has its own independent cap enforcement.
1351    #[test]
1352    fn project_memo_evicts_when_max_entries_reached() {
1353        let client = client();
1354        for i in 0..MAX_MEMO_ENTRIES {
1355            client.store_project_memo(
1356                ProjectKeyMemo {
1357                    base: "https://api.deps.dev".to_string(),
1358                    project_key: format!("github.com/org/repo-{i}"),
1359                },
1360                Some(8.0),
1361                DEPS_DEV_SUCCESS_TTL,
1362            );
1363        }
1364        assert_eq!(client.projects.len(), MAX_MEMO_ENTRIES);
1365
1366        client.store_project_memo(
1367            ProjectKeyMemo {
1368                base: "https://api.deps.dev".to_string(),
1369                project_key: "github.com/org/overflow".to_string(),
1370            },
1371            Some(8.0),
1372            DEPS_DEV_SUCCESS_TTL,
1373        );
1374
1375        assert!(
1376            client.projects.len() <= MAX_MEMO_ENTRIES,
1377            "projects memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
1378            client.projects.len()
1379        );
1380    }
1381}