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