feat: support staged publishes in trust scale (#12056)

Fixes #11887.

Staged publishes now have a signal in the packument: `approver`.

If this is set, the package is more trustworthy than a "trusted publisher" package, since it requires 2FA publish approvals.

## Changes

**pnpm (TypeScript)**
- `getTrustEvidence` recognizes `_npmUser.approver` and classifies it as a new `stagedPublish` trust evidence, ranked above `trustedPublisher` and `provenance`.
- Trust-downgrade detection treats `stagedPublish` as the strongest rank, and the resolution verifier's PII-minimizing metadata projection retains the approver *signal* (without keeping the approver's name/email).

**pacquet (Rust port)**
- Ported the same staged-publish support: an `Approver` registry type, a `StagedPublish` trust evidence (rank 3 — above `TrustedPublisher`/`Provenance`), detection, pretty-printing, and the PII-stripping trust-meta projection.
- Wired `trustPolicy='no-downgrade'` enforcement into the **resolver-time** path, not just the lockfile verifier. Previously pacquet only re-checked entries already in `pnpm-lock.yaml`; fresh resolutions weren't gated. The npm resolver now runs `fail_if_trust_downgraded` on each freshly picked version (full metadata is already forced under this policy), mirroring pnpm's resolver-time `failIfTrustDowngraded` call.
- Ported the matching `trustChecks` tests for full parity with the TypeScript suite (staged-publish classification/downgrade, plus previously-unported `trustedPublisher → none`, no-evidence-anywhere, and exclude + missing-time cases).

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
James Garbutt
2026-05-29 11:49:11 +01:00
committed by GitHub
parent 98d4c60f61
commit 1e9ab2935f
16 changed files with 543 additions and 44 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/resolving.registry.types": minor
"@pnpm/resolving.npm-resolver": minor
"pnpm": minor
---
Staged publishes are now recognized in the trust scale. When a package version's registry metadata carries an `approver` field, it is treated as the strongest trust evidence (ranked above trusted publishers and provenance attestations), since staged publishes require 2FA publish approvals. This prevents false-positive trust downgrade errors when moving from a staged publish to a lower trust level [#11887](https://github.com/pnpm/pnpm/issues/11887).

View File

@@ -577,8 +577,10 @@ where
// <https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/index.ts#L355-L383>.
// `lockfile.is_none()` (writable-lockfile path) skips the
// gate entirely — fresh local resolution is already filtered
// by the resolver's per-version gate (when pacquet's
// resolver lands). `trust_lockfile` (the OR of yaml's
// by the resolver's per-version gate (`minimumReleaseAge` via
// `ResolveResult::policy_violation`, `trustPolicy='no-downgrade'`
// via the npm resolver's `fail_if_trust_downgraded_for_pick`).
// `trust_lockfile` (the OR of yaml's
// `trustLockfile` and the `--trust-lockfile` CLI flag,
// resolved in [`crate::cli_args::install::InstallArgs::run`])
// is the opt-out for environments where the install can

View File

