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    "chokidar",
261    "eslint-config-prettier",
262    "eslint-import-resolver-typescript",
263    "react-redux",
264    "reselect",
265    "semver",
266    "ua-parser-js",
267    "undici",
268    "undici-types",
269    "vite",
270];
271
272/// A parsed package-version policy: a set of `<name>[@<semver-range>…]`
273/// rules with `*` name globs. pnpm backs both `trustPolicyExclude` and
274/// `minimumReleaseAgeExclude` with the same `createPackageVersionPolicy`
275/// engine, so we do too — `TrustExcludeRules` is the neutral
276/// [`PackageVersionPolicy`] type seeded with the trust defaults, while
277/// `minimumReleaseAgeExclude` builds an empty one from user rules only.
278///
279/// # Warning
280///
281/// Because this is an alias for [`TrustExcludeRules`],
282/// `PackageVersionPolicy::default()` runs that type's [`Default`] impl,
283/// which seeds [`DEFAULT_TRUST_POLICY_EXCLUDES`] (40+ well-known
284/// packages). For the age gate that is wrong — it would silently exempt
285/// those packages from `minimumReleaseAge`. Age-gate call sites must use
286/// [`TrustExcludeRules::empty`]; never `default()`, `#[derive(Default)]`
287/// on a containing struct, or `unwrap_or_default()`.
288pub type PackageVersionPolicy = TrustExcludeRules;
289
290#[derive(Debug, Clone)]
291pub struct TrustExcludeRules {
292    rules: Vec<TrustExcludeRule>,
293}
294
295impl Default for TrustExcludeRules {
296    fn default() -> Self {
297        Self::from_name_excludes(DEFAULT_TRUST_POLICY_EXCLUDES)
298    }
299}
300
301#[derive(Debug, Clone)]
302struct TrustExcludeRule {
303    name_matcher: NameMatcher,
304    /// `None` → rule matches every version of any name match.
305    /// `Some(ranges)` → rule matches any version satisfying one range.
306    version_ranges: Option<Vec<node_semver::Range>>,
307}
308
309#[derive(Debug, Clone)]
310enum NameMatcher {
311    Exact(String),
312    Glob(GlobMatcher),
313    Any,
314}
315
316#[derive(Debug, Clone)]
317struct GlobMatcher {
318    parts: Vec<String>,
319    leading_wildcard: bool,
320    trailing_wildcard: bool,
321}
322
323// Shared by both `trustPolicyExclude` and `minimumReleaseAgeExclude`, so
324// the message text stays setting-neutral — the caller's log line names
325// the specific setting the bad entry came from.
326#[derive(Debug, thiserror::Error, miette::Diagnostic)]
327pub enum TrustExcludeParseError {
328    #[error("invalid exclude pattern `{pattern}`: version selectors must be valid semver ranges")]
329    #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_INVALID_VERSION_UNION))]
330    InvalidVersionUnion { pattern: String },
331    #[error(
332        "invalid exclude pattern `{pattern}`: name patterns (`*`) cannot be combined with version unions"
333    )]
334    #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_NAME_GLOB_WITH_VERSIONS))]
335    NameGlobWithVersions { pattern: String },
336}
337
338impl TrustExcludeRules {
339    /// A policy that matches nothing. Used as the
340    /// `minimumReleaseAgeExclude` default — unlike [`Default`], which
341    /// seeds the trust-specific exclude list, the age gate must start
342    /// with no exemptions.
343    pub fn empty() -> Self {
344        Self { rules: Vec::new() }
345    }
346
347    pub fn is_empty(&self) -> bool {
348        self.rules.is_empty()
349    }
350
351    pub fn len(&self) -> usize {
352        self.rules.len()
353    }
354
355    fn from_name_excludes(names: &[&str]) -> Self {
356        Self {
357            rules: names
358                .iter()
359                .map(|name| TrustExcludeRule {
360                    name_matcher: NameMatcher::compile(name),
361                    version_ranges: None,
362                })
363                .collect(),
364        }
365    }
366
367    pub fn with_defaults_and_user_rules(user_rules: Self) -> Self {
368        let mut rules = Self::default();
369        rules.rules.extend(user_rules.rules);
370        rules
371    }
372
373    pub fn parse<I, S>(patterns: I) -> Result<Self, TrustExcludeParseError>
374    where
375        I: IntoIterator<Item = S>,
376        S: AsRef<str>,
377    {
378        let mut rules = Vec::new();
379        for pattern in patterns {
380            let pattern = pattern.as_ref();
381            if pattern.is_empty() {
382                continue;
383            }
384            rules.push(parse_one(pattern)?);
385        }
386        Ok(Self { rules })
387    }
388
389    /// Parse a list of patterns, keeping every rule that succeeds and
390    /// returning the per-pattern errors for everything that didn't.
391    /// Lets the caller log malformed entries individually without
392    /// dropping the rules that did parse — a strict batch `parse` would
393    /// turn one typo into a silent security regression where every
394    /// exclude vanishes.
395    pub fn parse_lossy<I, S>(patterns: I) -> (Self, Vec<TrustExcludeParseError>)
396    where
397        I: IntoIterator<Item = S>,
398        S: AsRef<str>,
399    {
400        let mut rules = Vec::new();
401        let mut errors = Vec::new();
402        for pattern in patterns {
403            let pattern = pattern.as_ref();
404            if pattern.is_empty() {
405                continue;
406            }
407            match parse_one(pattern) {
408                Ok(rule) => rules.push(rule),
409                Err(err) => errors.push(err),
410            }
411        }
412        (Self { rules }, errors)
413    }
414
415    pub(crate) fn matches(&self, name: &str, version: &node_semver::Version) -> bool {
416        for rule in &self.rules {
417            if !rule.name_matcher.matches(name) {
418                continue;
419            }
420            match &rule.version_ranges {
421                None => return true,
422                Some(ranges) => {
423                    if ranges.iter().any(|r| version.satisfies(r)) {
424                        return true;
425                    }
426                }
427            }
428        }
429        false
430    }
431
432    /// Used when the picked version string fails semver parse — only a
433    /// no-version rule can match in that case (pnpm behavior:
434    /// `evaluateVersionPolicy` returns `true` for name-only rules
435    /// before the version array branch is taken).
436    pub(crate) fn matches_name_only(&self, name: &str) -> bool {
437        self.rules
438            .iter()
439            .any(|r| r.version_ranges.is_none() && r.name_matcher.matches(name))
440    }
441}
442
443fn parse_one(pattern: &str) -> Result<TrustExcludeRule, TrustExcludeParseError> {
444    let scoped = pattern.starts_with('@');
445    let at_index = if scoped {
446        pattern[1..].find('@').map(|i| i + 1)
447    } else {
448        pattern.find('@')
449    };
450
451    let (name_part, versions_part) = match at_index {
452        Some(i) => (&pattern[..i], Some(&pattern[i + 1..])),
453        None => (pattern, None),
454    };
455
456    let version_ranges = match versions_part {
457        None => None,
458        Some(versions_str) => {
459            if name_part.contains('*') {
460                return Err(TrustExcludeParseError::NameGlobWithVersions {
461                    pattern: pattern.to_string(),
462                });
463            }
464            let mut parsed = Vec::new();
465            for chunk in versions_str.split("||") {
466                let trimmed = chunk.trim();
467                if trimmed.is_empty() {
468                    return Err(TrustExcludeParseError::InvalidVersionUnion {
469                        pattern: pattern.to_string(),
470                    });
471                }
472                let r = node_semver::Range::parse(trimmed).map_err(|_| {
473                    TrustExcludeParseError::InvalidVersionUnion {
474                        pattern: pattern.to_string(),
475                    }
476                })?;
477                parsed.push(r);
478            }
479            Some(parsed)
480        }
481    };
482
483    Ok(TrustExcludeRule {
484        name_matcher: NameMatcher::compile(name_part),
485        version_ranges,
486    })
487}
488
489impl NameMatcher {
490    fn compile(pattern: &str) -> Self {
491        if pattern == "*" {
492            return Self::Any;
493        }
494        if !pattern.contains('*') {
495            return Self::Exact(pattern.to_string());
496        }
497        let parts: Vec<String> = pattern.split('*').map(str::to_string).collect();
498        Self::Glob(GlobMatcher {
499            leading_wildcard: parts.first().is_some_and(String::is_empty),
500            trailing_wildcard: parts.last().is_some_and(String::is_empty),
501            parts: parts.into_iter().filter(|s| !s.is_empty()).collect(),
502        })
503    }
504
505    fn matches(&self, input: &str) -> bool {
506        match self {
507            Self::Any => true,
508            Self::Exact(s) => s == input,
509            Self::Glob(g) => g.matches(input),
510        }
511    }
512}
513
514impl GlobMatcher {
515    fn matches(&self, input: &str) -> bool {
516        if self.parts.is_empty() {
517            return true;
518        }
519        let mut cursor = 0usize;
520        for (i, segment) in self.parts.iter().enumerate() {
521            let search_window = &input[cursor..];
522            let is_first = i == 0;
523            let is_last = i == self.parts.len() - 1;
524            if is_first && !self.leading_wildcard {
525                if !search_window.starts_with(segment.as_str()) {
526                    return false;
527                }
528                cursor += segment.len();
529            } else if is_last && !self.trailing_wildcard {
530                if !search_window.ends_with(segment.as_str()) {
531                    return false;
532                }
533                if search_window.len() < segment.len() {
534                    return false;
535                }
536                cursor = input.len();
537            } else {
538                let Some(idx) = search_window.find(segment.as_str()) else {
539                    return false;
540                };
541                cursor += idx + segment.len();
542            }
543        }
544        true
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use aube_registry::{Attestations, Dist, NpmUser};
552    use std::collections::BTreeMap;
553
554    fn version(name: &str, ver: &str) -> VersionMetadata {
555        VersionMetadata {
556            name: name.to_string(),
557            version: ver.to_string(),
558            dependencies: BTreeMap::new(),
559            dev_dependencies: BTreeMap::new(),
560            peer_dependencies: BTreeMap::new(),
561            peer_dependencies_meta: BTreeMap::new(),
562            optional_dependencies: BTreeMap::new(),
563            bundled_dependencies: None,
564            dist: Some(Dist {
565                tarball: format!("https://r/{name}/-/{name}-{ver}.tgz"),
566                integrity: None,
567                shasum: None,
568                unpacked_size: None,
569                attestations: None,
570            }),
571            os: vec![],
572            cpu: vec![],
573            libc: vec![],
574            engines: BTreeMap::new(),
575            license: None,
576            funding_url: None,
577            bin: BTreeMap::new(),
578            has_install_script: false,
579            deprecated: None,
580            approver: None,
581            npm_user: None,
582        }
583    }
584
585    fn with_provenance(mut v: VersionMetadata) -> VersionMetadata {
586        let dist = v.dist.as_mut().unwrap();
587        dist.attestations = Some(Attestations {
588            provenance: Some(serde_json::json!({
589                "predicateType": "https://slsa.dev/provenance/v1"
590            })),
591        });
592        v
593    }
594
595    fn with_trusted_publisher(mut v: VersionMetadata) -> VersionMetadata {
596        v.npm_user = Some(NpmUser {
597            trusted_publisher: Some(serde_json::json!({"id": "gh"})),
598        });
599        v
600    }
601
602    fn with_staged_publish(mut v: VersionMetadata) -> VersionMetadata {
603        v.approver = Some(serde_json::json!({"name": "release-manager"}));
604        v
605    }
606
607    fn packument(name: &str, versions: Vec<(&str, &str, VersionMetadata)>) -> Packument {
608        let mut p = Packument {
609            name: name.to_string(),
610            modified: None,
611            versions: BTreeMap::new(),
612            dist_tags: BTreeMap::new(),
613            time: BTreeMap::new(),
614        };
615        for (ver, time, meta) in versions {
616            p.versions.insert(ver.to_string(), meta);
617            p.time.insert(ver.to_string(), time.to_string());
618        }
619        p
620    }
621
622    #[test]
623    fn evidence_trusted_publisher_outranks_provenance() {
624        let v = with_trusted_publisher(with_provenance(version("foo", "1.0.0")));
625        assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
626    }
627
628    #[test]
629    fn evidence_staged_publish_outranks_trusted_publisher() {
630        let v = with_staged_publish(with_trusted_publisher(with_provenance(version(
631            "foo", "1.0.0",
632        ))));
633        assert_eq!(evidence_for(&v), Some(TrustEvidence::StagedPublish));
634    }
635
636    #[test]
637    fn evidence_provenance_only() {
638        let v = with_provenance(version("foo", "1.0.0"));
639        assert_eq!(evidence_for(&v), Some(TrustEvidence::Provenance));
640    }
641
642    #[test]
643    fn evidence_npm_user_without_trusted_publisher_is_none() {
644        let mut v = version("foo", "1.0.0");
645        v.npm_user = Some(NpmUser {
646            trusted_publisher: None,
647        });
648        assert_eq!(evidence_for(&v), None);
649    }
650
651    #[test]
652    fn evidence_malformed_trusted_publisher_is_none() {
653        let mut v = version("foo", "1.0.0");
654        for malformed in [
655            serde_json::Value::Bool(false),
656            serde_json::Value::Null,
657            serde_json::json!(0),
658            serde_json::json!(0.0),
659            serde_json::json!(""),
660            serde_json::json!([]),
661            serde_json::json!({}),
662            serde_json::json!({"id": ""}),
663        ] {
664            v.npm_user = Some(NpmUser {
665                trusted_publisher: Some(malformed.clone()),
666            });
667            assert_eq!(
668                evidence_for(&v),
669                None,
670                "{malformed:?} should not count as trusted-publisher evidence"
671            );
672        }
673    }
674
675    #[test]
676    fn evidence_empty_approver_is_none() {
677        let mut v = version("foo", "1.0.0");
678        for malformed in [
679            serde_json::Value::Bool(false),
680            serde_json::Value::Null,
681            serde_json::json!(0),
682            serde_json::json!(0.0),
683            serde_json::json!(""),
684            serde_json::json!([]),
685            serde_json::json!([null]),
686            serde_json::json!([false]),
687            serde_json::json!([""]),
688            serde_json::json!([[], {}]),
689            serde_json::json!({}),
690            serde_json::json!({"name": null}),
691            serde_json::json!({"name": null, "email": null}),
692            serde_json::json!({"name": ""}),
693            serde_json::json!({"nested": {}}),
694        ] {
695            v.approver = Some(malformed.clone());
696            assert_eq!(
697                evidence_for(&v),
698                None,
699                "{malformed:?} should not count as staged-publish evidence"
700            );
701        }
702    }
703
704    #[test]
705    fn evidence_truthy_scalar_approver_counts() {
706        let mut v = version("foo", "1.0.0");
707        for approver in [
708            serde_json::Value::Bool(true),
709            serde_json::json!(1),
710            serde_json::json!("release-manager"),
711            serde_json::json!(["release-manager"]),
712            serde_json::json!({"name": "release-manager"}),
713        ] {
714            v.approver = Some(approver.clone());
715            assert_eq!(
716                evidence_for(&v),
717                Some(TrustEvidence::StagedPublish),
718                "{approver:?} should count as staged-publish evidence"
719            );
720        }
721    }
722
723    #[test]
724    fn evidence_malformed_provenance_is_none() {
725        let mut v = version("foo", "1.0.0");
726        for malformed in [
727            serde_json::Value::Bool(false),
728            serde_json::Value::Null,
729            serde_json::json!(0),
730            serde_json::json!(""),
731            serde_json::json!([]),
732            serde_json::json!({}),
733            serde_json::json!({"predicateType": ""}),
734            serde_json::json!({"predicateType": "https://slsa.dev/provenance/"}),
735            serde_json::json!({"predicateType": "https://slsa.dev/provenance/v"}),
736            serde_json::json!({"predicateType": "https://slsa.dev/provenance/latest"}),
737            serde_json::json!({"predicateType": "https://example.com/provenance/v1"}),
738        ] {
739            v.dist.as_mut().unwrap().attestations = Some(Attestations {
740                provenance: Some(malformed.clone()),
741            });
742            assert_eq!(
743                evidence_for(&v),
744                None,
745                "{malformed:?} should not count as provenance evidence"
746            );
747        }
748    }
749
750    #[test]
751    fn evidence_structured_trusted_publisher_counts() {
752        let mut v = version("foo", "1.0.0");
753        v.npm_user = Some(NpmUser {
754            trusted_publisher: Some(serde_json::json!({
755                "id": "github",
756                "oidcConfigId": "oidc:example"
757            })),
758        });
759        assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
760    }
761
762    #[test]
763    fn evidence_none_when_neither() {
764        let v = version("foo", "1.0.0");
765        assert_eq!(evidence_for(&v), None);
766    }
767
768    #[test]
769    fn no_evidence_anywhere_passes() {
770        let p = packument(
771            "foo",
772            vec![
773                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
774                ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
775            ],
776        );
777        let picked = p.versions.get("2.0.0").unwrap();
778        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
779        assert!(result.is_ok());
780    }
781
782    #[test]
783    fn first_attested_version_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                (
789                    "2.0.0",
790                    "2025-02-01T00:00:00.000Z",
791                    with_provenance(version("foo", "2.0.0")),
792                ),
793            ],
794        );
795        let picked = p.versions.get("1.0.0").unwrap();
796        let result = check_no_downgrade(&p, "1.0.0", picked, &TrustExcludeRules::default(), None);
797        assert!(
798            result.is_ok(),
799            "version 1.0.0 was published first; it has nothing prior to compare against"
800        );
801    }
802
803    #[test]
804    fn downgrade_provenance_to_none_fails() {
805        let p = packument(
806            "foo",
807            vec![
808                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
809                (
810                    "2.0.0",
811                    "2025-02-01T00:00:00.000Z",
812                    with_provenance(version("foo", "2.0.0")),
813                ),
814                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
815            ],
816        );
817        let picked = p.versions.get("3.0.0").unwrap();
818        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
819            .expect_err("3.0.0 should fail: prior version had provenance, this one has none");
820        match err {
821            TrustCheckError::Downgrade(d) => {
822                assert_eq!(d.prior_evidence, TrustEvidence::Provenance);
823                assert_eq!(d.prior_version, "2.0.0");
824                assert_eq!(d.current_evidence, None);
825            }
826            _ => panic!("expected Downgrade"),
827        }
828    }
829
830    #[test]
831    fn downgrade_trusted_publisher_to_provenance_fails() {
832        let p = packument(
833            "foo",
834            vec![
835                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
836                (
837                    "2.0.0",
838                    "2025-02-01T00:00:00.000Z",
839                    with_trusted_publisher(version("foo", "2.0.0")),
840                ),
841                (
842                    "3.0.0",
843                    "2025-03-01T00:00:00.000Z",
844                    with_provenance(version("foo", "3.0.0")),
845                ),
846            ],
847        );
848        let picked = p.versions.get("3.0.0").unwrap();
849        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
850            .expect_err("trustedPublisher → provenance is a downgrade");
851        match err {
852            TrustCheckError::Downgrade(d) => {
853                assert_eq!(d.prior_evidence, TrustEvidence::TrustedPublisher);
854                assert_eq!(d.current_evidence, Some(TrustEvidence::Provenance));
855            }
856            _ => panic!("expected Downgrade"),
857        }
858    }
859
860    #[test]
861    fn downgrade_staged_publish_to_trusted_publisher_fails() {
862        let p = packument(
863            "foo",
864            vec![
865                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
866                (
867                    "2.0.0",
868                    "2025-02-01T00:00:00.000Z",
869                    with_staged_publish(version("foo", "2.0.0")),
870                ),
871                (
872                    "3.0.0",
873                    "2025-03-01T00:00:00.000Z",
874                    with_trusted_publisher(version("foo", "3.0.0")),
875                ),
876            ],
877        );
878        let picked = p.versions.get("3.0.0").unwrap();
879        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
880            .expect_err("staged publish → trusted publisher is a downgrade");
881        match err {
882            TrustCheckError::Downgrade(d) => {
883                assert_eq!(d.prior_evidence, TrustEvidence::StagedPublish);
884                assert_eq!(d.prior_version, "2.0.0");
885                assert_eq!(d.current_evidence, Some(TrustEvidence::TrustedPublisher));
886            }
887            _ => panic!("expected Downgrade"),
888        }
889    }
890
891    #[test]
892    fn staged_publish_after_trusted_publisher_passes() {
893        let p = packument(
894            "foo",
895            vec![
896                (
897                    "1.0.0",
898                    "2025-01-01T00:00:00.000Z",
899                    with_trusted_publisher(version("foo", "1.0.0")),
900                ),
901                (
902                    "2.0.0",
903                    "2025-02-01T00:00:00.000Z",
904                    with_staged_publish(version("foo", "2.0.0")),
905                ),
906            ],
907        );
908        let picked = p.versions.get("2.0.0").unwrap();
909        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
910        assert!(result.is_ok());
911    }
912
913    #[test]
914    fn same_trust_level_passes() {
915        let p = packument(
916            "foo",
917            vec![
918                (
919                    "2.0.0",
920                    "2025-02-01T00:00:00.000Z",
921                    with_trusted_publisher(version("foo", "2.0.0")),
922                ),
923                (
924                    "3.0.0",
925                    "2025-03-01T00:00:00.000Z",
926                    with_trusted_publisher(version("foo", "3.0.0")),
927                ),
928            ],
929        );
930        let picked = p.versions.get("3.0.0").unwrap();
931        let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
932        assert!(result.is_ok());
933    }
934
935    #[test]
936    fn prior_prerelease_ignored_when_picking_stable() {
937        let p = packument(
938            "foo",
939            vec![
940                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
941                (
942                    "2.0.0-0",
943                    "2025-02-01T00:00:00.000Z",
944                    with_provenance(version("foo", "2.0.0-0")),
945                ),
946                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
947            ],
948        );
949        let picked = p.versions.get("3.0.0").unwrap();
950        let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
951        assert!(
952            result.is_ok(),
953            "trusted prerelease shouldn't block a stable that omits attestation"
954        );
955    }
956
957    #[test]
958    fn prior_prerelease_counts_when_picking_prerelease() {
959        let p = packument(
960            "foo",
961            vec![
962                (
963                    "2.0.0-0",
964                    "2025-02-01T00:00:00.000Z",
965                    with_provenance(version("foo", "2.0.0-0")),
966                ),
967                (
968                    "3.0.0-0",
969                    "2025-03-01T00:00:00.000Z",
970                    version("foo", "3.0.0-0"),
971                ),
972            ],
973        );
974        let picked = p.versions.get("3.0.0-0").unwrap();
975        let result = check_no_downgrade(&p, "3.0.0-0", picked, &TrustExcludeRules::default(), None);
976        assert!(
977            result.is_err(),
978            "prerelease pick should compare against prior prereleases"
979        );
980    }
981
982    /// Registries that don't publish `time` at all (Verdaccio without
983    /// the `--store-info` middleware, private mirrors that strip it,
984    /// old registry forks) must not break every install. Verified by
985    /// constructing a packument with versions but no `time` map.
986    #[test]
987    fn empty_time_map_skips_check() {
988        let p = Packument {
989            name: "foo".to_string(),
990            modified: None,
991            versions: {
992                let mut m = BTreeMap::new();
993                m.insert(
994                    "1.0.0".to_string(),
995                    with_provenance(version("foo", "1.0.0")),
996                );
997                m.insert("2.0.0".to_string(), version("foo", "2.0.0"));
998                m
999            },
1000            dist_tags: BTreeMap::new(),
1001            time: BTreeMap::new(), // Empty — registry doesn't ship time at all.
1002        };
1003        let picked = p.versions.get("2.0.0").unwrap();
1004        // Would normally be a downgrade (2.0.0 lost provenance), but
1005        // without `time` we can't establish chronology and degrade safely.
1006        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
1007        assert!(result.is_ok(), "empty time map should skip the check");
1008    }
1009
1010    #[test]
1011    fn missing_time_for_picked_version_errors() {
1012        let mut p = packument(
1013            "foo",
1014            vec![
1015                (
1016                    "1.0.0",
1017                    "2025-01-01T00:00:00.000Z",
1018                    with_provenance(version("foo", "1.0.0")),
1019                ),
1020                ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
1021            ],
1022        );
1023        // Drop the time entry for 2.0.0.
1024        p.time.remove("2.0.0");
1025        let picked = p.versions.get("2.0.0").unwrap();
1026        let err = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None)
1027            .expect_err("missing time should error");
1028        assert!(matches!(err, TrustCheckError::MissingTime(_)));
1029    }
1030
1031    #[test]
1032    fn exclude_name_at_version_bypasses_missing_time() {
1033        // No time field anywhere — would normally error.
1034        let p = Packument {
1035            name: "baz".to_string(),
1036            modified: None,
1037            versions: {
1038                let mut m = BTreeMap::new();
1039                m.insert("1.0.0".to_string(), version("baz", "1.0.0"));
1040                m
1041            },
1042            dist_tags: BTreeMap::new(),
1043            time: BTreeMap::new(),
1044        };
1045        let picked = p.versions.get("1.0.0").unwrap();
1046        let exclude = TrustExcludeRules::parse(["baz@1.0.0"]).unwrap();
1047        let result = check_no_downgrade(&p, "1.0.0", picked, &exclude, None);
1048        assert!(result.is_ok(), "excluded version must skip the time lookup");
1049    }
1050
1051    #[test]
1052    fn exclude_name_only_bypasses_missing_time() {
1053        let p = Packument {
1054            name: "qux".to_string(),
1055            modified: None,
1056            versions: {
1057                let mut m = BTreeMap::new();
1058                m.insert("2.0.0".to_string(), version("qux", "2.0.0"));
1059                m
1060            },
1061            dist_tags: BTreeMap::new(),
1062            time: BTreeMap::new(),
1063        };
1064        let picked = p.versions.get("2.0.0").unwrap();
1065        let exclude = TrustExcludeRules::parse(["qux"]).unwrap();
1066        let result = check_no_downgrade(&p, "2.0.0", picked, &exclude, None);
1067        assert!(result.is_ok());
1068    }
1069
1070    #[test]
1071    fn exclude_blocks_downgrade_failure() {
1072        let p = packument(
1073            "foo",
1074            vec![
1075                (
1076                    "2.0.0",
1077                    "2025-02-01T00:00:00.000Z",
1078                    with_provenance(version("foo", "2.0.0")),
1079                ),
1080                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1081            ],
1082        );
1083        let picked = p.versions.get("3.0.0").unwrap();
1084        let exclude = TrustExcludeRules::parse(["foo@3.0.0"]).unwrap();
1085        let result = check_no_downgrade(&p, "3.0.0", picked, &exclude, None);
1086        assert!(result.is_ok(), "exclude should bypass the downgrade");
1087    }
1088
1089    #[test]
1090    fn ignore_after_skips_old_versions() {
1091        let p = packument(
1092            "foo",
1093            vec![
1094                (
1095                    "2.0.0",
1096                    "2025-02-01T00:00:00.000Z",
1097                    with_provenance(version("foo", "2.0.0")),
1098                ),
1099                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1100            ],
1101        );
1102        let picked = p.versions.get("3.0.0").unwrap();
1103        // 1 minute cutoff — both versions are way older, should skip.
1104        let result =
1105            check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), Some(1));
1106        assert!(result.is_ok());
1107    }
1108
1109    // ---------- TrustExcludeRules parsing ----------
1110
1111    #[test]
1112    fn exclude_parses_name_only() {
1113        let r = TrustExcludeRules::parse(["foo"]).unwrap();
1114        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1115        assert!(r.matches("foo", &node_semver::Version::parse("99.0.0").unwrap()));
1116        assert!(!r.matches("bar", &node_semver::Version::parse("1.0.0").unwrap()));
1117    }
1118
1119    #[test]
1120    fn default_excludes_known_provenance_churn_packages() {
1121        let r = TrustExcludeRules::default();
1122        for package in DEFAULT_TRUST_POLICY_EXCLUDES {
1123            assert!(
1124                r.matches(package, &node_semver::Version::parse("1.0.0").unwrap()),
1125                "{package} should be globally excluded"
1126            );
1127        }
1128        assert!(!r.matches("left-pad", &node_semver::Version::parse("1.0.0").unwrap()));
1129    }
1130
1131    #[test]
1132    fn default_excludes_scoped_octokit_endpoint_backport() {
1133        // Regression: @octokit backports fixes to older major lines without
1134        // provenance, so a legitimate older release (e.g. 9.0.6) published
1135        // after an attested newer major (10.1.0) tripped no-downgrade. The
1136        // scoped name must match every version, and the leading `@` must not
1137        // be misparsed as a version separator.
1138        let r = TrustExcludeRules::default();
1139        assert!(r.matches(
1140            "@octokit/endpoint",
1141            &node_semver::Version::parse("9.0.6").unwrap()
1142        ));
1143        assert!(r.matches(
1144            "@octokit/endpoint",
1145            &node_semver::Version::parse("10.1.0").unwrap()
1146        ));
1147        assert!(!r.matches(
1148            "@octokit/core",
1149            &node_semver::Version::parse("9.0.6").unwrap()
1150        ));
1151    }
1152
1153    #[test]
1154    fn exclude_parses_name_at_version() {
1155        let r = TrustExcludeRules::parse(["foo@1.0.0"]).unwrap();
1156        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1157        assert!(!r.matches("foo", &node_semver::Version::parse("1.0.1").unwrap()));
1158    }
1159
1160    #[test]
1161    fn exclude_parses_version_union() {
1162        let r = TrustExcludeRules::parse(["foo@1.0.0 || 2.0.0 || 3.0.0"]).unwrap();
1163        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1164        assert!(r.matches("foo", &node_semver::Version::parse("2.0.0").unwrap()));
1165        assert!(r.matches("foo", &node_semver::Version::parse("3.0.0").unwrap()));
1166        assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1167    }
1168
1169    #[test]
1170    fn exclude_parses_scoped_name() {
1171        let r = TrustExcludeRules::parse(["@babel/core@7.20.0"]).unwrap();
1172        assert!(r.matches(
1173            "@babel/core",
1174            &node_semver::Version::parse("7.20.0").unwrap()
1175        ));
1176        assert!(!r.matches(
1177            "@babel/core",
1178            &node_semver::Version::parse("7.20.1").unwrap()
1179        ));
1180    }
1181
1182    #[test]
1183    fn exclude_parses_scoped_name_only() {
1184        let r = TrustExcludeRules::parse(["@babel/core"]).unwrap();
1185        assert!(r.matches(
1186            "@babel/core",
1187            &node_semver::Version::parse("9.9.9").unwrap()
1188        ));
1189    }
1190
1191    #[test]
1192    fn exclude_parses_glob() {
1193        let r = TrustExcludeRules::parse(["is-*"]).unwrap();
1194        assert!(r.matches("is-odd", &node_semver::Version::parse("1.0.0").unwrap()));
1195        assert!(r.matches("is-even", &node_semver::Version::parse("1.0.0").unwrap()));
1196        assert!(!r.matches("lodash", &node_semver::Version::parse("1.0.0").unwrap()));
1197    }
1198
1199    #[test]
1200    fn exclude_parses_star_matches_all() {
1201        let r = TrustExcludeRules::parse(["*"]).unwrap();
1202        assert!(r.matches("anything", &node_semver::Version::parse("0.0.1").unwrap()));
1203    }
1204
1205    #[test]
1206    fn exclude_parses_version_ranges() {
1207        let r = TrustExcludeRules::parse(["foo@^1.0.0 || ~2.1.0 || >=3.0.0 <4.0.0"]).unwrap();
1208        assert!(r.matches("foo", &node_semver::Version::parse("1.2.3").unwrap()));
1209        assert!(r.matches("foo", &node_semver::Version::parse("2.1.9").unwrap()));
1210        assert!(r.matches("foo", &node_semver::Version::parse("3.5.0").unwrap()));
1211        assert!(!r.matches("foo", &node_semver::Version::parse("2.2.0").unwrap()));
1212        assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1213    }
1214
1215    #[test]
1216    fn exclude_rejects_invalid_version_ranges() {
1217        let err = TrustExcludeRules::parse(["foo@definitely-not-a-range"]).expect_err("bad range");
1218        assert!(matches!(
1219            err,
1220            TrustExcludeParseError::InvalidVersionUnion { .. }
1221        ));
1222    }
1223
1224    #[test]
1225    fn exclude_rejects_glob_with_version() {
1226        let err = TrustExcludeRules::parse(["is-*@1.0.0"]).expect_err("glob+version");
1227        assert!(matches!(
1228            err,
1229            TrustExcludeParseError::NameGlobWithVersions { .. }
1230        ));
1231    }
1232
1233    #[test]
1234    fn parse_lossy_keeps_valid_drops_invalid() {
1235        let (rules, errors) = TrustExcludeRules::parse_lossy([
1236            "good",
1237            "bad@definitely-not-a-range",
1238            "@scope/also-good@1.0.0",
1239            "is-*@nope",
1240        ]);
1241        // Two valid rules survive; two invalid surface as separate errors.
1242        assert!(rules.matches("good", &node_semver::Version::parse("1.0.0").unwrap()));
1243        assert!(rules.matches(
1244            "@scope/also-good",
1245            &node_semver::Version::parse("1.0.0").unwrap()
1246        ));
1247        assert_eq!(errors.len(), 2, "two malformed entries reported");
1248    }
1249
1250    #[test]
1251    fn exclude_skips_empty_patterns() {
1252        // npm config arrays sometimes include empty entries; ignore them.
1253        let r = TrustExcludeRules::parse(["", "foo", ""]).unwrap();
1254        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1255    }
1256}