Skip to main content

aube_resolver/
trust.rs

1//! Trust-policy enforcement.
2//!
3//! Mirrors pnpm's `failIfTrustDowngraded`
4//! (resolving/npm-resolver/src/trustChecks.ts), verified against pnpm's
5//! own test suite. Three trust-evidence sources, ranked
6//! `StagedPublish (3) > TrustedPublisher (2) > Provenance (1)`. aube
7//! only accepts the structured metadata shapes npm emits after
8//! server-side checks: `approver` must be present, `_npmUser.trustedPublisher`
9//! must name a publisher id, and `dist.attestations.provenance` must
10//! name an SLSA provenance predicate. This is metadata-shape validation,
11//! not install-time cryptographic verification of the attestation bundle.
12//! The check runs immediately after a version is picked from a packument:
13//! if any strictly older version of the same package had stronger trust
14//! evidence, the install fails. Pre-2010 packuments without per-version
15//! `time` entries error when the picked version isn't excluded — same as
16//! pnpm.
17
18use aube_registry::{Packument, VersionMetadata};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21/// Trust-evidence ranks. Higher is stronger. Variants intentionally do
22/// not derive `Ord` — the variant declaration order does not match the
23/// rank order, so callers must go through [`Self::rank`].
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TrustEvidence {
26    StagedPublish,
27    TrustedPublisher,
28    Provenance,
29}
30
31impl TrustEvidence {
32    pub fn rank(self) -> u8 {
33        match self {
34            Self::StagedPublish => 3,
35            Self::TrustedPublisher => 2,
36            Self::Provenance => 1,
37        }
38    }
39
40    pub fn label(self) -> &'static str {
41        match self {
42            Self::StagedPublish => "staged publish approval",
43            Self::TrustedPublisher => "trusted publisher",
44            Self::Provenance => "provenance attestation",
45        }
46    }
47}
48
49/// Strongest trust evidence carried by a single version's metadata.
50/// `approver` outranks `_npmUser.trustedPublisher`, which outranks
51/// `dist.attestations.provenance`.
52pub fn evidence_for(meta: &VersionMetadata) -> Option<TrustEvidence> {
53    if meta.approver.as_ref().is_some_and(is_approver) {
54        return Some(TrustEvidence::StagedPublish);
55    }
56    if meta
57        .npm_user
58        .as_ref()
59        .and_then(|u| u.trusted_publisher.as_ref())
60        .is_some_and(is_trusted_publisher)
61    {
62        return Some(TrustEvidence::TrustedPublisher);
63    }
64    if meta
65        .dist
66        .as_ref()
67        .and_then(|d| d.attestations.as_ref())
68        .and_then(|a| a.provenance.as_ref())
69        .is_some_and(is_provenance)
70    {
71        return Some(TrustEvidence::Provenance);
72    }
73    None
74}
75
76fn is_approver(v: &serde_json::Value) -> bool {
77    match v {
78        serde_json::Value::Null => false,
79        serde_json::Value::String(s) => !s.is_empty(),
80        serde_json::Value::Array(a) => a.iter().any(is_approver),
81        serde_json::Value::Object(o) => o.values().any(is_approver),
82        serde_json::Value::Bool(b) => *b,
83        serde_json::Value::Number(n) => {
84            n.as_i64().is_some_and(|i| i != 0)
85                || n.as_u64().is_some_and(|u| u != 0)
86                || n.as_f64().is_some_and(|f| f != 0.0)
87        }
88    }
89}
90
91fn is_trusted_publisher(v: &serde_json::Value) -> bool {
92    v.as_object()
93        .and_then(|o| o.get("id"))
94        .and_then(|id| id.as_str())
95        .is_some_and(|id| !id.is_empty())
96}
97
98fn is_provenance(v: &serde_json::Value) -> bool {
99    v.as_object()
100        .and_then(|o| o.get("predicateType"))
101        .and_then(|predicate| predicate.as_str())
102        .is_some_and(|predicate| {
103            predicate
104                .strip_prefix("https://slsa.dev/provenance/v")
105                .and_then(|suffix| suffix.chars().next())
106                .is_some_and(|c| c.is_ascii_digit())
107        })
108}
109
110#[derive(Debug)]
111pub enum TrustCheckError {
112    Downgrade(TrustDowngradeDetails),
113    MissingTime(MissingTimeDetails),
114}
115
116#[derive(Debug)]
117pub struct TrustDowngradeDetails {
118    pub name: String,
119    pub picked_version: String,
120    pub current_evidence: Option<TrustEvidence>,
121    pub prior_evidence: TrustEvidence,
122    pub prior_version: String,
123}
124
125#[derive(Debug)]
126pub struct MissingTimeDetails {
127    pub name: String,
128    pub version: String,
129}
130
131/// Run the trust-downgrade check. Returns `Ok(())` when the picked
132/// version is acceptable (excluded, missing-evidence-everywhere, older
133/// than `ignore_after_minutes`, or carrying evidence at least as strong
134/// as the strongest prior version's). Errors otherwise.
135///
136/// Step ordering matters: exclude check runs *before* the time lookup
137/// so an excluded `name@version` does not surface a `MissingTime` error
138/// when the registry omits the `time` field. Verified against pnpm's
139/// `does not fail with ERR_PNPM_MISSING_TIME when ... excluded` tests.
140pub fn check_no_downgrade(
141    packument: &Packument,
142    picked_version: &str,
143    picked_meta: &VersionMetadata,
144    exclude: &TrustExcludeRules,
145    ignore_after_minutes: Option<u64>,
146) -> Result<(), TrustCheckError> {
147    let picked_parsed = node_semver::Version::parse(picked_version).ok();
148
149    if let Some(ref pv) = picked_parsed {
150        if exclude.matches(&packument.name, pv) {
151            return Ok(());
152        }
153    } else if exclude.matches_name_only(&packument.name) {
154        return Ok(());
155    }
156
157    // Registry doesn't publish `time` at all — local Verdaccio fixtures,
158    // some private mirrors, ancient registry forks. Without per-version
159    // publish times we can't compare evidence chronologically, so skip
160    // the check rather than fail every install. This degrades the
161    // protection but preserves install behavior against compliant
162    // registries (npmjs.org, JSR, modern Verdaccio). Diverges from
163    // pnpm's strict-throw behavior because trustPolicy is default-on
164    // in aube — strict-throw against the long tail of registries that
165    // omit `time` would make aube unusable on first install.
166    if packument.time.is_empty() {
167        return Ok(());
168    }
169
170    let Some(picked_time) = packument.time.get(picked_version) else {
171        return Err(TrustCheckError::MissingTime(MissingTimeDetails {
172            name: packument.name.clone(),
173            version: picked_version.to_string(),
174        }));
175    };
176
177    if let Some(minutes) = ignore_after_minutes
178        && minutes > 0
179        && let Some(cutoff) = cutoff_iso8601(minutes)
180        && picked_time.as_str() < cutoff.as_str()
181    {
182        return Ok(());
183    }
184
185    // pnpm v10.24.0+: when the picked version is a stable release,
186    // ignore prior prerelease evidence — a trusted alpha shouldn't
187    // block a stable that omits attestation.
188    let exclude_prereleases = picked_parsed
189        .as_ref()
190        .map(|v| v.pre_release.is_empty())
191        .unwrap_or(false);
192
193    let mut best: Option<(TrustEvidence, &str)> = None;
194    for (other_ver, other_meta) in &packument.versions {
195        if other_ver == picked_version {
196            continue;
197        }
198        let Some(other_time) = packument.time.get(other_ver) else {
199            continue;
200        };
201        if other_time.as_str() >= picked_time.as_str() {
202            continue;
203        }
204        if exclude_prereleases
205            && let Ok(parsed) = node_semver::Version::parse(other_ver)
206            && !parsed.pre_release.is_empty()
207        {
208            continue;
209        }
210        let Some(evidence) = evidence_for(other_meta) else {
211            continue;
212        };
213        match best {
214            None => best = Some((evidence, other_ver.as_str())),
215            Some((cur, _)) if evidence.rank() > cur.rank() => {
216                best = Some((evidence, other_ver.as_str()));
217            }
218            _ => {}
219        }
220        if matches!(best, Some((TrustEvidence::StagedPublish, _))) {
221            break;
222        }
223    }
224
225    let Some((prior_evidence, prior_version)) = best else {
226        return Ok(());
227    };
228
229    let current = evidence_for(picked_meta);
230    let current_rank = current.map_or(0, TrustEvidence::rank);
231    if current_rank < prior_evidence.rank() {
232        return Err(TrustCheckError::Downgrade(TrustDowngradeDetails {
233            name: packument.name.clone(),
234            picked_version: picked_version.to_string(),
235            current_evidence: current,
236            prior_evidence,
237            prior_version: prior_version.to_string(),
238        }));
239    }
240    Ok(())
241}
242
243fn cutoff_iso8601(minutes_ago: u64) -> Option<String> {
244    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
245    let cutoff_secs = now.saturating_sub(minutes_ago * 60);
246    Some(crate::types::format_iso8601_utc(cutoff_secs))
247}
248
249/// Parsed `trustPolicyExclude` rules. Mirrors pnpm's
250/// `createPackageVersionPolicy` (config/version-policy/src/index.ts).
251/// Each rule is `<name>` (matches all versions, supports `*` glob in
252/// the name) or `<name>@<semver-range>[ || <semver-range>]…` (no name
253/// globs combined with versions).
254pub const DEFAULT_TRUST_POLICY_EXCLUDES: &[&str] = &[
255    // @octokit maintains several major-version lines in parallel and backports
256    // fixes to older lines without provenance attestation. A backport (e.g.
257    // @octokit/endpoint@9.0.6) is published after an attested newer major
258    // (10.1.0), so the no-downgrade check flags the legitimate older release.
259    "@octokit/endpoint",
260    // @hono/node-server keeps a 1.x line alive alongside 2.x. The 2.x releases
261    // are published from CI with SLSA provenance, but 1.x backports are
262    // hand-published by the maintainer without attestation — e.g.
263    // @hono/node-server@1.19.15 (2026-07-24) came after the attested
264    // 2.0.10 (2026-07-15), so no-downgrade flags the legitimate backport.
265    // Deliberately package-wide rather than scoped to `^1`: this publisher
266    // has already shipped one line without attestation, and we are not
267    // betting on 2.x holding its CI-published discipline. Do not narrow
268    // this to a version range on the theory that only 1.x is affected.
269    "@hono/node-server",
270    "chokidar",
271    "eslint-config-prettier",
272    "eslint-import-resolver-typescript",
273    "react-redux",
274    "reselect",
275    "semver",
276    "ua-parser-js",
277    "undici",
278    "undici-types",
279    "vite",
280];
281
282/// A parsed package-version policy: a set of `<name>[@<semver-range>…]`
283/// rules with `*` name globs. pnpm backs both `trustPolicyExclude` and
284/// `minimumReleaseAgeExclude` with the same `createPackageVersionPolicy`
285/// engine, so we do too — `TrustExcludeRules` is the neutral
286/// [`PackageVersionPolicy`] type seeded with the trust defaults, while
287/// `minimumReleaseAgeExclude` builds an empty one from user rules only.
288///
289/// # Warning
290///
291/// Because this is an alias for [`TrustExcludeRules`],
292/// `PackageVersionPolicy::default()` runs that type's [`Default`] impl,
293/// which seeds [`DEFAULT_TRUST_POLICY_EXCLUDES`] (40+ well-known
294/// packages). For the age gate that is wrong — it would silently exempt
295/// those packages from `minimumReleaseAge`. Age-gate call sites must use
296/// [`TrustExcludeRules::empty`]; never `default()`, `#[derive(Default)]`
297/// on a containing struct, or `unwrap_or_default()`.
298pub type PackageVersionPolicy = TrustExcludeRules;
299
300#[derive(Debug, Clone)]
301pub struct TrustExcludeRules {
302    rules: Vec<TrustExcludeRule>,
303}
304
305impl Default for TrustExcludeRules {
306    fn default() -> Self {
307        // Parse the entries rather than assuming each is a bare name, so a
308        // future `name@range` default actually works. The previous
309        // `from_name_excludes` hardcoded `version_ranges: None`, which would
310        // have compiled such an entry into a name matcher for the literal
311        // string `"name@range"` — silently matching nothing and dropping the
312        // exemption rather than erroring. Every current entry is a bare name,
313        // for which this is behavior-identical. The list is a compile-time
314        // constant, so a malformed entry is an authoring bug;
315        // `default_excludes_known_provenance_churn_packages` asserts each one
316        // parses.
317        Self::parse(DEFAULT_TRUST_POLICY_EXCLUDES)
318            .expect("DEFAULT_TRUST_POLICY_EXCLUDES must be valid exclude patterns")
319    }
320}
321
322#[derive(Debug, Clone)]
323struct TrustExcludeRule {
324    name_matcher: NameMatcher,
325    /// `None` → rule matches every version of any name match.
326    /// `Some(ranges)` → rule matches any version satisfying one range.
327    version_ranges: Option<Vec<node_semver::Range>>,
328}
329
330#[derive(Debug, Clone)]
331enum NameMatcher {
332    Exact(String),
333    Glob(GlobMatcher),
334    Any,
335}
336
337#[derive(Debug, Clone)]
338struct GlobMatcher {
339    parts: Vec<String>,
340    leading_wildcard: bool,
341    trailing_wildcard: bool,
342}
343
344// Shared by both `trustPolicyExclude` and `minimumReleaseAgeExclude`, so
345// the message text stays setting-neutral — the caller's log line names
346// the specific setting the bad entry came from.
347#[derive(Debug, thiserror::Error, miette::Diagnostic)]
348pub enum TrustExcludeParseError {
349    #[error("invalid exclude pattern `{pattern}`: version selectors must be valid semver ranges")]
350    #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_INVALID_VERSION_UNION))]
351    InvalidVersionUnion { pattern: String },
352    #[error(
353        "invalid exclude pattern `{pattern}`: name patterns (`*`) cannot be combined with version unions"
354    )]
355    #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_NAME_GLOB_WITH_VERSIONS))]
356    NameGlobWithVersions { pattern: String },
357}
358
359impl TrustExcludeRules {
360    /// A policy that matches nothing. Used as the
361    /// `minimumReleaseAgeExclude` default — unlike [`Default`], which
362    /// seeds the trust-specific exclude list, the age gate must start
363    /// with no exemptions.
364    pub fn empty() -> Self {
365        Self { rules: Vec::new() }
366    }
367
368    pub fn is_empty(&self) -> bool {
369        self.rules.is_empty()
370    }
371
372    pub fn len(&self) -> usize {
373        self.rules.len()
374    }
375
376    pub fn with_defaults_and_user_rules(user_rules: Self) -> Self {
377        let mut rules = Self::default();
378        rules.rules.extend(user_rules.rules);
379        rules
380    }
381
382    pub fn parse<I, S>(patterns: I) -> Result<Self, TrustExcludeParseError>
383    where
384        I: IntoIterator<Item = S>,
385        S: AsRef<str>,
386    {
387        let mut rules = Vec::new();
388        for pattern in patterns {
389            let pattern = pattern.as_ref();
390            if pattern.is_empty() {
391                continue;
392            }
393            rules.push(parse_one(pattern)?);
394        }
395        Ok(Self { rules })
396    }
397
398    /// Parse a list of patterns, keeping every rule that succeeds and
399    /// returning the per-pattern errors for everything that didn't.
400    /// Lets the caller log malformed entries individually without
401    /// dropping the rules that did parse — a strict batch `parse` would
402    /// turn one typo into a silent security regression where every
403    /// exclude vanishes.
404    pub fn parse_lossy<I, S>(patterns: I) -> (Self, Vec<TrustExcludeParseError>)
405    where
406        I: IntoIterator<Item = S>,
407        S: AsRef<str>,
408    {
409        let mut rules = Vec::new();
410        let mut errors = Vec::new();
411        for pattern in patterns {
412            let pattern = pattern.as_ref();
413            if pattern.is_empty() {
414                continue;
415            }
416            match parse_one(pattern) {
417                Ok(rule) => rules.push(rule),
418                Err(err) => errors.push(err),
419            }
420        }
421        (Self { rules }, errors)
422    }
423
424    pub(crate) fn matches(&self, name: &str, version: &node_semver::Version) -> bool {
425        for rule in &self.rules {
426            if !rule.name_matcher.matches(name) {
427                continue;
428            }
429            match &rule.version_ranges {
430                None => return true,
431                Some(ranges) => {
432                    if ranges.iter().any(|r| version.satisfies(r)) {
433                        return true;
434                    }
435                }
436            }
437        }
438        false
439    }
440
441    /// Used when the picked version string fails semver parse — only a
442    /// no-version rule can match in that case (pnpm behavior:
443    /// `evaluateVersionPolicy` returns `true` for name-only rules
444    /// before the version array branch is taken).
445    pub(crate) fn matches_name_only(&self, name: &str) -> bool {
446        self.rules
447            .iter()
448            .any(|r| r.version_ranges.is_none() && r.name_matcher.matches(name))
449    }
450}
451
452/// Split `<name>[@<versions>]` on the separator that isn't a scope marker,
453/// so a scoped name's leading `@` isn't mistaken for a version selector.
454fn split_name_and_versions(pattern: &str) -> (&str, Option<&str>) {
455    let at_index = match pattern.strip_prefix('@') {
456        // Scoped name: the leading `@` is the scope marker, so the version
457        // separator is the next `@`, offset back past the one we stripped.
458        Some(rest) => rest.find('@').map(|i| i + 1),
459        None => pattern.find('@'),
460    };
461    match at_index {
462        Some(i) => (&pattern[..i], Some(&pattern[i + 1..])),
463        None => (pattern, None),
464    }
465}
466
467fn parse_one(pattern: &str) -> Result<TrustExcludeRule, TrustExcludeParseError> {
468    let (name_part, versions_part) = split_name_and_versions(pattern);
469
470    let version_ranges = match versions_part {
471        None => None,
472        Some(versions_str) => {
473            if name_part.contains('*') {
474                return Err(TrustExcludeParseError::NameGlobWithVersions {
475                    pattern: pattern.to_string(),
476                });
477            }
478            let mut parsed = Vec::new();
479            for chunk in versions_str.split("||") {
480                let trimmed = chunk.trim();
481                if trimmed.is_empty() {
482                    return Err(TrustExcludeParseError::InvalidVersionUnion {
483                        pattern: pattern.to_string(),
484                    });
485                }
486                let r = node_semver::Range::parse(trimmed).map_err(|_| {
487                    TrustExcludeParseError::InvalidVersionUnion {
488                        pattern: pattern.to_string(),
489                    }
490                })?;
491                parsed.push(r);
492            }
493            Some(parsed)
494        }
495    };
496
497    Ok(TrustExcludeRule {
498        name_matcher: NameMatcher::compile(name_part),
499        version_ranges,
500    })
501}
502
503impl NameMatcher {
504    fn compile(pattern: &str) -> Self {
505        if pattern == "*" {
506            return Self::Any;
507        }
508        if !pattern.contains('*') {
509            return Self::Exact(pattern.to_string());
510        }
511        let parts: Vec<String> = pattern.split('*').map(str::to_string).collect();
512        Self::Glob(GlobMatcher {
513            leading_wildcard: parts.first().is_some_and(String::is_empty),
514            trailing_wildcard: parts.last().is_some_and(String::is_empty),
515            parts: parts.into_iter().filter(|s| !s.is_empty()).collect(),
516        })
517    }
518
519    fn matches(&self, input: &str) -> bool {
520        match self {
521            Self::Any => true,
522            Self::Exact(s) => s == input,
523            Self::Glob(g) => g.matches(input),
524        }
525    }
526}
527
528impl GlobMatcher {
529    fn matches(&self, input: &str) -> bool {
530        if self.parts.is_empty() {
531            return true;
532        }
533        let mut cursor = 0usize;
534        for (i, segment) in self.parts.iter().enumerate() {
535            let search_window = &input[cursor..];
536            let is_first = i == 0;
537            let is_last = i == self.parts.len() - 1;
538            if is_first && !self.leading_wildcard {
539                if !search_window.starts_with(segment.as_str()) {
540                    return false;
541                }
542                cursor += segment.len();
543            } else if is_last && !self.trailing_wildcard {
544                if !search_window.ends_with(segment.as_str()) {
545                    return false;
546                }
547                if search_window.len() < segment.len() {
548                    return false;
549                }
550                cursor = input.len();
551            } else {
552                let Some(idx) = search_window.find(segment.as_str()) else {
553                    return false;
554                };
555                cursor += idx + segment.len();
556            }
557        }
558        true
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use aube_registry::{Attestations, Dist, NpmUser};
566    use std::collections::BTreeMap;
567
568    fn version(name: &str, ver: &str) -> VersionMetadata {
569        VersionMetadata {
570            name: name.to_string(),
571            version: ver.to_string(),
572            dependencies: BTreeMap::new(),
573            dev_dependencies: BTreeMap::new(),
574            peer_dependencies: BTreeMap::new(),
575            peer_dependencies_meta: BTreeMap::new(),
576            optional_dependencies: BTreeMap::new(),
577            bundled_dependencies: None,
578            dist: Some(Dist {
579                tarball: format!("https://r/{name}/-/{name}-{ver}.tgz"),
580                integrity: None,
581                shasum: None,
582                unpacked_size: None,
583                attestations: None,
584            }),
585            os: vec![],
586            cpu: vec![],
587            libc: vec![],
588            engines: BTreeMap::new(),
589            license: None,
590            funding_url: None,
591            bin: BTreeMap::new(),
592            has_install_script: false,
593            deprecated: None,
594            approver: None,
595            npm_user: None,
596        }
597    }
598
599    fn with_provenance(mut v: VersionMetadata) -> VersionMetadata {
600        let dist = v.dist.as_mut().unwrap();
601        dist.attestations = Some(Attestations {
602            provenance: Some(serde_json::json!({
603                "predicateType": "https://slsa.dev/provenance/v1"
604            })),
605        });
606        v
607    }
608
609    fn with_trusted_publisher(mut v: VersionMetadata) -> VersionMetadata {
610        v.npm_user = Some(NpmUser {
611            trusted_publisher: Some(serde_json::json!({"id": "gh"})),
612        });
613        v
614    }
615
616    fn with_staged_publish(mut v: VersionMetadata) -> VersionMetadata {
617        v.approver = Some(serde_json::json!({"name": "release-manager"}));
618        v
619    }
620
621    fn packument(name: &str, versions: Vec<(&str, &str, VersionMetadata)>) -> Packument {
622        let mut p = Packument {
623            name: name.to_string(),
624            modified: None,
625            versions: BTreeMap::new(),
626            dist_tags: BTreeMap::new(),
627            time: BTreeMap::new(),
628        };
629        for (ver, time, meta) in versions {
630            p.versions.insert(ver.to_string(), meta);
631            p.time.insert(ver.to_string(), time.to_string());
632        }
633        p
634    }
635
636    #[test]
637    fn evidence_trusted_publisher_outranks_provenance() {
638        let v = with_trusted_publisher(with_provenance(version("foo", "1.0.0")));
639        assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
640    }
641
642    #[test]
643    fn evidence_staged_publish_outranks_trusted_publisher() {
644        let v = with_staged_publish(with_trusted_publisher(with_provenance(version(
645            "foo", "1.0.0",
646        ))));
647        assert_eq!(evidence_for(&v), Some(TrustEvidence::StagedPublish));
648    }
649
650    #[test]
651    fn evidence_provenance_only() {
652        let v = with_provenance(version("foo", "1.0.0"));
653        assert_eq!(evidence_for(&v), Some(TrustEvidence::Provenance));
654    }
655
656    #[test]
657    fn evidence_npm_user_without_trusted_publisher_is_none() {
658        let mut v = version("foo", "1.0.0");
659        v.npm_user = Some(NpmUser {
660            trusted_publisher: None,
661        });
662        assert_eq!(evidence_for(&v), None);
663    }
664
665    #[test]
666    fn evidence_malformed_trusted_publisher_is_none() {
667        let mut v = version("foo", "1.0.0");
668        for malformed in [
669            serde_json::Value::Bool(false),
670            serde_json::Value::Null,
671            serde_json::json!(0),
672            serde_json::json!(0.0),
673            serde_json::json!(""),
674            serde_json::json!([]),
675            serde_json::json!({}),
676            serde_json::json!({"id": ""}),
677        ] {
678            v.npm_user = Some(NpmUser {
679                trusted_publisher: Some(malformed.clone()),
680            });
681            assert_eq!(
682                evidence_for(&v),
683                None,
684                "{malformed:?} should not count as trusted-publisher evidence"
685            );
686        }
687    }
688
689    #[test]
690    fn evidence_empty_approver_is_none() {
691        let mut v = version("foo", "1.0.0");
692        for malformed in [
693            serde_json::Value::Bool(false),
694            serde_json::Value::Null,
695            serde_json::json!(0),
696            serde_json::json!(0.0),
697            serde_json::json!(""),
698            serde_json::json!([]),
699            serde_json::json!([null]),
700            serde_json::json!([false]),
701            serde_json::json!([""]),
702            serde_json::json!([[], {}]),
703            serde_json::json!({}),
704            serde_json::json!({"name": null}),
705            serde_json::json!({"name": null, "email": null}),
706            serde_json::json!({"name": ""}),
707            serde_json::json!({"nested": {}}),
708        ] {
709            v.approver = Some(malformed.clone());
710            assert_eq!(
711                evidence_for(&v),
712                None,
713                "{malformed:?} should not count as staged-publish evidence"
714            );
715        }
716    }
717
718    #[test]
719    fn evidence_truthy_scalar_approver_counts() {
720        let mut v = version("foo", "1.0.0");
721        for approver in [
722            serde_json::Value::Bool(true),
723            serde_json::json!(1),
724            serde_json::json!("release-manager"),
725            serde_json::json!(["release-manager"]),
726            serde_json::json!({"name": "release-manager"}),
727        ] {
728            v.approver = Some(approver.clone());
729            assert_eq!(
730                evidence_for(&v),
731                Some(TrustEvidence::StagedPublish),
732                "{approver:?} should count as staged-publish evidence"
733            );
734        }
735    }
736
737    #[test]
738    fn evidence_malformed_provenance_is_none() {
739        let mut v = version("foo", "1.0.0");
740        for malformed in [
741            serde_json::Value::Bool(false),
742            serde_json::Value::Null,
743            serde_json::json!(0),
744            serde_json::json!(""),
745            serde_json::json!([]),
746            serde_json::json!({}),
747            serde_json::json!({"predicateType": ""}),
748            serde_json::json!({"predicateType": "https://slsa.dev/provenance/"}),
749            serde_json::json!({"predicateType": "https://slsa.dev/provenance/v"}),
750            serde_json::json!({"predicateType": "https://slsa.dev/provenance/latest"}),
751            serde_json::json!({"predicateType": "https://example.com/provenance/v1"}),
752        ] {
753            v.dist.as_mut().unwrap().attestations = Some(Attestations {
754                provenance: Some(malformed.clone()),
755            });
756            assert_eq!(
757                evidence_for(&v),
758                None,
759                "{malformed:?} should not count as provenance evidence"
760            );
761        }
762    }
763
764    #[test]
765    fn evidence_structured_trusted_publisher_counts() {
766        let mut v = version("foo", "1.0.0");
767        v.npm_user = Some(NpmUser {
768            trusted_publisher: Some(serde_json::json!({
769                "id": "github",
770                "oidcConfigId": "oidc:example"
771            })),
772        });
773        assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
774    }
775
776    #[test]
777    fn evidence_none_when_neither() {
778        let v = version("foo", "1.0.0");
779        assert_eq!(evidence_for(&v), None);
780    }
781
782    #[test]
783    fn no_evidence_anywhere_passes() {
784        let p = packument(
785            "foo",
786            vec![
787                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
788                ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
789            ],
790        );
791        let picked = p.versions.get("2.0.0").unwrap();
792        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
793        assert!(result.is_ok());
794    }
795
796    #[test]
797    fn first_attested_version_passes() {
798        let p = packument(
799            "foo",
800            vec![
801                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
802                (
803                    "2.0.0",
804                    "2025-02-01T00:00:00.000Z",
805                    with_provenance(version("foo", "2.0.0")),
806                ),
807            ],
808        );
809        let picked = p.versions.get("1.0.0").unwrap();
810        let result = check_no_downgrade(&p, "1.0.0", picked, &TrustExcludeRules::default(), None);
811        assert!(
812            result.is_ok(),
813            "version 1.0.0 was published first; it has nothing prior to compare against"
814        );
815    }
816
817    #[test]
818    fn downgrade_provenance_to_none_fails() {
819        let p = packument(
820            "foo",
821            vec![
822                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
823                (
824                    "2.0.0",
825                    "2025-02-01T00:00:00.000Z",
826                    with_provenance(version("foo", "2.0.0")),
827                ),
828                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
829            ],
830        );
831        let picked = p.versions.get("3.0.0").unwrap();
832        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
833            .expect_err("3.0.0 should fail: prior version had provenance, this one has none");
834        match err {
835            TrustCheckError::Downgrade(d) => {
836                assert_eq!(d.prior_evidence, TrustEvidence::Provenance);
837                assert_eq!(d.prior_version, "2.0.0");
838                assert_eq!(d.current_evidence, None);
839            }
840            _ => panic!("expected Downgrade"),
841        }
842    }
843
844    #[test]
845    fn downgrade_trusted_publisher_to_provenance_fails() {
846        let p = packument(
847            "foo",
848            vec![
849                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
850                (
851                    "2.0.0",
852                    "2025-02-01T00:00:00.000Z",
853                    with_trusted_publisher(version("foo", "2.0.0")),
854                ),
855                (
856                    "3.0.0",
857                    "2025-03-01T00:00:00.000Z",
858                    with_provenance(version("foo", "3.0.0")),
859                ),
860            ],
861        );
862        let picked = p.versions.get("3.0.0").unwrap();
863        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
864            .expect_err("trustedPublisher → provenance is a downgrade");
865        match err {
866            TrustCheckError::Downgrade(d) => {
867                assert_eq!(d.prior_evidence, TrustEvidence::TrustedPublisher);
868                assert_eq!(d.current_evidence, Some(TrustEvidence::Provenance));
869            }
870            _ => panic!("expected Downgrade"),
871        }
872    }
873
874    #[test]
875    fn downgrade_staged_publish_to_trusted_publisher_fails() {
876        let p = packument(
877            "foo",
878            vec![
879                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
880                (
881                    "2.0.0",
882                    "2025-02-01T00:00:00.000Z",
883                    with_staged_publish(version("foo", "2.0.0")),
884                ),
885                (
886                    "3.0.0",
887                    "2025-03-01T00:00:00.000Z",
888                    with_trusted_publisher(version("foo", "3.0.0")),
889                ),
890            ],
891        );
892        let picked = p.versions.get("3.0.0").unwrap();
893        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
894            .expect_err("staged publish → trusted publisher is a downgrade");
895        match err {
896            TrustCheckError::Downgrade(d) => {
897                assert_eq!(d.prior_evidence, TrustEvidence::StagedPublish);
898                assert_eq!(d.prior_version, "2.0.0");
899                assert_eq!(d.current_evidence, Some(TrustEvidence::TrustedPublisher));
900            }
901            _ => panic!("expected Downgrade"),
902        }
903    }
904
905    #[test]
906    fn staged_publish_after_trusted_publisher_passes() {
907        let p = packument(
908            "foo",
909            vec![
910                (
911                    "1.0.0",
912                    "2025-01-01T00:00:00.000Z",
913                    with_trusted_publisher(version("foo", "1.0.0")),
914                ),
915                (
916                    "2.0.0",
917                    "2025-02-01T00:00:00.000Z",
918                    with_staged_publish(version("foo", "2.0.0")),
919                ),
920            ],
921        );
922        let picked = p.versions.get("2.0.0").unwrap();
923        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
924        assert!(result.is_ok());
925    }
926
927    #[test]
928    fn same_trust_level_passes() {
929        let p = packument(
930            "foo",
931            vec![
932                (
933                    "2.0.0",
934                    "2025-02-01T00:00:00.000Z",
935                    with_trusted_publisher(version("foo", "2.0.0")),
936                ),
937                (
938                    "3.0.0",
939                    "2025-03-01T00:00:00.000Z",
940                    with_trusted_publisher(version("foo", "3.0.0")),
941                ),
942            ],
943        );
944        let picked = p.versions.get("3.0.0").unwrap();
945        let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
946        assert!(result.is_ok());
947    }
948
949    #[test]
950    fn prior_prerelease_ignored_when_picking_stable() {
951        let p = packument(
952            "foo",
953            vec![
954                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
955                (
956                    "2.0.0-0",
957                    "2025-02-01T00:00:00.000Z",
958                    with_provenance(version("foo", "2.0.0-0")),
959                ),
960                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
961            ],
962        );
963        let picked = p.versions.get("3.0.0").unwrap();
964        let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
965        assert!(
966            result.is_ok(),
967            "trusted prerelease shouldn't block a stable that omits attestation"
968        );
969    }
970
971    #[test]
972    fn prior_prerelease_counts_when_picking_prerelease() {
973        let p = packument(
974            "foo",
975            vec![
976                (
977                    "2.0.0-0",
978                    "2025-02-01T00:00:00.000Z",
979                    with_provenance(version("foo", "2.0.0-0")),
980                ),
981                (
982                    "3.0.0-0",
983                    "2025-03-01T00:00:00.000Z",
984                    version("foo", "3.0.0-0"),
985                ),
986            ],
987        );
988        let picked = p.versions.get("3.0.0-0").unwrap();
989        let result = check_no_downgrade(&p, "3.0.0-0", picked, &TrustExcludeRules::default(), None);
990        assert!(
991            result.is_err(),
992            "prerelease pick should compare against prior prereleases"
993        );
994    }
995
996    /// Registries that don't publish `time` at all (Verdaccio without
997    /// the `--store-info` middleware, private mirrors that strip it,
998    /// old registry forks) must not break every install. Verified by
999    /// constructing a packument with versions but no `time` map.
1000    #[test]
1001    fn empty_time_map_skips_check() {
1002        let p = Packument {
1003            name: "foo".to_string(),
1004            modified: None,
1005            versions: {
1006                let mut m = BTreeMap::new();
1007                m.insert(
1008                    "1.0.0".to_string(),
1009                    with_provenance(version("foo", "1.0.0")),
1010                );
1011                m.insert("2.0.0".to_string(), version("foo", "2.0.0"));
1012                m
1013            },
1014            dist_tags: BTreeMap::new(),
1015            time: BTreeMap::new(), // Empty — registry doesn't ship time at all.
1016        };
1017        let picked = p.versions.get("2.0.0").unwrap();
1018        // Would normally be a downgrade (2.0.0 lost provenance), but
1019        // without `time` we can't establish chronology and degrade safely.
1020        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
1021        assert!(result.is_ok(), "empty time map should skip the check");
1022    }
1023
1024    #[test]
1025    fn missing_time_for_picked_version_errors() {
1026        let mut p = packument(
1027            "foo",
1028            vec![
1029                (
1030                    "1.0.0",
1031                    "2025-01-01T00:00:00.000Z",
1032                    with_provenance(version("foo", "1.0.0")),
1033                ),
1034                ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
1035            ],
1036        );
1037        // Drop the time entry for 2.0.0.
1038        p.time.remove("2.0.0");
1039        let picked = p.versions.get("2.0.0").unwrap();
1040        let err = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None)
1041            .expect_err("missing time should error");
1042        assert!(matches!(err, TrustCheckError::MissingTime(_)));
1043    }
1044
1045    #[test]
1046    fn exclude_name_at_version_bypasses_missing_time() {
1047        // No time field anywhere — would normally error.
1048        let p = Packument {
1049            name: "baz".to_string(),
1050            modified: None,
1051            versions: {
1052                let mut m = BTreeMap::new();
1053                m.insert("1.0.0".to_string(), version("baz", "1.0.0"));
1054                m
1055            },
1056            dist_tags: BTreeMap::new(),
1057            time: BTreeMap::new(),
1058        };
1059        let picked = p.versions.get("1.0.0").unwrap();
1060        let exclude = TrustExcludeRules::parse(["baz@1.0.0"]).unwrap();
1061        let result = check_no_downgrade(&p, "1.0.0", picked, &exclude, None);
1062        assert!(result.is_ok(), "excluded version must skip the time lookup");
1063    }
1064
1065    #[test]
1066    fn exclude_name_only_bypasses_missing_time() {
1067        let p = Packument {
1068            name: "qux".to_string(),
1069            modified: None,
1070            versions: {
1071                let mut m = BTreeMap::new();
1072                m.insert("2.0.0".to_string(), version("qux", "2.0.0"));
1073                m
1074            },
1075            dist_tags: BTreeMap::new(),
1076            time: BTreeMap::new(),
1077        };
1078        let picked = p.versions.get("2.0.0").unwrap();
1079        let exclude = TrustExcludeRules::parse(["qux"]).unwrap();
1080        let result = check_no_downgrade(&p, "2.0.0", picked, &exclude, None);
1081        assert!(result.is_ok());
1082    }
1083
1084    #[test]
1085    fn exclude_blocks_downgrade_failure() {
1086        let p = packument(
1087            "foo",
1088            vec![
1089                (
1090                    "2.0.0",
1091                    "2025-02-01T00:00:00.000Z",
1092                    with_provenance(version("foo", "2.0.0")),
1093                ),
1094                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1095            ],
1096        );
1097        let picked = p.versions.get("3.0.0").unwrap();
1098        let exclude = TrustExcludeRules::parse(["foo@3.0.0"]).unwrap();
1099        let result = check_no_downgrade(&p, "3.0.0", picked, &exclude, None);
1100        assert!(result.is_ok(), "exclude should bypass the downgrade");
1101    }
1102
1103    #[test]
1104    fn ignore_after_skips_old_versions() {
1105        let p = packument(
1106            "foo",
1107            vec![
1108                (
1109                    "2.0.0",
1110                    "2025-02-01T00:00:00.000Z",
1111                    with_provenance(version("foo", "2.0.0")),
1112                ),
1113                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1114            ],
1115        );
1116        let picked = p.versions.get("3.0.0").unwrap();
1117        // 1 minute cutoff — both versions are way older, should skip.
1118        let result =
1119            check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), Some(1));
1120        assert!(result.is_ok());
1121    }
1122
1123    // ---------- TrustExcludeRules parsing ----------
1124
1125    #[test]
1126    fn exclude_parses_name_only() {
1127        let r = TrustExcludeRules::parse(["foo"]).unwrap();
1128        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1129        assert!(r.matches("foo", &node_semver::Version::parse("99.0.0").unwrap()));
1130        assert!(!r.matches("bar", &node_semver::Version::parse("1.0.0").unwrap()));
1131    }
1132
1133    #[test]
1134    fn default_excludes_known_provenance_churn_packages() {
1135        let r = TrustExcludeRules::default();
1136        // Every entry parses into exactly one rule — a malformed default
1137        // would otherwise silently drop protection or panic at first use.
1138        assert_eq!(r.len(), DEFAULT_TRUST_POLICY_EXCLUDES.len());
1139        for package in DEFAULT_TRUST_POLICY_EXCLUDES {
1140            let (name, versions) = split_name_and_versions(package);
1141            // Bare-name entries exempt every version. Version-scoped entries
1142            // deliberately do not, so they get their own targeted tests.
1143            if versions.is_some() {
1144                continue;
1145            }
1146            assert!(
1147                r.matches(name, &node_semver::Version::parse("1.0.0").unwrap()),
1148                "{name} should be globally excluded"
1149            );
1150        }
1151        assert!(!r.matches("left-pad", &node_semver::Version::parse("1.0.0").unwrap()));
1152    }
1153
1154    #[test]
1155    fn default_excludes_scoped_octokit_endpoint_backport() {
1156        // Regression: @octokit backports fixes to older major lines without
1157        // provenance, so a legitimate older release (e.g. 9.0.6) published
1158        // after an attested newer major (10.1.0) tripped no-downgrade. The
1159        // scoped name must match every version, and the leading `@` must not
1160        // be misparsed as a version separator.
1161        let r = TrustExcludeRules::default();
1162        assert!(r.matches(
1163            "@octokit/endpoint",
1164            &node_semver::Version::parse("9.0.6").unwrap()
1165        ));
1166        assert!(r.matches(
1167            "@octokit/endpoint",
1168            &node_semver::Version::parse("10.1.0").unwrap()
1169        ));
1170        assert!(!r.matches(
1171            "@octokit/core",
1172            &node_semver::Version::parse("9.0.6").unwrap()
1173        ));
1174    }
1175
1176    #[test]
1177    fn default_excludes_scoped_hono_node_server_backport() {
1178        // Regression: @hono/node-server publishes 2.x from CI with provenance
1179        // but hand-publishes 1.x backports without it, so 1.19.15 (released
1180        // after the attested 2.0.10) tripped no-downgrade.
1181        let r = TrustExcludeRules::default();
1182        assert!(r.matches(
1183            "@hono/node-server",
1184            &node_semver::Version::parse("1.19.15").unwrap()
1185        ));
1186        // Every version, not just the 1.x line — the exclusion is
1187        // intentionally package-wide (see DEFAULT_TRUST_POLICY_EXCLUDES).
1188        assert!(r.matches(
1189            "@hono/node-server",
1190            &node_semver::Version::parse("2.0.10").unwrap()
1191        ));
1192        assert!(r.matches(
1193            "@hono/node-server",
1194            &node_semver::Version::parse("2.1.0").unwrap()
1195        ));
1196        assert!(!r.matches("hono", &node_semver::Version::parse("1.19.15").unwrap()));
1197    }
1198
1199    #[test]
1200    fn exclude_parses_name_at_version() {
1201        let r = TrustExcludeRules::parse(["foo@1.0.0"]).unwrap();
1202        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1203        assert!(!r.matches("foo", &node_semver::Version::parse("1.0.1").unwrap()));
1204    }
1205
1206    #[test]
1207    fn exclude_parses_version_union() {
1208        let r = TrustExcludeRules::parse(["foo@1.0.0 || 2.0.0 || 3.0.0"]).unwrap();
1209        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1210        assert!(r.matches("foo", &node_semver::Version::parse("2.0.0").unwrap()));
1211        assert!(r.matches("foo", &node_semver::Version::parse("3.0.0").unwrap()));
1212        assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1213    }
1214
1215    #[test]
1216    fn exclude_parses_scoped_name() {
1217        let r = TrustExcludeRules::parse(["@babel/core@7.20.0"]).unwrap();
1218        assert!(r.matches(
1219            "@babel/core",
1220            &node_semver::Version::parse("7.20.0").unwrap()
1221        ));
1222        assert!(!r.matches(
1223            "@babel/core",
1224            &node_semver::Version::parse("7.20.1").unwrap()
1225        ));
1226    }
1227
1228    #[test]
1229    fn exclude_parses_scoped_name_only() {
1230        let r = TrustExcludeRules::parse(["@babel/core"]).unwrap();
1231        assert!(r.matches(
1232            "@babel/core",
1233            &node_semver::Version::parse("9.9.9").unwrap()
1234        ));
1235    }
1236
1237    #[test]
1238    fn exclude_parses_glob() {
1239        let r = TrustExcludeRules::parse(["is-*"]).unwrap();
1240        assert!(r.matches("is-odd", &node_semver::Version::parse("1.0.0").unwrap()));
1241        assert!(r.matches("is-even", &node_semver::Version::parse("1.0.0").unwrap()));
1242        assert!(!r.matches("lodash", &node_semver::Version::parse("1.0.0").unwrap()));
1243    }
1244
1245    #[test]
1246    fn exclude_parses_star_matches_all() {
1247        let r = TrustExcludeRules::parse(["*"]).unwrap();
1248        assert!(r.matches("anything", &node_semver::Version::parse("0.0.1").unwrap()));
1249    }
1250
1251    #[test]
1252    fn exclude_parses_version_ranges() {
1253        let r = TrustExcludeRules::parse(["foo@^1.0.0 || ~2.1.0 || >=3.0.0 <4.0.0"]).unwrap();
1254        assert!(r.matches("foo", &node_semver::Version::parse("1.2.3").unwrap()));
1255        assert!(r.matches("foo", &node_semver::Version::parse("2.1.9").unwrap()));
1256        assert!(r.matches("foo", &node_semver::Version::parse("3.5.0").unwrap()));
1257        assert!(!r.matches("foo", &node_semver::Version::parse("2.2.0").unwrap()));
1258        assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1259    }
1260
1261    #[test]
1262    fn exclude_rejects_invalid_version_ranges() {
1263        let err = TrustExcludeRules::parse(["foo@definitely-not-a-range"]).expect_err("bad range");
1264        assert!(matches!(
1265            err,
1266            TrustExcludeParseError::InvalidVersionUnion { .. }
1267        ));
1268    }
1269
1270    #[test]
1271    fn exclude_rejects_glob_with_version() {
1272        let err = TrustExcludeRules::parse(["is-*@1.0.0"]).expect_err("glob+version");
1273        assert!(matches!(
1274            err,
1275            TrustExcludeParseError::NameGlobWithVersions { .. }
1276        ));
1277    }
1278
1279    #[test]
1280    fn parse_lossy_keeps_valid_drops_invalid() {
1281        let (rules, errors) = TrustExcludeRules::parse_lossy([
1282            "good",
1283            "bad@definitely-not-a-range",
1284            "@scope/also-good@1.0.0",
1285            "is-*@nope",
1286        ]);
1287        // Two valid rules survive; two invalid surface as separate errors.
1288        assert!(rules.matches("good", &node_semver::Version::parse("1.0.0").unwrap()));
1289        assert!(rules.matches(
1290            "@scope/also-good",
1291            &node_semver::Version::parse("1.0.0").unwrap()
1292        ));
1293        assert_eq!(errors.len(), 2, "two malformed entries reported");
1294    }
1295
1296    #[test]
1297    fn exclude_skips_empty_patterns() {
1298        // npm config arrays sometimes include empty entries; ignore them.
1299        let r = TrustExcludeRules::parse(["", "foo", ""]).unwrap();
1300        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1301    }
1302}