@@ -231,6 +231,12 @@ pub enum InstallWithFreshLockfileError {
#[diagnostic(code(ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE))]
MinimumReleaseAgeExclude(#[error(source)] pacquet_config::version_policy::VersionPolicyError),
/// `trustPolicyExclude` patterns rejected at compile time.
/// Mirrors upstream's `ERR_PNPM_INVALID_TRUST_POLICY_EXCLUDE`.
#[display("Invalid value in trustPolicyExclude: {_0}")]
#[diagnostic(code(ERR_PNPM_INVALID_TRUST_POLICY_EXCLUDE))]
TrustPolicyExclude(#[error(source)] pacquet_config::version_policy::VersionPolicyError),
/// `allowBuilds` patterns in `pnpm-workspace.yaml` couldn't be
/// parsed. Same `VersionPolicyError` shape the frozen-lockfile
/// path surfaces — see `InstallFrozenLockfileError::VersionPolicy`
@@ -609,6 +615,23 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList>
.transpose()
.map_err(InstallWithFreshLockfileError::MinimumReleaseAgeExclude)?;
// `trustPolicy='no-downgrade'` config, threaded into every
// resolve so the npm resolver re-applies the downgrade gate to
// freshly picked versions. `full_metadata` above is already
// forced on under this policy, so the picker hands the resolver
// the per-version `time` + trust evidence the check reads.
let trust_policy = match config.trust_policy {
TrustPolicy::Off => None,
TrustPolicy::NoDowngrade => Some(TrustPolicy::NoDowngrade),
};
let trust_policy_exclude = config
.trust_policy_exclude
.as_deref()
.filter(|patterns| !patterns.is_empty())
.map(pacquet_config::version_policy::create_package_version_policy)
.transpose()
.map_err(InstallWithFreshLockfileError::TrustPolicyExclude)?;
// Seed `allPreferredVersions` from every importer's manifest +
// the wanted lockfile's snapshots (when an existing one is
// present and is being rewritten). Mirrors upstream's
@@ -725,6 +748,9 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList>
default_tag: Some("latest".to_string()),
published_by,
published_by_exclude: published_by_exclude.clone(),
trust_policy,
trust_policy_exclude: trust_policy_exclude.clone(),
trust_policy_ignore_after: config.trust_policy_ignore_after,
project_dir,
lockfile_dir: lockfile_dir.to_path_buf(),
workspace_packages: workspace_packages.clone(),

View File

@@ -6,7 +6,7 @@ mod package_version;
pub use package::Package;
pub use package_distribution::{AttestationsDist, PackageDistribution, ProvenanceMeta};
pub use package_tag::PackageTag;
pub use package_version::{NpmUser, PackageVersion, TrustedPublisher};
pub use package_version::{Approver, NpmUser, PackageVersion, TrustedPublisher};
use derive_more::{Display, Error, From};
use miette::Diagnostic;

View File

@@ -222,6 +222,43 @@ fn package_deserializes_full_provenance_packument() {
assert_eq!(provenance.predicate_type.as_deref(), Some("https://slsa.dev/provenance/v1"));
}
/// A staged publish carries `_npmUser.approver`; that field must
/// round-trip through serde so the trust check can read it as the
/// strongest (`stagedPublish`) evidence.
#[test]
fn package_deserializes_approver_packument() {
let body = r#"{
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"time": { "1.0.0": "2025-01-10T08:30:00.000Z" },
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"_npmUser": {
"name": "alice",
"email": "alice@example.com",
"approver": {
"name": "bob",
"email": "bob@example.com"
}
},
"dist": {
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz"
}
}
}
}"#;
let pkg: Package = serde_json::from_str(body).expect("deserialize approver packument");
let version = pkg.versions.get("1.0.0").expect("1.0.0 deserialized");
let user = version.npm_user.as_ref().expect("_npmUser present");
let approver = user.approver.as_ref().expect("approver present");
assert_eq!(approver.name.as_deref(), Some("bob"));
assert_eq!(approver.email.as_deref(), Some("bob@example.com"));
}
/// A packument that doesn't ship `_npmUser` or `attestations` (the
/// common case for older registries) still deserializes; the
/// trust-evidence fields land as `None` and the trust check that

View File

@@ -191,9 +191,9 @@ pub struct PeerDependencyMeta {
}
/// `_npmUser` field on a per-version manifest. The verifier reads
/// `trusted_publisher` to assign the higher of the two trust ranks
/// (`trustedPublisher` > `provenance` > none). `name` / `email` are
/// kept for round-trip parity.
/// `approver` and `trusted_publisher` to assign the trust rank
/// (`stagedPublish` > `trustedPublisher` > `provenance` > none).
/// `name` / `email` are kept for round-trip parity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NpmUser {
@@ -202,9 +202,24 @@ pub struct NpmUser {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approver: Option<Approver>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_publisher: Option<TrustedPublisher>,
}
/// `_npmUser.approver` record on a per-version manifest. Its presence
/// marks a staged publish — one that required a 2FA publish approval,
/// the strongest trust signal. The verifier only checks for the
/// field's presence; `name` / `email` are kept for round-trip parity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Approver {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
/// OIDC trusted-publisher record on `_npmUser.trustedPublisher`.
/// The verifier only checks for the field's presence; the inner
/// values are kept for round-trip parity.

View File

