1mod 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
50const DEPS_DEV_CALL_TIMEOUT: Duration = Duration::from_millis(400);
56
57const DEPS_DEV_SUCCESS_TTL: Duration = Duration::from_hours(1);
64
65const DEPS_DEV_ERROR_TTL: Duration = Duration::from_secs(90);
69
70const MAX_MEMO_ENTRIES: usize = 512;
74
75const DEPS_DEV_BODY_LIMIT: usize = 1024 * 1024;
78
79#[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 signal: Option<SupplyChainTrustSignal>,
103}
104
105#[derive(Debug, Hash, PartialEq, Eq, Clone)]
111struct ProjectKeyMemo {
112 base: String,
113 project_key: String,
115}
116
117struct ProjectMemoEntry {
126 fetched_at: Instant,
127 ttl: Duration,
128 overall_score: Option<f32>,
129}
130
131struct 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#[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
175fn 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
186fn 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
225fn 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
240fn 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
259pub 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
272impl 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 #[must_use]
285 pub fn new(cache: Arc<HttpCache>) -> Self {
286 Self::with_base_url(cache, DEPS_DEV_API.to_string())
287 }
288
289 #[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 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 #[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 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 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 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 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 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
545enum 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 #[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 #[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 assert!(a.is_some() || b.is_some());
1091 assert_eq!(call_count.load(Ordering::SeqCst), 1);
1092 }
1093
1094 #[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 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 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 #[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 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 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 project.assert_async().await;
1216 }
1217
1218 #[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 #[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 #[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 #[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 #[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 #[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 #[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}