mod types;
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::{DashMap, DashSet};
use types::{DepsDevProject, DepsDevVersionInfo, ProvenanceEntry, RelatedProject};
pub use types::{ProvenanceStatus, ScorecardSummary, SupplyChainTrustSignal};
use crate::EcosystemId;
use crate::cache::{BodyLimit, HttpCache};
use crate::error::DepsError;
const DEPS_DEV_API: &str = "https://api.deps.dev";
const DEPS_DEV_CALL_TIMEOUT: Duration = Duration::from_millis(400);
const DEPS_DEV_SUCCESS_TTL: Duration = Duration::from_hours(1);
const DEPS_DEV_ERROR_TTL: Duration = Duration::from_secs(90);
const MAX_MEMO_ENTRIES: usize = 512;
const DEPS_DEV_BODY_LIMIT: usize = 1024 * 1024;
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
struct MemoKey {
base: String,
system: &'static str,
name: String,
version: String,
}
struct MemoEntry {
fetched_at: Instant,
ttl: Duration,
signal: Option<SupplyChainTrustSignal>,
}
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
struct ProjectKeyMemo {
base: String,
project_key: String,
}
struct ProjectMemoEntry {
fetched_at: Instant,
ttl: Duration,
overall_score: Option<f32>,
}
struct InFlightGuard<'a> {
set: &'a DashSet<MemoKey>,
key: MemoKey,
}
impl Drop for InFlightGuard<'_> {
fn drop(&mut self) {
self.set.remove(&self.key);
}
}
#[must_use]
pub(crate) const fn deps_dev_system(id: EcosystemId) -> Option<&'static str> {
match id {
EcosystemId::Npm => Some("npm"),
EcosystemId::Cargo => Some("cargo"),
EcosystemId::Go => Some("go"),
EcosystemId::Maven => Some("maven"),
EcosystemId::Pypi => Some("pypi"),
EcosystemId::Bundler => Some("rubygems"),
EcosystemId::NuGet => Some("nuget"),
EcosystemId::Composer
| EcosystemId::Dart
| EcosystemId::Swift
| EcosystemId::Gradle
| EcosystemId::Deno
| EcosystemId::GithubActions
| EcosystemId::GitlabCi => None,
}
}
fn is_host_label(segment: &str) -> bool {
!segment.is_empty()
&& !segment.starts_with('-')
&& !segment.ends_with('-')
&& segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
}
fn is_valid_project_key(key: &str) -> bool {
let segments: Vec<&str> = key.split('/').collect();
if !(2..=4).contains(&segments.len()) || segments.iter().any(|s| s.is_empty()) {
return false;
}
if segments
.iter()
.any(|s| crate::lsp_helpers::is_dot_segment(s))
{
return false;
}
#[allow(clippy::expect_used)]
let (host, rest) = segments
.split_first()
.expect("segments has at least 2 entries");
if !host.contains('.') || !host.split('.').all(is_host_label) {
return false;
}
rest.iter().all(|s| {
s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
})
}
fn classify_provenance(
slsa: &[ProvenanceEntry],
attestations: &[ProvenanceEntry],
) -> ProvenanceStatus {
if slsa.is_empty() && attestations.is_empty() {
ProvenanceStatus::None
} else if slsa.iter().chain(attestations).any(|e| e.verified) {
ProvenanceStatus::Verified
} else {
ProvenanceStatus::Unverified
}
}
fn choose_project_key(projects: &[RelatedProject]) -> Option<(String, bool)> {
let attested = projects
.iter()
.find(|p| p.relation_type == "SOURCE_REPO" && p.relation_provenance == "SLSA_ATTESTATION");
let (chosen, self_reported) = attested.map(|p| (p, false)).or_else(|| {
projects
.iter()
.find(|p| p.relation_type == "SOURCE_REPO")
.map(|p| (p, true))
})?;
is_valid_project_key(&chosen.project_key.id)
.then(|| (chosen.project_key.id.clone(), self_reported))
}
pub struct DepsDevClient {
cache: Arc<HttpCache>,
base_url: String,
trusted_origin: String,
memo: DashMap<MemoKey, MemoEntry>,
projects: DashMap<ProjectKeyMemo, ProjectMemoEntry>,
in_flight: DashSet<MemoKey>,
}
impl std::fmt::Debug for DepsDevClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DepsDevClient").finish_non_exhaustive()
}
}
impl DepsDevClient {
#[must_use]
pub fn new(cache: Arc<HttpCache>) -> Self {
Self::with_base_url(cache, DEPS_DEV_API.to_string())
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub fn for_test(cache: Arc<HttpCache>, base_url: impl Into<String>) -> Self {
Self::with_base_url(cache, base_url.into())
}
fn with_base_url(cache: Arc<HttpCache>, base_url: String) -> Self {
let trusted_origin = format!("{base_url}/");
Self {
cache,
base_url,
trusted_origin,
memo: DashMap::new(),
projects: DashMap::new(),
in_flight: DashSet::new(),
}
}
pub async fn trust_signal(
&self,
system: &'static str,
name: &str,
version: &str,
) -> Option<SupplyChainTrustSignal> {
let key = MemoKey {
base: self.base_url.clone(),
system,
name: name.to_string(),
version: version.to_string(),
};
if let Some(entry) = self.memo.get(&key)
&& entry.fetched_at.elapsed() < entry.ttl
{
return entry.signal.clone();
}
if !self.in_flight.insert(key.clone()) {
return None;
}
let _guard = InFlightGuard {
set: &self.in_flight,
key: key.clone(),
};
let (signal, ttl) = self.fetch(system, name, version).await;
self.store_memo(key, signal.clone(), ttl);
signal
}
fn store_memo(&self, key: MemoKey, signal: Option<SupplyChainTrustSignal>, ttl: Duration) {
if !self.memo.contains_key(&key) {
crate::cache_policy::evict_expired_then_oldest(
&self.memo,
MAX_MEMO_ENTRIES,
|e| e.fetched_at,
|e| e.ttl,
);
}
self.memo.insert(
key,
MemoEntry {
fetched_at: Instant::now(),
ttl,
signal,
},
);
}
fn store_project_memo(&self, key: ProjectKeyMemo, overall_score: Option<f32>, ttl: Duration) {
if !self.projects.contains_key(&key) {
crate::cache_policy::evict_expired_then_oldest(
&self.projects,
MAX_MEMO_ENTRIES,
|e| e.fetched_at,
|e| e.ttl,
);
}
self.projects.insert(
key,
ProjectMemoEntry {
fetched_at: Instant::now(),
ttl,
overall_score,
},
);
}
#[tracing::instrument(
skip(self),
fields(url = %crate::net_policy::RedactedUrl::new(url))
)]
async fn get(&self, url: &str) -> Result<bytes::Bytes, DepsDevFetchError> {
match tokio::time::timeout(
DEPS_DEV_CALL_TIMEOUT,
self.cache
.get_transport_only_with_headers_limited_trusted_origin(
url,
&[],
BodyLimit::new(DEPS_DEV_BODY_LIMIT),
&self.trusted_origin,
),
)
.await
{
Ok(Ok(bytes)) => Ok(bytes),
Ok(Err(e)) if e.is_not_found() => Err(DepsDevFetchError::NotFound),
Ok(Err(e)) => Err(DepsDevFetchError::Failed(e)),
Err(_) => Err(DepsDevFetchError::TimedOut),
}
}
async fn fetch(
&self,
system: &'static str,
name: &str,
version: &str,
) -> (Option<SupplyChainTrustSignal>, Duration) {
let version_url = format!(
"{}/v3/systems/{system}/packages/{}/versions/{}",
self.base_url,
urlencoding::encode(name),
urlencoding::encode(version),
);
let (provenance, related_projects, licenses) = match self.get(&version_url).await {
Ok(bytes) => match crate::parser::parse_json_checked::<DepsDevVersionInfo>(&bytes) {
Ok(info) => {
let provenance =
classify_provenance(&info.slsa_provenances, &info.attestations);
(Some(provenance), info.related_projects, info.licenses)
}
Err(e) => {
tracing::debug!(error = %e, "deps.dev version response parse failed");
return (None, DEPS_DEV_ERROR_TTL);
}
},
Err(DepsDevFetchError::NotFound) => return (None, DEPS_DEV_SUCCESS_TTL),
Err(DepsDevFetchError::Failed(e)) => {
let (status, cause) = e.safe_tracing_summary();
tracing::debug!(status = ?status, cause, "deps.dev version fetch failed");
return (None, DEPS_DEV_ERROR_TTL);
}
Err(DepsDevFetchError::TimedOut) => {
tracing::debug!(package = name, "deps.dev version fetch timed out");
return (None, DEPS_DEV_ERROR_TTL);
}
};
let (scorecard, project_ttl) = match choose_project_key(&related_projects) {
Some((project_key, self_reported)) => {
let (raw_score, ttl) = self.fetch_scorecard(&project_key).await;
let scorecard = raw_score.map(|overall_score| ScorecardSummary {
overall_score,
self_reported,
});
(scorecard, ttl)
}
None => (None, DEPS_DEV_SUCCESS_TTL),
};
let signal = SupplyChainTrustSignal {
scorecard,
provenance,
licenses,
};
(Some(signal), DEPS_DEV_SUCCESS_TTL.min(project_ttl))
}
async fn fetch_scorecard(&self, project_key: &str) -> (Option<f32>, Duration) {
let memo_key = ProjectKeyMemo {
base: self.base_url.clone(),
project_key: project_key.to_string(),
};
if let Some(entry) = self.projects.get(&memo_key)
&& entry.fetched_at.elapsed() < entry.ttl
{
return (entry.overall_score, entry.ttl);
}
let url = format!(
"{}/v3/projects/{}",
self.base_url,
urlencoding::encode(project_key),
);
let (overall_score, ttl) = match self.get(&url).await {
Ok(bytes) => match crate::parser::parse_json_checked::<DepsDevProject>(&bytes) {
Ok(project) => {
let overall_score = project
.scorecard
.and_then(|s| s.overall_score)
.filter(|score| (0.0..=10.0).contains(score));
(overall_score, DEPS_DEV_SUCCESS_TTL)
}
Err(e) => {
tracing::debug!(error = %e, "deps.dev project response parse failed");
(None, DEPS_DEV_ERROR_TTL)
}
},
Err(DepsDevFetchError::NotFound) => (None, DEPS_DEV_SUCCESS_TTL),
Err(DepsDevFetchError::Failed(e)) => {
let (status, cause) = e.safe_tracing_summary();
tracing::debug!(status = ?status, cause, "deps.dev project fetch failed");
(None, DEPS_DEV_ERROR_TTL)
}
Err(DepsDevFetchError::TimedOut) => {
tracing::debug!(project_key, "deps.dev project fetch timed out");
(None, DEPS_DEV_ERROR_TTL)
}
};
self.store_project_memo(memo_key, overall_score, ttl);
(overall_score, ttl)
}
}
enum DepsDevFetchError {
NotFound,
Failed(DepsError),
TimedOut,
}
#[cfg(test)]
mod tests {
use super::*;
fn client() -> DepsDevClient {
DepsDevClient::new(Arc::new(HttpCache::new()))
}
async fn mock_client() -> (mockito::ServerGuard, DepsDevClient) {
let server = mockito::Server::new_async().await;
let client = DepsDevClient::for_test(Arc::new(HttpCache::new()), server.url());
(server, client)
}
const EXPRESS_VERSION_NO_PROVENANCE: &str = r#"{
"slsaProvenances": [],
"attestations": [],
"relatedProjects": [
{"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"},
{"projectKey": {"id": "github.com/expressjs/express"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]
}"#;
const SIGSTORE_VERSION_VERIFIED: &str = r#"{
"slsaProvenances": [{"verified": true, "sourceRepository": "github.com/sigstore/sigstore-js"}],
"attestations": [],
"relatedProjects": [
{"projectKey": {"id": "github.com/sigstore/sigstore-js"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]
}"#;
const EXPRESS_PROJECT: &str = r#"{"scorecard": {"overallScore": 8.5}}"#;
#[test]
fn deps_dev_system_covers_seven_ecosystems() {
assert_eq!(deps_dev_system(EcosystemId::Npm), Some("npm"));
assert_eq!(deps_dev_system(EcosystemId::Cargo), Some("cargo"));
assert_eq!(deps_dev_system(EcosystemId::Go), Some("go"));
assert_eq!(deps_dev_system(EcosystemId::Maven), Some("maven"));
assert_eq!(deps_dev_system(EcosystemId::Pypi), Some("pypi"));
assert_eq!(deps_dev_system(EcosystemId::Bundler), Some("rubygems"));
assert_eq!(deps_dev_system(EcosystemId::NuGet), Some("nuget"));
}
#[test]
fn deps_dev_system_excludes_uncovered_ecosystems() {
assert_eq!(deps_dev_system(EcosystemId::Composer), None);
assert_eq!(deps_dev_system(EcosystemId::Dart), None);
assert_eq!(deps_dev_system(EcosystemId::Swift), None);
assert_eq!(deps_dev_system(EcosystemId::Gradle), None);
assert_eq!(deps_dev_system(EcosystemId::Deno), None);
assert_eq!(deps_dev_system(EcosystemId::GithubActions), None);
}
#[test]
fn is_valid_project_key_accepts_github_style_key() {
assert!(is_valid_project_key("github.com/expressjs/express"));
}
#[test]
fn is_valid_project_key_rejects_traversal() {
assert!(!is_valid_project_key("github.com/../../etc"));
assert!(!is_valid_project_key("github.com/expressjs/.."));
}
#[test]
fn is_valid_project_key_rejects_non_host_first_segment() {
assert!(!is_valid_project_key("not-a-host/expressjs/express"));
}
#[test]
fn is_valid_project_key_rejects_too_few_or_too_many_segments() {
assert!(!is_valid_project_key("github.com"));
assert!(!is_valid_project_key("github.com/a/b/c/d"));
}
#[test]
fn classify_provenance_both_empty_is_none() {
assert_eq!(classify_provenance(&[], &[]), ProvenanceStatus::None);
}
#[test]
fn classify_provenance_any_verified_is_verified() {
let entries = [ProvenanceEntry { verified: true }];
assert_eq!(
classify_provenance(&entries, &[]),
ProvenanceStatus::Verified
);
}
#[test]
fn classify_provenance_nonempty_unverified_is_unverified() {
let entries = [ProvenanceEntry { verified: false }];
assert_eq!(
classify_provenance(&entries, &[]),
ProvenanceStatus::Unverified
);
}
#[tokio::test]
async fn trust_signal_renders_score_and_verified_provenance() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
.with_status(200)
.with_body(SIGSTORE_VERSION_VERIFIED)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 9.1}}"#)
.create_async()
.await;
let signal = client
.trust_signal("npm", "sigstore", "2.3.1")
.await
.expect("signal expected");
assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
let scorecard = signal.scorecard.expect("scorecard expected");
assert!((scorecard.overall_score - 9.1).abs() < f32::EPSILON);
assert!(!scorecard.self_reported);
}
#[tokio::test]
async fn trust_signal_parses_licenses_from_version_response() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [], "licenses": ["MIT", "Apache-2.0"]}"#,
)
.create_async()
.await;
let signal = client
.trust_signal("npm", "sigstore", "2.3.1")
.await
.expect("signal expected");
assert_eq!(
signal.licenses,
vec!["MIT".to_string(), "Apache-2.0".to_string()]
);
}
#[tokio::test]
async fn trust_signal_missing_licenses_field_is_empty_vec() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let signal = client
.trust_signal("npm", "express", "4.19.2")
.await
.expect("signal expected");
assert!(signal.licenses.is_empty());
}
#[tokio::test]
async fn trust_signal_self_reported_relation_is_marked() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/left-pad/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/example/left-pad"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
]}"#,
)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexample%2Fleft-pad")
.with_status(200)
.with_body(EXPRESS_PROJECT)
.create_async()
.await;
let signal = client
.trust_signal("npm", "left-pad", "1.0.0")
.await
.expect("signal expected");
let scorecard = signal.scorecard.expect("scorecard expected");
assert!(scorecard.self_reported);
assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
}
#[tokio::test]
async fn trust_signal_both_endpoints_fail_returns_none() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(500)
.create_async()
.await;
let signal = client.trust_signal("npm", "express", "4.19.2").await;
assert!(signal.is_none());
}
#[tokio::test]
async fn trust_signal_project_call_fails_keeps_provenance() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/sigstore/versions/2.3.1")
.with_status(200)
.with_body(SIGSTORE_VERSION_VERIFIED)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fsigstore%2Fsigstore-js")
.with_status(500)
.create_async()
.await;
let signal = client
.trust_signal("npm", "sigstore", "2.3.1")
.await
.expect("signal expected");
assert_eq!(signal.provenance, Some(ProvenanceStatus::Verified));
assert!(signal.scorecard.is_none());
}
#[tokio::test]
async fn trust_signal_no_source_repo_omits_scorecard_keeps_provenance() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(EXPRESS_PROJECT)
.create_async()
.await;
let signal = client
.trust_signal("npm", "express", "4.19.2")
.await
.expect("signal expected");
assert_eq!(signal.provenance, Some(ProvenanceStatus::None));
let scorecard = signal.scorecard.expect("scorecard expected");
assert!((scorecard.overall_score - 8.5).abs() < f32::EPSILON);
}
#[tokio::test]
async fn trust_signal_malformed_json_returns_none() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body("not json")
.create_async()
.await;
let signal = client.trust_signal("npm", "express", "4.19.2").await;
assert!(signal.is_none());
}
#[tokio::test]
async fn trust_signal_404_plaintext_body_returns_none_no_panic() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
.with_status(404)
.with_body("version not found")
.create_async()
.await;
let signal = client.trust_signal("npm", "missing", "1.0.0").await;
assert!(signal.is_none());
}
#[tokio::test]
async fn trust_signal_scorecard_overall_score_absent_omits_scorecard_never_zero() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(r#"{"scorecard": {}}"#)
.create_async()
.await;
let signal = client
.trust_signal("npm", "express", "4.19.2")
.await
.expect("signal expected (provenance still present)");
assert!(signal.scorecard.is_none());
}
#[tokio::test]
async fn trust_signal_second_call_within_ttl_issues_zero_requests() {
let (mut server, client) = mock_client().await;
let version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.expect(1)
.create_async()
.await;
let project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(EXPRESS_PROJECT)
.expect(1)
.create_async()
.await;
client.trust_signal("npm", "express", "4.19.2").await;
client.trust_signal("npm", "express", "4.19.2").await;
version.assert_async().await;
project.assert_async().await;
}
#[tokio::test]
async fn trust_signal_404_is_not_requeried_within_success_ttl() {
let (mut server, client) = mock_client().await;
let version = server
.mock("GET", "/v3/systems/npm/packages/missing/versions/1.0.0")
.with_status(404)
.expect(1)
.create_async()
.await;
client.trust_signal("npm", "missing", "1.0.0").await;
client.trust_signal("npm", "missing", "1.0.0").await;
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_two_packages_sharing_project_key_issue_one_project_call() {
let (mut server, client) = mock_client().await;
let _v1 = server
.mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let _v2 = server
.mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let project = server
.mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
.expect(1)
.create_async()
.await;
client.trust_signal("npm", "pkg-a", "1.0.0").await;
client.trust_signal("npm", "pkg-b", "1.0.0").await;
project.assert_async().await;
}
#[tokio::test]
async fn trust_signal_percent_encodes_go_module_path() {
let (mut server, client) = mock_client().await;
let version = server
.mock(
"GET",
"/v3/systems/go/packages/golang.org%2Fx%2Ftext/versions/v0.4.0",
)
.with_status(200)
.with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
.expect(1)
.create_async()
.await;
client
.trust_signal("go", "golang.org/x/text", "v0.4.0")
.await;
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_percent_encodes_scoped_npm_name() {
let (mut server, client) = mock_client().await;
let version = server
.mock(
"GET",
"/v3/systems/npm/packages/%40types%2Fnode/versions/20.0.0",
)
.with_status(200)
.with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
.expect(1)
.create_async()
.await;
client.trust_signal("npm", "@types/node", "20.0.0").await;
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_percent_encodes_maven_coordinate() {
let (mut server, client) = mock_client().await;
let version = server
.mock(
"GET",
"/v3/systems/maven/packages/com.google.guava%3Aguava/versions/32.0.0",
)
.with_status(200)
.with_body(r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": []}"#)
.expect(1)
.create_async()
.await;
client
.trust_signal("maven", "com.google.guava:guava", "32.0.0")
.await;
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_memo_keys_do_not_alias_on_control_characters() {
let client = client();
client.store_memo(
MemoKey {
base: "https://api.deps.dev".to_string(),
system: "npm",
name: "a\0b".to_string(),
version: "c".to_string(),
},
Some(SupplyChainTrustSignal::default()),
DEPS_DEV_SUCCESS_TTL,
);
assert!(!client.memo.contains_key(&MemoKey {
base: "https://api.deps.dev".to_string(),
system: "npm",
name: "a".to_string(),
version: "b\0c".to_string(),
}));
}
#[tokio::test]
async fn trust_signal_invalid_project_key_issues_zero_project_requests() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/evil/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/../../etc"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let project = server
.mock("GET", mockito::Matcher::Regex(r"^/v3/projects/.*".into()))
.expect(0)
.create_async()
.await;
let signal = client
.trust_signal("npm", "evil", "1.0.0")
.await
.expect("signal expected (provenance still present)");
assert!(signal.scorecard.is_none());
project.assert_async().await;
}
#[tokio::test]
async fn trust_signal_concurrent_calls_for_same_key_issue_one_request() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (mut server, client) = mock_client().await;
let call_count = Arc::new(AtomicUsize::new(0));
let call_count_clone = Arc::clone(&call_count);
let client = Arc::new(client);
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body_from_request(move |_req| {
call_count_clone.fetch_add(1, Ordering::SeqCst);
EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
})
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(EXPRESS_PROJECT)
.create_async()
.await;
let (a, b) = tokio::join!(
{
let client = Arc::clone(&client);
async move { client.trust_signal("npm", "express", "4.19.2").await }
},
{
let client = Arc::clone(&client);
async move { client.trust_signal("npm", "express", "4.19.2").await }
}
);
assert!(a.is_some() || b.is_some());
assert_eq!(call_count.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn trust_signal_survives_dropped_join_handle_and_warms_memo() {
let (mut server, client) = mock_client().await;
let client = Arc::new(client);
let version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body_from_request(|_req| {
std::thread::sleep(Duration::from_millis(60));
EXPRESS_VERSION_NO_PROVENANCE.as_bytes().to_vec()
})
.expect(1)
.create_async()
.await;
let spawn_client = Arc::clone(&client);
let handle =
tokio::spawn(
async move { spawn_client.trust_signal("npm", "express", "4.19.2").await },
);
let outcome = tokio::time::timeout(Duration::from_millis(5), handle).await;
assert!(
outcome.is_err(),
"the artificial budget must elapse before the mock responds"
);
tokio::time::sleep(Duration::from_millis(250)).await;
let second = client.trust_signal("npm", "express", "4.19.2").await;
assert!(
second.is_some(),
"the memo warmed by the detached task must serve the next call"
);
version.assert_async().await;
}
#[tokio::test]
async fn trust_signal_project_memo_never_leaks_self_reported_across_packages() {
let (mut server, client) = mock_client().await;
let _version_a = server
.mock("GET", "/v3/systems/npm/packages/pkg-a/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "SLSA_ATTESTATION"}
]}"#,
)
.create_async()
.await;
let _version_b = server
.mock("GET", "/v3/systems/npm/packages/pkg-b/versions/1.0.0")
.with_status(200)
.with_body(
r#"{"slsaProvenances": [], "attestations": [], "relatedProjects": [
{"projectKey": {"id": "github.com/babel/babel"}, "relationType": "SOURCE_REPO", "relationProvenance": "UNVERIFIED_METADATA"}
]}"#,
)
.create_async()
.await;
let project = server
.mock("GET", "/v3/projects/github.com%2Fbabel%2Fbabel")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": 7.0}}"#)
.expect(1)
.create_async()
.await;
let signal_a = client
.trust_signal("npm", "pkg-a", "1.0.0")
.await
.expect("signal expected");
assert!(
!signal_a
.scorecard
.expect("scorecard expected")
.self_reported,
"A's attested relation must not be marked self-reported"
);
let signal_b = client
.trust_signal("npm", "pkg-b", "1.0.0")
.await
.expect("signal expected");
assert!(
signal_b
.scorecard
.expect("scorecard expected")
.self_reported,
"B's UNVERIFIED_METADATA relation must be marked self-reported even though A's \
attested fetch warmed the shared project memo first"
);
project.assert_async().await;
}
#[tokio::test]
async fn trust_signal_project_call_failure_downgrades_version_memo_ttl() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(500)
.create_async()
.await;
let signal = client
.trust_signal("npm", "express", "4.19.2")
.await
.expect("signal expected (provenance still present)");
assert!(signal.scorecard.is_none());
let key = MemoKey {
base: client.base_url.clone(),
system: "npm",
name: "express".to_string(),
version: "4.19.2".to_string(),
};
let entry_ttl = client.memo.get(&key).expect("memo entry expected").ttl;
assert_eq!(
entry_ttl, DEPS_DEV_ERROR_TTL,
"a failed project call must downgrade the whole signal's memo TTL to the short \
error TTL, not the 1h success TTL"
);
}
#[tokio::test]
async fn trust_signal_project_overall_score_wrong_type_omits_scorecard_never_panics() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(200)
.with_body(EXPRESS_VERSION_NO_PROVENANCE)
.create_async()
.await;
let _project = server
.mock("GET", "/v3/projects/github.com%2Fexpressjs%2Fexpress")
.with_status(200)
.with_body(r#"{"scorecard": {"overallScore": "not-a-number"}}"#)
.create_async()
.await;
let signal = client
.trust_signal("npm", "express", "4.19.2")
.await
.expect("signal expected (provenance still present)");
assert!(signal.scorecard.is_none());
}
#[tokio::test]
async fn trust_signal_in_flight_claim_released_after_failure() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(500)
.create_async()
.await;
let signal = client.trust_signal("npm", "express", "4.19.2").await;
assert!(signal.is_none());
let key = MemoKey {
base: client.base_url.clone(),
system: "npm",
name: "express".to_string(),
version: "4.19.2".to_string(),
};
assert!(
!client.in_flight.contains(&key),
"the in-flight claim must be released after a failed fetch, not just a successful one"
);
}
#[tokio::test]
async fn trust_signal_version_call_failure_ttl_is_error_ttl() {
let (mut server, client) = mock_client().await;
let _version = server
.mock("GET", "/v3/systems/npm/packages/express/versions/4.19.2")
.with_status(500)
.create_async()
.await;
let signal = client.trust_signal("npm", "express", "4.19.2").await;
assert!(signal.is_none());
let key = MemoKey {
base: client.base_url.clone(),
system: "npm",
name: "express".to_string(),
version: "4.19.2".to_string(),
};
let entry_ttl = client.memo.get(&key).expect("memo entry expected").ttl;
assert_eq!(
entry_ttl, DEPS_DEV_ERROR_TTL,
"a failed version call must memoize the short error TTL, not the 1h success TTL"
);
}
#[test]
fn memo_evicts_when_max_entries_reached() {
let client = client();
for i in 0..MAX_MEMO_ENTRIES {
client.store_memo(
MemoKey {
base: "https://api.deps.dev".to_string(),
system: "npm",
name: format!("pkg-{i}"),
version: "1.0.0".to_string(),
},
None,
DEPS_DEV_SUCCESS_TTL,
);
}
assert_eq!(client.memo.len(), MAX_MEMO_ENTRIES);
client.store_memo(
MemoKey {
base: "https://api.deps.dev".to_string(),
system: "npm",
name: "overflow".to_string(),
version: "1.0.0".to_string(),
},
None,
DEPS_DEV_SUCCESS_TTL,
);
assert!(
client.memo.len() <= MAX_MEMO_ENTRIES,
"memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
client.memo.len()
);
}
#[tokio::test]
#[ignore]
async fn trust_signal_real_npm_express_carries_license() {
let client = client();
let signal = client
.trust_signal("npm", "express", "4.19.2")
.await
.expect("signal expected for a real, well-known package");
assert!(
!signal.licenses.is_empty(),
"expected express@4.19.2 to report a real license"
);
}
#[test]
fn project_memo_evicts_when_max_entries_reached() {
let client = client();
for i in 0..MAX_MEMO_ENTRIES {
client.store_project_memo(
ProjectKeyMemo {
base: "https://api.deps.dev".to_string(),
project_key: format!("github.com/org/repo-{i}"),
},
Some(8.0),
DEPS_DEV_SUCCESS_TTL,
);
}
assert_eq!(client.projects.len(), MAX_MEMO_ENTRIES);
client.store_project_memo(
ProjectKeyMemo {
base: "https://api.deps.dev".to_string(),
project_key: "github.com/org/overflow".to_string(),
},
Some(8.0),
DEPS_DEV_SUCCESS_TTL,
);
assert!(
client.projects.len() <= MAX_MEMO_ENTRIES,
"projects memo must stay bounded at MAX_MEMO_ENTRIES, got {}",
client.projects.len()
);
}
}