@@ -24,7 +24,7 @@ use chrono::{DateTime, Utc};
use pacquet_config::{TrustPolicy, version_policy::PackageVersionPolicy};
use pacquet_lockfile::{LockfileResolution, PkgName};
use pacquet_network::{AuthHeaders, ThrottledClient};
use pacquet_registry::{NpmUser, Package, PackageDistribution, PackageVersion};
use pacquet_registry::{Approver, NpmUser, Package, PackageDistribution, PackageVersion};
use pacquet_resolving_resolver_base::{
ResolutionVerification, ResolutionVerifier, VerifyCtx, VerifyFuture,
};
@@ -835,8 +835,9 @@ fn build_policy_snapshot(
/// Build a [`Package`] that retains only the fields
/// [`fail_if_trust_downgraded`] reads: the package name, the per-version
/// `time` map, and per-version trust evidence (`_npmUser.trustedPublisher`
/// and `dist.attestations.provenance`). Drops everything else — dependency
/// `time` map, and per-version trust evidence (`_npmUser.approver`,
/// `_npmUser.trustedPublisher`, and `dist.attestations.provenance`).
/// Drops everything else — dependency
/// graphs, scripts, READMEs — so the per-install trust-meta cache stays
/// bounded by the trust-evidence footprint, not the full packument size.
///
@@ -871,14 +872,20 @@ fn project_trust_package_version(version: &PackageVersion) -> PackageVersion {
version.dist.attestations.as_ref().and_then(|att| att.provenance.as_ref()).map(|prov| {
pacquet_registry::AttestationsDist { provenance: Some(prov.clone()), url: None }
});
// `get_trust_evidence` only reads `npm_user.trusted_publisher`; drop
// the maintainer `name` / `email` PII so the projected cache entry
// `get_trust_evidence` only reads `npm_user.approver` (presence) and
// `npm_user.trusted_publisher`; drop the maintainer `name` / `email`
// PII — including the approver's — so the projected cache entry
// doesn't hold per-version publisher metadata that downstream
// doesn't need.
let npm_user =
version.npm_user.as_ref().and_then(|user| user.trusted_publisher.as_ref()).map(|trusted| {
NpmUser { name: None, email: None, trusted_publisher: Some(trusted.clone()) }
});
let approver = version.npm_user.as_ref().and_then(|user| user.approver.as_ref());
let trusted_publisher =
version.npm_user.as_ref().and_then(|user| user.trusted_publisher.as_ref());
let npm_user = (approver.is_some() || trusted_publisher.is_some()).then(|| NpmUser {
name: None,
email: None,
approver: approver.map(|_| Approver { name: None, email: None }),
trusted_publisher: trusted_publisher.cloned(),
});
PackageVersion {
// `fail_if_trust_downgraded` keys off the outer `meta.versions`
// map and the version-level npm_user / attestations fields. The

View File

@@ -24,15 +24,12 @@
//! registry fetch when the lockfile-pinned tarball is already in the
//! store. Pacquet today goes through the picker unconditionally;
//! restoring the fast path is a separate item.
//! - **Trust-policy enforcement.** The resolver-side
//! `failIfTrustDowngraded` call is wired through the verifier crate
//! only; the resolver path doesn't enforce it yet.
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use chrono::{DateTime, Utc};
use node_semver::Version;
use pacquet_config::version_policy::PackageVersionPolicy;
use pacquet_config::{TrustPolicy, version_policy::PackageVersionPolicy};
use pacquet_lockfile::{LockfileResolution, PkgName, PkgNameVer, TarballResolution};
use pacquet_network::{AuthHeaders, ThrottledClient};
use pacquet_registry::{Package, PackageVersion};
@@ -52,6 +49,7 @@ use crate::{
resolve_from_local_package, try_resolve_from_workspace,
try_resolve_from_workspace_packages,
},
trust_checks::{TrustCheckOptions, fail_if_trust_downgraded},
violation_codes::MINIMUM_RELEASE_AGE_VIOLATION_CODE,
};
@@ -264,6 +262,15 @@ impl<Cache: PackageMetaCache + 'static> NpmResolver<Cache> {
}
};
// Resolver-time `trustPolicy='no-downgrade'` gate. Runs on the
// registry pick before the workspace shadow, matching upstream's
// [`failIfTrustDowngraded`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/index.ts#L548-L550)
// call site. A downgrade is a hard error that aborts the
// install, so it propagates as a `ResolveError` rather than the
// soft [`ResolveResult::policy_violation`] used for
// `minimumReleaseAge`.
fail_if_trust_downgraded_for_pick(opts, &picked)?;
// Registry pick succeeded — prefer the workspace copy when it
// matches (or is newer, or `preferWorkspacePackages` is on).
// Mirrors upstream's
@@ -620,6 +627,30 @@ pub(crate) fn build_resolve_result(
})
}
/// Resolver-time `trustPolicy='no-downgrade'` check on a fresh pick.
/// No-op unless the policy is `NoDowngrade`. When active, runs
/// [`fail_if_trust_downgraded`] against the picked version using the
/// full packument the picker fetched (forced to full metadata under
/// this policy by the install layer) and propagates a downgrade as a
/// hard [`ResolveError`]. Mirrors upstream's resolver-time
/// [`failIfTrustDowngraded`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/index.ts#L548-L550)
/// call.
fn fail_if_trust_downgraded_for_pick(
opts: &ResolveOptions,
picked: &PickedFromRegistry,
) -> Result<(), ResolveError> {
if opts.trust_policy != Some(TrustPolicy::NoDowngrade) {
return Ok(());
}
let trust_opts = TrustCheckOptions {
trust_policy_exclude: opts.trust_policy_exclude.as_ref(),
trust_policy_ignore_after_minutes: opts.trust_policy_ignore_after,
now: None,
};
fail_if_trust_downgraded(&picked.meta, &picked.version.version.to_string(), &trust_opts)
.map_err(|err| Box::new(err) as ResolveError)
}
/// Resolver-time `minimumReleaseAge` check. Returns a violation entry
/// when the picked version's publish timestamp falls past the policy
/// cutoff and isn't excluded by name/version. Mirrors upstream's

