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
43use crate::EcosystemId;
44use crate::cache::{BodyLimit, HttpCache};
45use crate::error::DepsError;
46
47const DEPS_DEV_API: &str = "https://api.deps.dev";
48
49const DEPS_DEV_CALL_TIMEOUT: Duration = Duration::from_millis(400);
55
56const DEPS_DEV_SUCCESS_TTL: Duration = Duration::from_hours(1);
63
64const DEPS_DEV_ERROR_TTL: Duration = Duration::from_secs(90);
68
69const MAX_MEMO_ENTRIES: usize = 512;
73
74const DEPS_DEV_BODY_LIMIT: usize = 1024 * 1024;
77
78#[derive(Debug, Hash, PartialEq, Eq, Clone)]
89struct MemoKey {
90 base: String,
91 system: &'static str,
92 name: String,
93 version: String,
94}
95
96struct MemoEntry {
97 fetched_at: Instant,
98 ttl: Duration,
99 signal: Option<SupplyChainTrustSignal>,
102}
103
104#[derive(Debug, Hash, PartialEq, Eq, Clone)]
110struct ProjectKeyMemo {
111 base: String,
112 project_key: String,
114}
115
116struct ProjectMemoEntry {
125 fetched_at: Instant,
126 ttl: Duration,
127 overall_score: Option<f32>,
128}
129
130struct InFlightGuard<'a> {
133 set: &'a DashSet<MemoKey>,
134 key: MemoKey,
135}
136
137impl Drop for InFlightGuard<'_> {
138 fn drop(&mut self) {
139 self.set.remove(&self.key);
140 }
141}
142
143fn evict_if_full<K, V>(
150 map: &DashMap<K, V>,
151 max_entries: usize,
152 fetched_at: impl Fn(&V) -> Instant,
153 ttl: impl Fn(&V) -> Duration,
154) where
155 K: Eq + std::hash::Hash + Clone,
156{
157 if map.len() < max_entries {
158 return;
159 }
160 let now = Instant::now();
161 map.retain(|_, v| now.duration_since(fetched_at(v)) < ttl(v));
162 if map.len() >= max_entries
163 && let Some(oldest) = map
164 .iter()
165 .min_by_key(|e| fetched_at(e.value()))
166 .map(|e| e.key().clone())
167 {
168 map.remove(&oldest);
169 }
170}
171
172#[must_use]
183pub(crate) const fn deps_dev_system(id: EcosystemId) -> Option<&'static str> {
184 match id {
185 EcosystemId::Npm => Some("npm"),
186 EcosystemId::Cargo => Some("cargo"),
187 EcosystemId::Go => Some("go"),
188 EcosystemId::Maven => Some("maven"),
189 EcosystemId::Pypi => Some("pypi"),
190 EcosystemId::Bundler => Some("rubygems"),
191 EcosystemId::NuGet => Some("nuget"),
192 EcosystemId::Composer
193 | EcosystemId::Dart
194 | EcosystemId::Swift
195 | EcosystemId::Gradle
196 | EcosystemId::Deno
197 | EcosystemId::GithubActions
198 | EcosystemId::GitlabCi => None,
199 }
200}
201
202fn is_host_label(segment: &str) -> bool {
205 !segment.is_empty()
206 && !segment.starts_with('-')
207 && !segment.ends_with('-')
208 && segment
209 .bytes()
210 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
211}
212
213fn is_valid_project_key(key: &str) -> bool {
225 let segments: Vec<&str> = key.split('/').collect();
226 if !(2..=4).contains(&segments.len()) || segments.iter().any(|s| s.is_empty()) {
227 return false;
228 }
229 if segments
230 .iter()
231 .any(|s| crate::lsp_helpers::is_dot_segment(s))
232 {
233 return false;
234 }
235 let (host, rest) = segments
238 .split_first()
239 .expect("segments has at least 2 entries");
240 if !host.contains('.') || !host.split('.').all(is_host_label) {
241 return false;
242 }
243 rest.iter().all(|s| {
244 s.bytes()
245 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
246 })
247}
248
249fn classify_provenance(
252 slsa: &[ProvenanceEntry],
253 attestations: &[ProvenanceEntry],
254) -> ProvenanceStatus {
255 if slsa.is_empty() && attestations.is_empty() {
256 ProvenanceStatus::None
257 } else if slsa.iter().chain(attestations).any(|e| e.verified) {
258 ProvenanceStatus::Verified
259 } else {
260 ProvenanceStatus::Unverified
261 }
262}
263
264fn choose_project_key(projects: &[RelatedProject]) -> Option<(String, bool)> {
269 let attested = projects
270 .iter()
271 .find(|p| p.relation_type == "SOURCE_REPO" && p.relation_provenance == "SLSA_ATTESTATION");
272 let (chosen, self_reported) = attested.map(|p| (p, false)).or_else(|| {
273 projects
274 .iter()
275 .find(|p| p.relation_type == "SOURCE_REPO")
276 .map(|p| (p, true))
277 })?;
278
279 is_valid_project_key(&chosen.project_key.id)
280 .then(|| (chosen.project_key.id.clone(), self_reported))
281}
282
283pub struct DepsDevClient {
288 cache: Arc<HttpCache>,
289 base_url: String,
290 trusted_origin: String,
291 memo: DashMap<MemoKey, MemoEntry>,
292 projects: DashMap<ProjectKeyMemo, ProjectMemoEntry>,
293 in_flight: DashSet<MemoKey>,
294}
295
296impl std::fmt::Debug for DepsDevClient {
300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 f.debug_struct("DepsDevClient").finish_non_exhaustive()
302 }
303}
304
305impl DepsDevClient {
306 #[must_use]
309 pub fn new(cache: Arc<HttpCache>) -> Self {
310 Self::with_base_url(cache, DEPS_DEV_API.to_string())
311 }
312
313 #[cfg(any(test, feature = "test-util"))]
316 #[must_use]
317 pub fn for_test(cache: Arc<HttpCache>, base_url: impl Into<String>) -> Self {
318 Self::with_base_url(cache, base_url.into())
319 }
320
321 fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
322 let trusted_origin = format!("{base_url}/");
323 Self {
324 cache,
325 base_url,
326 trusted_origin,
327 memo: DashMap::new(),
328 projects: DashMap::new(),
329 in_flight: DashSet::new(),
330 }
331 }
332
333 pub async fn trust_signal(
345 &self,
346 system: &'static str,
347 name: &str,
348 version: &str,
349 ) -> Option<SupplyChainTrustSignal> {
350 let key = MemoKey {
351 base: self.base_url.clone(),
352 system,
353 name: name.to_string(),
354 version: version.to_string(),
355 };
356
357 if let Some(entry) = self.memo.get(&key)
358 && entry.fetched_at.elapsed() < entry.ttl
359 {
360 return entry.signal.clone();
361 }
362
363 if !self.in_flight.insert(key.clone()) {
364 return None;
365 }
366 let _guard = InFlightGuard {
367 set: &self.in_flight,
368 key: key.clone(),
369 };
370
371 let (signal, ttl) = self.fetch(system, name, version).await;
372 self.store_memo(key, signal.clone(), ttl);
373 signal
374 }
375
376 fn store_memo(&self, key: MemoKey, signal: Option<SupplyChainTrustSignal>, ttl: Duration) {
377 if !self.memo.contains_key(&key) {
378 evict_if_full(&self.memo, MAX_MEMO_ENTRIES, |e| e.fetched_at, |e| e.ttl);
379 }
380 self.memo.insert(
381 key,
382 MemoEntry {
383 fetched_at: Instant::now(),
384 ttl,
385 signal,
386 },
387 );
388 }
389
390 fn store_project_memo(&self, key: ProjectKeyMemo, overall_score: Option<f32>, ttl: Duration) {
391 if !self.projects.contains_key(&key) {
392 evict_if_full(
393 &self.projects,
394 MAX_MEMO_ENTRIES,
395 |e| e.fetched_at,
396 |e| e.ttl,
397 );
398 }
399 self.projects.insert(
400 key,
401 ProjectMemoEntry {
402 fetched_at: Instant::now(),
403 ttl,
404 overall_score,
405 },
406 );
407 }
408
409 async fn get(&self, url: &str) -> Result<bytes::Bytes, DepsDevFetchError> {
413 match tokio::time::timeout(
414 DEPS_DEV_CALL_TIMEOUT,
415 self.cache
416 .get_transport_only_with_headers_limited_trusted_origin(
417 url,
418 &[],
419 BodyLimit::new(DEPS_DEV_BODY_LIMIT),
420 &self.trusted_origin,
421 ),
422 )
423 .await
424 {
425 Ok(Ok(bytes)) => Ok(bytes),
426 Ok(Err(e)) if e.is_not_found() => Err(DepsDevFetchError::NotFound),
427 Ok(Err(e)) => Err(DepsDevFetchError::Failed(e)),
428 Err(_) => Err(DepsDevFetchError::TimedOut),
429 }
430 }
431
432 async fn fetch(
437 &self,
438 system: &'static str,
439 name: &str,
440 version: &str,
441 ) -> (Option<SupplyChainTrustSignal>, Duration) {
442 let version_url = format!(
443 "{}/v3/systems/{system}/packages/{}/versions/{}",
444 self.base_url,
445 urlencoding::encode(name),
446 urlencoding::encode(version),
447 );
448
449 let (provenance, related_projects) = match self.get(&version_url).await {
450 Ok(bytes) => match crate::parser::parse_json_checked::<DepsDevVersionInfo>(&bytes) {
451 Ok(info) => {
452 let provenance =
453 classify_provenance(&info.slsa_provenances, &info.attestations);
454 (Some(provenance), info.related_projects)
455 }
456 Err(e) => {
457 tracing::debug!(error = %e, "deps.dev version response parse failed");
458 return (None, DEPS_DEV_ERROR_TTL);
459 }
460 },
461 Err(DepsDevFetchError::NotFound) => return (None, DEPS_DEV_SUCCESS_TTL),
462 Err(DepsDevFetchError::Failed(e)) => {
463 tracing::debug!(error = %e, "deps.dev version fetch failed");
464 return (None, DEPS_DEV_ERROR_TTL);
465 }
466 Err(DepsDevFetchError::TimedOut) => {
467 tracing::debug!(package = name, "deps.dev version fetch timed out");
468 return (None, DEPS_DEV_ERROR_TTL);
469 }
470 };
471
472 let (scorecard, project_ttl) = match choose_project_key(&related_projects) {
479 Some((project_key, self_reported)) => {
480 let (raw_score, ttl) = self.fetch_scorecard(&project_key).await;
481 let scorecard = raw_score.map(|overall_score| ScorecardSummary {
482 overall_score,
483 self_reported,
484 });
485 (scorecard, ttl)
486 }
487 None => (None, DEPS_DEV_SUCCESS_TTL),
488 };
489
490 let signal = SupplyChainTrustSignal {
491 scorecard,
492 provenance,
493 };
494 (Some(signal), DEPS_DEV_SUCCESS_TTL.min(project_ttl))
495 }
496
497 async fn fetch_scorecard(&self, project_key: &str) -> (Option<f32>, Duration) {
506 let memo_key = ProjectKeyMemo {
507 base: self.base_url.clone(),
508 project_key: project_key.to_string(),
509 };
510
511 if let Some(entry) = self.projects.get(&memo_key)
512 && entry.fetched_at.elapsed() < entry.ttl
513 {
514 return (entry.overall_score, entry.ttl);
515 }
516
517 let url = format!(
518 "{}/v3/projects/{}",
519 self.base_url,
520 urlencoding::encode(project_key),
521 );
522
523 let (overall_score, ttl) = match self.get(&url).await {
524 Ok(bytes) => match crate::parser::parse_json_checked::<DepsDevProject>(&bytes) {
525 Ok(project) => {
526 let overall_score = project
527 .scorecard
528 .and_then(|s| s.overall_score)
529 .filter(|score| (0.0..=10.0).contains(score));
530 (overall_score, DEPS_DEV_SUCCESS_TTL)
531 }
532 Err(e) => {
533 tracing::debug!(error = %e, "deps.dev project response parse failed");
534 (None, DEPS_DEV_ERROR_TTL)
535 }
536 },
537 Err(DepsDevFetchError::NotFound) => (None, DEPS_DEV_SUCCESS_TTL),
538 Err(DepsDevFetchError::Failed(e)) => {
539 tracing::debug!(error = %e, "deps.dev project fetch failed");
540 (None, DEPS_DEV_ERROR_TTL)
541 }
542 Err(DepsDevFetchError::TimedOut) => {
543 tracing::debug!(project_key, "deps.dev project fetch timed out");
544 (None, DEPS_DEV_ERROR_TTL)
545 }
546 };
547
548 self.store_project_memo(memo_key, overall_score, ttl);
549 (overall_score, ttl)
550 }
551}
552
553enum DepsDevFetchError {
557 NotFound,
558 Failed(DepsError),
559 TimedOut,
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 fn client() -> DepsDevClient {
567 DepsDevClient::new(Arc::new(HttpCache::new()))
568 }
569
570 async fn mock_client() -> (mockito::ServerGuard, DepsDevClient) {
571 let server = mockito::Server::new_async().await;
572 let client = DepsDevClient::for_test(Arc::new(HttpCache::new()), server.url());
573 (server, client)
574 }
575
576 const EXPRESS_VERSION_NO_PROVENANCE: &str = r#"{
577 "slsaProvenances": [],
578 "attestations": [],
579 "relatedProjects": [
580 {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"},
581 {"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
582 ]
583 }"#;
584
585 const SIGSTORE_VERSION_VERIFIED: &str = r#"{
586 "slsaProvenances": [{"verified": true, "sourceRepository": "github.com/sigstore/sigstore-js"}],
587 "attestations": [],
588 "relatedProjects": [
589 {"projectKey": {"id": "github.com/sigstore/sigstore-js"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
590 ]
591 }"#;
592
593 const EXPRESS_PROJECT: &str = r#"{"scorecard": {"overallScore": 8.5}}"#;
594
595 #[test]
598 fn deps_dev_system_covers_seven_ecosystems() {
599 assert_eq!(deps_dev_system(EcosystemId::Npm), Some("npm"));
600 assert_eq!(deps_dev_system(EcosystemId::Cargo), Some("cargo"));
601 assert_eq!(deps_dev_system(EcosystemId::Go), Some("go"));
602 assert_eq!(deps_dev_system(EcosystemId::Maven), Some("maven"));
603 assert_eq!(deps_dev_system(EcosystemId::Pypi), Some("pypi"));
604 assert_eq!(deps_dev_system(EcosystemId::Bundler), Some("rubygems"));
605 assert_eq!(deps_dev_system(EcosystemId::NuGet), Some("nuget"));
606 }
607
608 #[test]
609 fn deps_dev_system_excludes_uncovered_ecosystems() {
610 assert_eq!(deps_dev_system(EcosystemId::Composer), None);
611 assert_eq!(deps_dev_system(EcosystemId::Dart), None);
612 assert_eq!(deps_dev_system(EcosystemId::Swift), None);
613 assert_eq!(deps_dev_system(EcosystemId::Gradle), None);
614 assert_eq!(deps_dev_system(EcosystemId::Deno), None);
615 assert_eq!(deps_dev_system(EcosystemId::GithubActions), None);
616 }
617
618 #[test]
621 fn is_valid_project_key_accepts_github_style_key() {
622 assert!(is_valid_project_key("github.com/expressjs/express"));
623 }
624
625 #[test]
626 fn is_valid_project_key_rejects_traversal() {
627 assert!(!is_valid_project_key("github.com/../../etc"));
628 assert!(!is_valid_project_key("github.com/expressjs/.."));
629 }
630
631 #[test]
632 fn is_valid_project_key_rejects_non_host_first_segment() {
633 assert!(!is_valid_project_key("not-a-host/expressjs/express"));
634 }
635
636 #[test]
637 fn is_valid_project_key_rejects_too_few_or_too_many_segments() {
638 assert!(!is_valid_project_key("github.com"));
639 assert!(!is_valid_project_key("github.com/a/b/c/d"));
640 }
641
642 #[test]
645 fn classify_provenance_both_empty_is_none() {
646 assert_eq!(classify_provenance(&[], &[]), ProvenanceStatus::None);
647 }
648
649 #[test]
650 fn classify_provenance_any_verified_is_verified() {
651 let entries = [ProvenanceEntry { verified: true }];
652 assert_eq!(
653 classify_provenance(&entries, &[]),
654 ProvenanceStatus::Verified
655 );
656 }
657
658 #[test]
659 fn classify_provenance_nonempty_unverified_is_unverified() {
660 let entries = [ProvenanceEntry { verified: false }];
661 assert_eq!(
662 classify_provenance(&entries, &[]),
663 ProvenanceStatus::Unverified
664 );
665 }
666
667 #[tokio::test]
670 async fn trust_signal_renders_score_and_verified_provenance() {
671 let (mut server, client) = mock_client().await;
672 let _version = server
673 .mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
674 .with_status(200)
675 .with_body(SIGSTORE_VERSION_VERIFIED)
676 .create_async()
677 .await;
678 let _project = server
679 .mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
680 .with_status(200)
681 .with_body(r#"{"scorecard": {"overallScore": 9.1}}"#)
682 .create_async()
683 .await;
684
685 let signal = client
686 .trust_signal("npm", "sigstore", "2.3.1")
687 .await
688 .expect("signal expected");
689 assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
690 let scorecard = signal.scorecard.expect("scorecard expected");
691 assert!((scorecard.overall_score - 9.1).abs() < f32::EPSILON);
692 assert!(!scorecard.self_reported);
693 }
694
695 #[tokio::test]
696 async fn trust_signal_self_reported_relation_is_marked() {
697 let (mut server, client) = mock_client().await;
698 let _version = server
699 .mock("GET", "/v3/systems/npm/packages/left-pad/versions/1.0.0")
700 .with_status(200)
701 .with_body(
702 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
703 {"projectKey": {"id": "github.com/example/left-pad"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
704 ]}"#,
705 )
706 .create_async()
707 .await;
708 let _project = server
709 .mock("GET", "/v3/projects/github.com%2Fexample%2Fleft-pad")
710 .with_status(200)
711 .with_body(EXPRESS_PROJECT)
712 .create_async()
713 .await;
714
715 let signal = client
716 .trust_signal("npm", "left-pad", "1.0.0")
717 .await
718 .expect("signal expected");
719 let scorecard = signal.scorecard.expect("scorecard expected");
720 assert!(scorecard.self_reported);
721 assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
722 }
723
724 #[tokio::test]
725 async fn trust_signal_both_endpoints_fail_returns_none() {
726 let (mut server, client) = mock_client().await;
727 let _version = server
728 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
729 .with_status(500)
730 .create_async()
731 .await;
732
733 let signal = client.trust_signal("npm", "express", "4.19.2").await;
734 assert!(signal.is_none());
735 }
736
737 #[tokio::test]
738 async fn trust_signal_project_call_fails_keeps_provenance() {
739 let (mut server, client) = mock_client().await;
740 let _version = server
741 .mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
742 .with_status(200)
743 .with_body(SIGSTORE_VERSION_VERIFIED)
744 .create_async()
745 .await;
746 let _project = server
747 .mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
748 .with_status(500)
749 .create_async()
750 .await;
751
752 let signal = client
753 .trust_signal("npm", "sigstore", "2.3.1")
754 .await
755 .expect("signal expected");
756 assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
757 assert!(signal.scorecard.is_none());
758 }
759
760 #[tokio::test]
761 async fn trust_signal_no_source_repo_omits_scorecard_keeps_provenance() {
762 let (mut server, client) = mock_client().await;
763 let _version = server
764 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
765 .with_status(200)
766 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
767 .create_async()
768 .await;
769 let _project = server
770 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
771 .with_status(200)
772 .with_body(EXPRESS_PROJECT)
773 .create_async()
774 .await;
775
776 let signal = client
777 .trust_signal("npm", "express", "4.19.2")
778 .await
779 .expect("signal expected");
780 assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
781 let scorecard = signal.scorecard.expect("scorecard expected");
782 assert!((scorecard.overall_score - 8.5).abs() < f32::EPSILON);
783 }
784
785 #[tokio::test]
786 async fn trust_signal_malformed_json_returns_none() {
787 let (mut server, client) = mock_client().await;
788 let _version = server
789 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
790 .with_status(200)
791 .with_body("not json")
792 .create_async()
793 .await;
794
795 let signal = client.trust_signal("npm", "express", "4.19.2").await;
796 assert!(signal.is_none());
797 }
798
799 #[tokio::test]
800 async fn trust_signal_404_plaintext_body_returns_none_no_panic() {
801 let (mut server, client) = mock_client().await;
802 let _version = server
803 .mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
804 .with_status(404)
805 .with_body("version not found")
806 .create_async()
807 .await;
808
809 let signal = client.trust_signal("npm", "missing", "1.0.0").await;
810 assert!(signal.is_none());
811 }
812
813 #[tokio::test]
814 async fn trust_signal_scorecard_overall_score_absent_omits_scorecard_never_zero() {
815 let (mut server, client) = mock_client().await;
816 let _version = server
817 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
818 .with_status(200)
819 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
820 .create_async()
821 .await;
822 let _project = server
823 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
824 .with_status(200)
825 .with_body(r#"{"scorecard": {}}"#)
826 .create_async()
827 .await;
828
829 let signal = client
830 .trust_signal("npm", "express", "4.19.2")
831 .await
832 .expect("signal expected (provenance still present)");
833 assert!(signal.scorecard.is_none());
834 }
835
836 #[tokio::test]
837 async fn trust_signal_second_call_within_ttl_issues_zero_requests() {
838 let (mut server, client) = mock_client().await;
839 let version = server
840 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
841 .with_status(200)
842 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
843 .expect(1)
844 .create_async()
845 .await;
846 let project = server
847 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
848 .with_status(200)
849 .with_body(EXPRESS_PROJECT)
850 .expect(1)
851 .create_async()
852 .await;
853
854 client.trust_signal("npm", "express", "4.19.2").await;
855 client.trust_signal("npm", "express", "4.19.2").await;
856
857 version.assert_async().await;
858 project.assert_async().await;
859 }
860
861 #[tokio::test]
862 async fn trust_signal_404_is_not_requeried_within_success_ttl() {
863 let (mut server, client) = mock_client().await;
864 let version = server
865 .mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
866 .with_status(404)
867 .expect(1)
868 .create_async()
869 .await;
870
871 client.trust_signal("npm", "missing", "1.0.0").await;
872 client.trust_signal("npm", "missing", "1.0.0").await;
873
874 version.assert_async().await;
875 }
876
877 #[tokio::test]
878 async fn trust_signal_two_packages_sharing_project_key_issue_one_project_call() {
879 let (mut server, client) = mock_client().await;
880 let _v1 = server
881 .mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
882 .with_status(200)
883 .with_body(
884 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
885 {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
886 ]}"#,
887 )
888 .create_async()
889 .await;
890 let _v2 = server
891 .mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
892 .with_status(200)
893 .with_body(
894 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
895 {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
896 ]}"#,
897 )
898 .create_async()
899 .await;
900 let project = server
901 .mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
902 .with_status(200)
903 .with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
904 .expect(1)
905 .create_async()
906 .await;
907
908 client.trust_signal("npm", "pkg-a", "1.0.0").await;
909 client.trust_signal("npm", "pkg-b", "1.0.0").await;
910
911 project.assert_async().await;
912 }
913
914 #[tokio::test]
915 async fn trust_signal_percent_encodes_go_module_path() {
916 let (mut server, client) = mock_client().await;
917 let version = server
918 .mock(
919 "GET",
920 "/v3/systems/go/packages/golang.org%2Fx%2Ftext/versions/v0.4.0",
921 )
922 .with_status(200)
923 .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
924 .expect(1)
925 .create_async()
926 .await;
927
928 client
929 .trust_signal("go", "golang.org/x/text", "v0.4.0")
930 .await;
931
932 version.assert_async().await;
933 }
934
935 #[tokio::test]
936 async fn trust_signal_percent_encodes_scoped_npm_name() {
937 let (mut server, client) = mock_client().await;
938 let version = server
939 .mock(
940 "GET",
941 "/v3/systems/npm/packages/%40types%2Fnode/versions/20.0.0",
942 )
943 .with_status(200)
944 .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
945 .expect(1)
946 .create_async()
947 .await;
948
949 client.trust_signal("npm", "@types/node", "20.0.0").await;
950
951 version.assert_async().await;
952 }
953
954 #[tokio::test]
955 async fn trust_signal_percent_encodes_maven_coordinate() {
956 let (mut server, client) = mock_client().await;
957 let version = server
958 .mock(
959 "GET",
960 "/v3/systems/maven/packages/com.google.guava%3Aguava/versions/32.0.0",
961 )
962 .with_status(200)
963 .with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
964 .expect(1)
965 .create_async()
966 .await;
967
968 client
969 .trust_signal("maven", "com.google.guava:guava", "32.0.0")
970 .await;
971
972 version.assert_async().await;
973 }
974
975 #[tokio::test]
976 async fn trust_signal_memo_keys_do_not_alias_on_control_characters() {
977 let client = client();
978 client.store_memo(
979 MemoKey {
980 base: "https://api.deps.dev".to_string(),
981 system: "npm",
982 name: "a\0b".to_string(),
983 version: "c".to_string(),
984 },
985 Some(SupplyChainTrustSignal::default()),
986 DEPS_DEV_SUCCESS_TTL,
987 );
988 assert!(!client.memo.contains_key(&MemoKey {
989 base: "https://api.deps.dev".to_string(),
990 system: "npm",
991 name: "a".to_string(),
992 version: "b\0c".to_string(),
993 }));
994 }
995
996 #[tokio::test]
997 async fn trust_signal_invalid_project_key_issues_zero_project_requests() {
998 let (mut server, client) = mock_client().await;
999 let _version = server
1000 .mock("GET", "/v3/systems/npm/packages/evil/versions/1.0.0")
1001 .with_status(200)
1002 .with_body(
1003 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1004 {"projectKey": {"id": "github.com/../../etc"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
1005 ]}"#,
1006 )
1007 .create_async()
1008 .await;
1009 let project = server
1010 .mock("GET", mockito::Matcher::Regex(r"^/v3/projects/.*".into()))
1011 .expect(0)
1012 .create_async()
1013 .await;
1014
1015 let signal = client
1016 .trust_signal("npm", "evil", "1.0.0")
1017 .await
1018 .expect("signal expected (provenance still present)");
1019 assert!(signal.scorecard.is_none());
1020 project.assert_async().await;
1021 }
1022
1023 #[tokio::test]
1024 async fn trust_signal_concurrent_calls_for_same_key_issue_one_request() {
1025 use std::sync::atomic::{AtomicUsize, Ordering};
1026
1027 let (mut server, client) = mock_client().await;
1028 let call_count = Arc::new(AtomicUsize::new(0));
1029 let call_count_clone = Arc::clone(&call_count);
1030 let client = Arc::new(client);
1031
1032 let _version = server
1033 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1034 .with_status(200)
1035 .with_body_from_request(move |_req| {
1036 call_count_clone.fetch_add(1, Ordering::SeqCst);
1037 EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
1038 })
1039 .create_async()
1040 .await;
1041 let _project = server
1042 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1043 .with_status(200)
1044 .with_body(EXPRESS_PROJECT)
1045 .create_async()
1046 .await;
1047
1048 let (a, b) = tokio::join!(
1049 {
1050 let client = Arc::clone(&client);
1051 async move { client.trust_signal("npm", "express", "4.19.2").await }
1052 },
1053 {
1054 let client = Arc::clone(&client);
1055 async move { client.trust_signal("npm", "express", "4.19.2").await }
1056 }
1057 );
1058 assert!(a.is_some() || b.is_some());
1061 assert_eq!(call_count.load(Ordering::SeqCst), 1);
1062 }
1063
1064 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1076 async fn trust_signal_survives_dropped_join_handle_and_warms_memo() {
1077 let (mut server, client) = mock_client().await;
1078 let client = Arc::new(client);
1079 let version = server
1080 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1081 .with_status(200)
1082 .with_body_from_request(|_req| {
1083 std::thread::sleep(Duration::from_millis(60));
1084 EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
1085 })
1086 .expect(1)
1087 .create_async()
1088 .await;
1089
1090 let spawn_client = Arc::clone(&client);
1091 let handle =
1092 tokio::spawn(
1093 async move { spawn_client.trust_signal("npm", "express", "4.19.2").await },
1094 );
1095 let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
1098 assert!(
1099 outcome.is_err(),
1100 "the artificial budget must elapse before the mock responds"
1101 );
1102 tokio::time::sleep(Duration::from_millis(250)).await;
1108
1109 let second = client.trust_signal("npm", "express", "4.19.2").await;
1110 assert!(
1111 second.is_some(),
1112 "the memo warmed by the detached task must serve the next call"
1113 );
1114 version.assert_async().await;
1115 }
1116
1117 #[tokio::test]
1126 async fn trust_signal_project_memo_never_leaks_self_reported_across_packages() {
1127 let (mut server, client) = mock_client().await;
1128 let _version_a = server
1129 .mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
1130 .with_status(200)
1131 .with_body(
1132 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1133 {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
1134 ]}"#,
1135 )
1136 .create_async()
1137 .await;
1138 let _version_b = server
1139 .mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
1140 .with_status(200)
1141 .with_body(
1142 r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
1143 {"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
1144 ]}"#,
1145 )
1146 .create_async()
1147 .await;
1148 let project = server
1149 .mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
1150 .with_status(200)
1151 .with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
1152 .expect(1)
1153 .create_async()
1154 .await;
1155
1156 let signal_a = client
1158 .trust_signal("npm", "pkg-a", "1.0.0")
1159 .await
1160 .expect("signal expected");
1161 assert!(
1162 !signal_a
1163 .scorecard
1164 .expect("scorecard expected")
1165 .self_reported,
1166 "A's attested relation must not be marked self-reported"
1167 );
1168
1169 let signal_b = client
1172 .trust_signal("npm", "pkg-b", "1.0.0")
1173 .await
1174 .expect("signal expected");
1175 assert!(
1176 signal_b
1177 .scorecard
1178 .expect("scorecard expected")
1179 .self_reported,
1180 "B's UNVERIFIED_METADATA relation must be marked self-reported even though A's \
1181 attested fetch warmed the shared project memo first"
1182 );
1183
1184 project.assert_async().await;
1186 }
1187
1188 #[tokio::test]
1193 async fn trust_signal_project_call_failure_downgrades_version_memo_ttl() {
1194 let (mut server, client) = mock_client().await;
1195 let _version = server
1196 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1197 .with_status(200)
1198 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
1199 .create_async()
1200 .await;
1201 let _project = server
1202 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1203 .with_status(500)
1204 .create_async()
1205 .await;
1206
1207 let signal = client
1208 .trust_signal("npm", "express", "4.19.2")
1209 .await
1210 .expect("signal expected (provenance still present)");
1211 assert!(signal.scorecard.is_none());
1212
1213 let key = MemoKey {
1214 base: client.base_url.clone(),
1215 system: "npm",
1216 name: "express".to_string(),
1217 version: "4.19.2".to_string(),
1218 };
1219 let entry_ttl = client.memo.get(&key).expect("memo entry expected").ttl;
1220 assert_eq!(
1221 entry_ttl, DEPS_DEV_ERROR_TTL,
1222 "a failed project call must downgrade the whole signal's memo TTL to the short \
1223 error TTL, not the 1h success TTL"
1224 );
1225 }
1226
1227 #[tokio::test]
1233 async fn trust_signal_project_overall_score_wrong_type_omits_scorecard_never_panics() {
1234 let (mut server, client) = mock_client().await;
1235 let _version = server
1236 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1237 .with_status(200)
1238 .with_body(EXPRESS_VERSION_NO_PROVENANCE)
1239 .create_async()
1240 .await;
1241 let _project = server
1242 .mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
1243 .with_status(200)
1244 .with_body(r#"{"scorecard": {"overallScore": "not-a-number"}}"#)
1245 .create_async()
1246 .await;
1247
1248 let signal = client
1249 .trust_signal("npm", "express", "4.19.2")
1250 .await
1251 .expect("signal expected (provenance still present)");
1252 assert!(signal.scorecard.is_none());
1253 }
1254
1255 #[tokio::test]
1259 async fn trust_signal_in_flight_claim_released_after_failure() {
1260 let (mut server, client) = mock_client().await;
1261 let _version = server
1262 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1263 .with_status(500)
1264 .create_async()
1265 .await;
1266
1267 let signal = client.trust_signal("npm", "express", "4.19.2").await;
1268 assert!(signal.is_none());
1269
1270 let key = MemoKey {
1271 base: client.base_url.clone(),
1272 system: "npm",
1273 name: "express".to_string(),
1274 version: "4.19.2".to_string(),
1275 };
1276 assert!(
1277 !client.in_flight.contains(&key),
1278 "the in-flight claim must be released after a failed fetch, not just a successful one"
1279 );
1280 }
1281
1282 #[tokio::test]
1287 async fn trust_signal_version_call_failure_ttl_is_error_ttl() {
1288 let (mut server, client) = mock_client().await;
1289 let _version = server
1290 .mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
1291 .with_status(500)
1292 .create_async()
1293 .await;
1294
1295 let signal = client.trust_signal("npm", "express", "4.19.2").await;
1296 assert!(signal.is_none());
1297
1298 let key = MemoKey {
1299 base: client.base_url.clone(),
1300 system: "npm",
1301 name: "express".to_string(),
1302 version: "4.19.2".to_string(),
1303 };
1304 let entry_ttl = client.memo.get(&key).expect("memo entry expected").ttl;
1305 assert_eq!(
1306 entry_ttl, DEPS_DEV_ERROR_TTL,
1307 "a failed version call must memoize the short error TTL, not the 1h success TTL"
1308 );
1309 }
1310
1311 #[test]
1315 fn memo_evicts_when_max_entries_reached() {
1316 let client = client();
1317 for i in 0..MAX_MEMO_ENTRIES {
1318 client.store_memo(
1319 MemoKey {
1320 base: "https://api.deps.dev".to_string(),
1321 system: "npm",
1322 name: format!("pkg-{i}"),
1323 version: "1.0.0".to_string(),
1324 },
1325 None,
1326 DEPS_DEV_SUCCESS_TTL,
1327 );
1328 }
1329 assert_eq!(client.memo.len(), MAX_MEMO_ENTRIES);
1330
1331 client.store_memo(
1332 MemoKey {
1333 base: "https://api.deps.dev".to_string(),
1334 system: "npm",
1335 name: "overflow".to_string(),
1336 version: "1.0.0".to_string(),
1337 },
1338 None,
1339 DEPS_DEV_SUCCESS_TTL,
1340 );
1341
1342 assert!(
1343 client.memo.len() <= MAX_MEMO_ENTRIES,
1344 "memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
1345 client.memo.len()
1346 );
1347 }
1348
1349 #[test]
1352 fn project_memo_evicts_when_max_entries_reached() {
1353 let client = client();
1354 for i in 0..MAX_MEMO_ENTRIES {
1355 client.store_project_memo(
1356 ProjectKeyMemo {
1357 base: "https://api.deps.dev".to_string(),
1358 project_key: format!("github.com/org/repo-{i}"),
1359 },
1360 Some(8.0),
1361 DEPS_DEV_SUCCESS_TTL,
1362 );
1363 }
1364 assert_eq!(client.projects.len(), MAX_MEMO_ENTRIES);
1365
1366 client.store_project_memo(
1367 ProjectKeyMemo {
1368 base: "https://api.deps.dev".to_string(),
1369 project_key: "github.com/org/overflow".to_string(),
1370 },
1371 Some(8.0),
1372 DEPS_DEV_SUCCESS_TTL,
1373 );
1374
1375 assert!(
1376 client.projects.len() <= MAX_MEMO_ENTRIES,
1377 "projects memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
1378 client.projects.len()
1379 );
1380 }
1381}