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