View File

@@ -5,6 +5,7 @@ use std::{
};
use chrono::TimeZone;
use pacquet_config::TrustPolicy;
use pacquet_lockfile::LockfileResolution;
use pacquet_network::{AuthHeaders, ThrottledClient};
use pacquet_resolving_resolver_base::{
@@ -113,6 +114,48 @@ const JSR_PACKAGE_BODY: &str = r#"{
}
}"#;
/// Packument where the earlier-published `1.0.0` carries the strongest
/// trust evidence available here (`trustedPublisher` + provenance) and
/// the later `1.1.0` carries none — a trust downgrade. Resolving
/// `^1.0.0` picks `1.1.0` (the max), so the resolver-time gate must
/// reject it under `trustPolicy='no-downgrade'`.
const TRUST_DOWNGRADE_PACKAGE_BODY: &str = r#"{
"name": "acme",
"dist-tags": { "latest": "1.1.0" },
"modified": "2025-01-15T12:00:00.000Z",
"time": {
"1.0.0": "2024-01-10T08:30:00.000Z",
"1.1.0": "2024-12-10T08:30:00.000Z"
},
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"_npmUser": {
"name": "alice",
"trustedPublisher": { "id": "github", "oidcConfigId": "release" }
},
"dist": {
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz",
"attestations": {
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
}
}
},
"1.1.0": {
"name": "acme",
"version": "1.1.0",
"dist": {
"integrity": "sha512-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==",
"shasum": "1111111111111111111111111111111111111111",
"tarball": "https://registry/acme-1.1.0.tgz"
}
}
}
}"#;
#[tokio::test]
async fn range_specifier_picks_max_in_range() {
let mut server = mockito::Server::new_async().await;
@@ -220,6 +263,57 @@ async fn surfaces_min_release_age_violation_inline() {
assert_eq!(violation.code, MINIMUM_RELEASE_AGE_VIOLATION_CODE);
}
#[tokio::test]
async fn trust_downgrade_at_resolve_time_fails_under_no_downgrade() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(TRUST_DOWNGRADE_PACKAGE_BODY)
.create_async()
.await;
let registry = format!("{}/", server.url());
let (resolver, _tempdir) = build_resolver(&registry);
// `^1.0.0` picks 1.1.0 (the max), which has no trust evidence while
// the earlier 1.0.0 shipped a trusted publisher — a downgrade. The
// resolver-time gate must reject it as a hard error.
let opts = ResolveOptions {
trust_policy: Some(TrustPolicy::NoDowngrade),
..ResolveOptions::default()
};
let wanted = WantedDependency {
alias: Some("acme".to_string()),
bare_specifier: Some("^1.0.0".to_string()),
..WantedDependency::default()
};
let err = resolver.resolve(&wanted, &opts).await.expect_err("trust downgrade should fail");
assert!(err.to_string().contains("trust downgrade"), "got {err}");
}
#[tokio::test]
async fn trust_downgrade_ignored_when_trust_policy_off() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(TRUST_DOWNGRADE_PACKAGE_BODY)
.create_async()
.await;
let registry = format!("{}/", server.url());
let (resolver, _tempdir) = build_resolver(&registry);
// Same downgrade history, but without `trustPolicy='no-downgrade'`
// the gate never runs and 1.1.0 resolves cleanly.
let wanted = WantedDependency {
alias: Some("acme".to_string()),
bare_specifier: Some("^1.0.0".to_string()),
..WantedDependency::default()
};
let result = resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap().unwrap();
assert_eq!(result.name_ver.as_ref().expect("name_ver").suffix.to_string(), "1.1.0");
}
#[tokio::test]
async fn resolve_latest_returns_picked_manifest() {
let mut server = mockito::Server::new_async().await;

View File

@@ -8,6 +8,9 @@
//! For each it asks [`get_trust_evidence`] which "rank" of evidence
//! the version exposes:
//!
//! - `stagedPublish` (rank 3) — `_npmUser.approver` is present. A
//! staged publish required a 2FA publish approval, the strongest
//! trust signal.
//! - `trustedPublisher` (rank 2) — `_npmUser.trustedPublisher` and
//! `dist.attestations.provenance` are both present.
//! - `provenance` (rank 1) — `dist.attestations.provenance` is
@@ -31,7 +34,8 @@ use node_semver::Version;
use pacquet_config::version_policy::{PackageVersionPolicy, PolicyMatch};
use pacquet_registry::{Package, PackageVersion};
/// Rank of supply-chain evidence on a single version.
/// Rank of supply-chain evidence on a single version. Variants are
/// declared weakest-first so the derived `Ord` matches `trust_rank`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TrustEvidence {
/// `dist.attestations.provenance` is set.
@@ -42,6 +46,10 @@ pub enum TrustEvidence {
/// only counts as the stronger signal when the version also
/// shipped a provenance attestation.
TrustedPublisher,
/// `_npmUser.approver` is set. The version was published through a
/// staged publish requiring a 2FA approval — the strongest signal,
/// ranked above a trusted publisher.
StagedPublish,
}
/// Failure surfaced by [`fail_if_trust_downgraded`]. Each variant
@@ -181,12 +189,13 @@ pub fn fail_if_trust_downgraded(
}
/// Map a [`TrustEvidence`] rank to upstream's numeric weight at
/// [`trustChecks.ts:10-13`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/trustChecks.ts#L10-L13).
/// [`trustChecks.ts:10-14`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/trustChecks.ts#L10-L14).
/// Upstream uses `undefined` for "no evidence"; the Rust port uses
/// `Option<TrustEvidence>` so callers compare ranks via
/// `Option::map_or(0, trust_rank)`.
fn trust_rank(evidence: TrustEvidence) -> u8 {
match evidence {
TrustEvidence::StagedPublish => 3,
TrustEvidence::TrustedPublisher => 2,
TrustEvidence::Provenance => 1,
}
@@ -194,6 +203,7 @@ fn trust_rank(evidence: TrustEvidence) -> u8 {
fn pretty_print_trust_evidence(evidence: Option<TrustEvidence>) -> &'static str {
match evidence {
Some(TrustEvidence::StagedPublish) => "staged publish",
Some(TrustEvidence::TrustedPublisher) => "trusted publisher",
Some(TrustEvidence::Provenance) => "provenance attestation",
None => "no trust evidence",
@@ -233,24 +243,32 @@ fn detect_strongest_trust_evidence_before(
let Some(evidence) = get_trust_evidence(manifest) else {
continue;
};
if matches!(evidence, TrustEvidence::TrustedPublisher) {
return Some(TrustEvidence::TrustedPublisher);
}
// First provenance hit sticks; a later trusted-publisher
// hit would have returned above.
if best.is_none() {
best = Some(TrustEvidence::Provenance);
// Keep the highest-ranked evidence seen so far. Don't short-
// circuit on a mid-rank hit: a later version may carry stronger
// evidence, and missing it would let a real downgrade slip
// through. Only `StagedPublish` — the maximum rank — ends the
// walk early.
if best.is_none_or(|current| trust_rank(evidence) > trust_rank(current)) {
best = Some(evidence);
if evidence == TrustEvidence::StagedPublish {
return best;
}
}
}
best
}
/// `_npmUser.trustedPublisher` outranks `dist.attestations.provenance`
/// `_npmUser.approver` (a staged publish) outranks everything; failing
/// that, `_npmUser.trustedPublisher` outranks `dist.attestations.provenance`
/// only when the version also carries a provenance attestation;
/// otherwise the publisher flag is ignored and the version falls back
/// to the provenance rank or `None`. Mirrors pnpm's
/// [`getTrustEvidence`](https://github.com/pnpm/pnpm/blob/fea5fd41da/resolving/npm-resolver/src/trustChecks.ts#L119-L127).
/// [`getTrustEvidence`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/trustChecks.ts#L123-L134).
pub fn get_trust_evidence(version: &PackageVersion) -> Option<TrustEvidence> {
let has_approver = version.npm_user.as_ref().and_then(|user| user.approver.as_ref()).is_some();
if has_approver {
return Some(TrustEvidence::StagedPublish);
}
let has_provenance =
version.dist.attestations.as_ref().and_then(|att| att.provenance.as_ref()).is_some();
let has_trusted_publisher =

View File

@@ -9,21 +9,26 @@ enum Evidence {
None,
Provenance,
TrustedPublisher,
StagedPublish,
}
/// Build a JSON object for a single version with the trust-evidence
/// shape the verifier reads (`_npmUser.trustedPublisher` or
/// `dist.attestations.provenance`). A `TrustedPublisher` fixture
/// includes both fields: per `get_trust_evidence`, the publisher
/// flag only outranks plain provenance when the version also ships a
/// provenance attestation.
/// shape the verifier reads (`_npmUser.approver`,
/// `_npmUser.trustedPublisher`, or `dist.attestations.provenance`). A
/// `TrustedPublisher` fixture includes both fields: per
/// `get_trust_evidence`, the publisher flag only outranks plain
/// provenance when the version also ships a provenance attestation. A
/// `StagedPublish` fixture carries an `approver`, the strongest signal.
fn version_json(name: &str, version: &str, evidence: Evidence) -> serde_json::Value {
let mut dist = serde_json::json!({
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": format!("https://registry/{name}-{version}.tgz")
});
if matches!(evidence, Evidence::Provenance | Evidence::TrustedPublisher) {
if matches!(
evidence,
Evidence::Provenance | Evidence::TrustedPublisher | Evidence::StagedPublish,
) {
dist["attestations"] = serde_json::json!({
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
});
@@ -39,6 +44,11 @@ fn version_json(name: &str, version: &str, evidence: Evidence) -> serde_json::Va
"trustedPublisher": { "id": "github", "oidcConfigId": "release" }
});
}
if matches!(evidence, Evidence::StagedPublish) {
version_obj["_npmUser"] = serde_json::json!({
"approver": { "name": "approver", "email": "approver@example.com" }
});
}
version_obj
}
@@ -87,6 +97,23 @@ fn trusted_publisher_to_provenance_downgrade_fails() {
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}
/// Earlier version had `stagedPublish` (an approver), current version
/// has only `trustedPublisher` → DOWNGRADE. Staged publish outranks a
/// trusted publisher.
#[test]
fn staged_publish_to_trusted_publisher_downgrade_fails() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::StagedPublish),
("2.0.0", "2025-02-01T00:00:00.000Z", Evidence::TrustedPublisher),
],
);
let err = fail_if_trust_downgraded(&meta, "2.0.0", &TrustCheckOptions::default())
.expect_err("staged-publish → trusted-publisher is a downgrade");
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}
/// Earlier version had `provenance`, current version has no
/// evidence at all → DOWNGRADE.
#[test]
@@ -103,6 +130,41 @@ fn provenance_to_unsigned_downgrade_fails() {
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}
/// Earlier version had `trustedPublisher`, current version has no
/// evidence at all → DOWNGRADE. Mirrors upstream's "downgrading from
/// trustedPublisher to none" case, with a third unsigned version
/// before the publisher version to exercise the full history walk.
#[test]
fn trusted_publisher_to_unsigned_downgrade_fails() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None),
("2.0.0", "2025-02-01T00:00:00.000Z", Evidence::TrustedPublisher),
("3.0.0", "2025-03-01T00:00:00.000Z", Evidence::None),
],
);
let err = fail_if_trust_downgraded(&meta, "3.0.0", &TrustCheckOptions::default())
.expect_err("trusted-publisher → no evidence is a downgrade");
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}
/// No version in the history carries any trust evidence, so there is
/// no baseline to downgrade from → passes. Mirrors upstream's
/// "succeeds when no versions have attestation".
#[test]
fn no_evidence_anywhere_passes() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None),
("2.0.0", "2025-02-01T00:00:00.000Z", Evidence::None),
],
);
fail_if_trust_downgraded(&meta, "2.0.0", &TrustCheckOptions::default())
.expect("no evidence anywhere → no downgrade possible");
}
/// Equal-rank evidence (provenance → provenance) is not a
/// downgrade.
#[test]
@@ -266,6 +328,44 @@ fn exclude_exact_version_short_circuits_check() {
assert!(err.is_none(), "1.0.0 has its own trusted-publisher → passes");
}
/// An excluded `name@version` short-circuits *before* the `time`
/// lookup, so a packument with no `time` map still passes rather than
/// surfacing `TrustCheckFailed`. Pins the ordering of the exclude
/// check ahead of the time assertion. Mirrors upstream's "does not
/// fail with ERR_PNPM_MISSING_TIME when package@version is excluded".
#[test]
fn exclude_exact_version_with_missing_time_does_not_fail() {
let mut meta = make_package("acme", &[("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None)]);
if let Some(time) = meta.time.as_mut() {
time.clear();
}
let exclude = create_package_version_policy(["acme@1.0.0"]).unwrap();
let opts = TrustCheckOptions { trust_policy_exclude: Some(&exclude), ..Default::default() };
fail_if_trust_downgraded(&meta, "1.0.0", &opts)
.expect("excluded version short-circuits before the missing-time check");
}
/// Same as above, but the whole package name is excluded. Mirrors
/// upstream's "does not fail with ERR_PNPM_MISSING_TIME when package
/// name is excluded".
#[test]
fn exclude_package_name_with_missing_time_does_not_fail() {
let mut meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None),
("2.0.0", "2025-02-01T00:00:00.000Z", Evidence::None),
],
);
if let Some(time) = meta.time.as_mut() {
time.clear();
}
let exclude = create_package_version_policy(["acme"]).unwrap();
let opts = TrustCheckOptions { trust_policy_exclude: Some(&exclude), ..Default::default() };
fail_if_trust_downgraded(&meta, "2.0.0", &opts)
.expect("excluded package short-circuits before the missing-time check");
}
/// Missing `time` entry for the target version surfaces as
/// `TrustCheckFailed`. Mirrors upstream's
/// `Missing time for version X of Y` PnpmError.
@@ -355,6 +455,24 @@ mod get_trust_evidence {
));
}
/// `_npmUser.approver` ranks as `StagedPublish`, the strongest
/// evidence.
#[test]
fn approver_ranks_as_staged_publish() {
let version = version_json("acme", "1.0.0", Evidence::StagedPublish);
assert!(matches!(get_trust_evidence(&parse(version)), Some(TrustEvidence::StagedPublish)));
}
/// `_npmUser.approver` wins even when `trustedPublisher` and
/// provenance are also present — staged publish takes priority.
#[test]
fn approver_outranks_trusted_publisher() {
let mut version = version_json("acme", "1.0.0", Evidence::TrustedPublisher);
version["_npmUser"]["approver"] =
serde_json::json!({ "name": "approver", "email": "approver@example.com" });
assert!(matches!(get_trust_evidence(&parse(version)), Some(TrustEvidence::StagedPublish)));
}
/// `dist.attestations.provenance` alone ranks as `Provenance`.
#[test]
fn provenance_alone_ranks_as_provenance() {

View File

@@ -12,7 +12,7 @@ use std::{collections::BTreeMap, future::Future, path::PathBuf, pin::Pin, sync::
use chrono::{DateTime, Utc};
use derive_more::{Display, From};
use pacquet_config::version_policy::PackageVersionPolicy;
use pacquet_config::{TrustPolicy, version_policy::PackageVersionPolicy};
use pacquet_lockfile::{LockfileResolution, PkgNameVer};
use serde::{Deserialize, Serialize};
@@ -215,6 +215,21 @@ pub struct ResolveOptions {
/// Per-package exclude policy for the maturity filter. `None`
/// applies the filter uniformly.
pub published_by_exclude: Option<PackageVersionPolicy>,
/// `trustPolicy='no-downgrade'` gate. When `Some(NoDowngrade)`, the
/// npm resolver rejects a freshly picked version whose trust
/// evidence is weaker than an earlier-published version's — the
/// resolver-time counterpart to the lockfile verifier's check.
/// `None`/`Some(Off)` disables it. Mirrors pnpm's resolver-time
/// [`failIfTrustDowngraded`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/index.ts#L548-L550)
/// call, gated on `opts.trustPolicy === 'no-downgrade'`.
pub trust_policy: Option<TrustPolicy>,
/// Per-package exclude policy for the trust gate. `None` applies
/// the gate uniformly.
pub trust_policy_exclude: Option<PackageVersionPolicy>,
/// Max age, in minutes, before which the trust gate still applies.
/// A picked version older than this skips the check. `None` always
/// checks. Mirrors pnpm's `trustPolicyIgnoreAfter`.
pub trust_policy_ignore_after: Option<u64>,
/// `true` suppresses on-disk and in-memory cache write-back during
/// resolution. Mirrors upstream's `dryRun` flag at the resolver
/// boundary.

View File

@@ -451,12 +451,23 @@ function projectTrustManifest (manifest: PackageInRegistry): PackageInRegistry {
// reads them; cast away the unsoundness so callers see the same nominal
// shape without the per-version dependency graph / scripts / README bulk
// carrying through. `_npmUser` is similarly narrowed to just
// `trustedPublisher` — the only sub-field the trust check inspects — so
// we don't keep maintainer name/email PII resident in the cache.
// `trustedPublisher` and `approver` — the only sub-fields the trust check
// inspects — so we don't keep maintainer name/email PII resident in the
// cache.
const approver = manifest._npmUser?.approver
const trustedPublisher = manifest._npmUser?.trustedPublisher
const provenance = manifest.dist?.attestations?.provenance
let npmUser: PackageInRegistry['_npmUser'] = undefined
if (approver) {
npmUser ||= {}
npmUser.approver = {}
}
if (trustedPublisher) {
npmUser ||= {}
npmUser.trustedPublisher = trustedPublisher
}
return {
_npmUser: trustedPublisher != null ? { trustedPublisher } : undefined,
_npmUser: npmUser,
dist: provenance != null
? { attestations: { provenance } }
: undefined,

View File

@@ -5,9 +5,10 @@ import semver from 'semver'
import { assertMetaHasTime } from './pickPackageFromMeta.js'
type TrustEvidence = 'provenance' | 'trustedPublisher'
type TrustEvidence = 'provenance' | 'trustedPublisher' | 'stagedPublish'
const TRUST_RANK = {
stagedPublish: 3,
trustedPublisher: 2,
provenance: 1,
} as const satisfies Record<TrustEvidence, number>
@@ -81,6 +82,7 @@ export function failIfTrustDowngraded (
function prettyPrintTrustEvidence (trustEvidence: TrustEvidence | undefined): string {
switch (trustEvidence) {
case 'stagedPublish': return 'staged publish'
case 'trustedPublisher': return 'trusted publisher'
case 'provenance': return 'provenance attestation'
default: return 'no trust evidence'
@@ -107,16 +109,21 @@ function detectStrongestTrustEvidenceBeforeDate (
const trustEvidence = getTrustEvidence(manifest)
if (!trustEvidence) continue
if (trustEvidence === 'trustedPublisher') {
return 'trustedPublisher'
if (best === undefined || TRUST_RANK[trustEvidence] > TRUST_RANK[best]) {
best = trustEvidence
if (best === 'stagedPublish') {
return best
}
}
best ||= 'provenance'
}
return best
}
export function getTrustEvidence (manifest: PackageInRegistry): TrustEvidence | undefined {
if (manifest._npmUser?.approver) {
return 'stagedPublish'
}
if (manifest._npmUser?.trustedPublisher && manifest.dist?.attestations?.provenance) {
return 'trustedPublisher'
}

View File

@@ -94,6 +94,55 @@ describe('getTrustEvidence', () => {
}
expect(getTrustEvidence(manifest)).toBeUndefined()
})
test('returns stagedPublish when approver exists', () => {
const manifest: PackageInRegistry = {
name: 'foo',
version: '1.0.0',
_npmUser: {
name: 'test-approver',
email: 'user@example.com',
approver: {
name: 'test-approver',
email: 'user@example.com',
},
},
dist: {
shasum: 'abc123',
tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz',
},
}
expect(getTrustEvidence(manifest)).toBe('stagedPublish')
})
test('returns stagedPublish when both approver and trustedPublisher exist', () => {
const manifest: PackageInRegistry = {
name: 'foo',
version: '1.0.0',
_npmUser: {
name: 'test-approver',
email: 'user@example.com',
approver: {
name: 'test-approver',
email: 'user@example.com',
},
trustedPublisher: {
id: 'test-provider',
oidcConfigId: 'oidc:test-config-123',
},
},
dist: {
shasum: 'abc123',
tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz',
attestations: {
provenance: {
predicateType: 'https://slsa.dev/provenance/v1',
},
},
},
}
expect(getTrustEvidence(manifest)).toBe('stagedPublish')
})
})
describe('failIfTrustDowngraded', () => {
@@ -371,6 +420,64 @@ describe('failIfTrustDowngraded', () => {
}).toThrow('High-risk trust downgrade')
})
test('throws an error when downgrading from stagedPublish to trustedPublisher', () => {
const meta: PackageMetaWithTime = {
name: 'foo',
'dist-tags': { latest: '2.0.0' },
versions: {
'1.0.0': {
name: 'foo',
version: '1.0.0',
_npmUser: {
name: 'test-approver',
email: 'approver@example.com',
approver: {
name: 'test-approver',
email: 'approver@example.com',
},
},
dist: {
shasum: 'abc123',
tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz',
attestations: {
provenance: {
predicateType: 'https://slsa.dev/provenance/v1',
},
},
},
},
'2.0.0': {
name: 'foo',
version: '2.0.0',
_npmUser: {
name: 'test-publisher',
email: 'publisher@example.com',
trustedPublisher: {
id: 'test-provider',
oidcConfigId: 'oidc:test-config-123',
},
},
dist: {
shasum: 'def456',
tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz',
attestations: {
provenance: {
predicateType: 'https://slsa.dev/provenance/v1',
},
},
},
},
},
time: {
'1.0.0': '2025-01-01T00:00:00.000Z',
'2.0.0': '2025-02-01T00:00:00.000Z',
},
}
expect(() => {
failIfTrustDowngraded(meta, '2.0.0')
}).toThrow('High-risk trust downgrade')
})
test('succeeds when maintaining same trust level', () => {
const meta: PackageMetaWithTime = {
name: 'foo',

View File

@@ -29,6 +29,10 @@ export interface PackageInRegistry extends PackageManifest {
_npmUser?: {
name?: string
email?: string
approver?: {
name?: string
email?: string
}
trustedPublisher?: {
id: string
oidcConfigId: string