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