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