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. Two trust-evidence sources, ranked
6//! `TrustedPublisher (2) > Provenance (1)`. The check runs immediately
7//! after a version is picked from a packument: if any strictly older
8//! version of the same package had stronger trust evidence, the install
9//! fails. Pre-2010 packuments without per-version `time` entries error
10//! when the picked version isn't excluded — same as pnpm.
11
12use aube_registry::{Packument, VersionMetadata};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15/// Trust-evidence ranks. Higher is stronger. Variants intentionally do
16/// not derive `Ord` — the variant declaration order does not match the
17/// rank order, so callers must go through [`Self::rank`].
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TrustEvidence {
20    TrustedPublisher,
21    Provenance,
22}
23
24impl TrustEvidence {
25    pub fn rank(self) -> u8 {
26        match self {
27            Self::TrustedPublisher => 2,
28            Self::Provenance => 1,
29        }
30    }
31
32    pub fn label(self) -> &'static str {
33        match self {
34            Self::TrustedPublisher => "trusted publisher",
35            Self::Provenance => "provenance attestation",
36        }
37    }
38}
39
40/// Strongest trust evidence carried by a single version's metadata.
41/// Mirrors pnpm's `getTrustEvidence`: `_npmUser.trustedPublisher`
42/// outranks `dist.attestations.provenance`. JS-style truthiness on the
43/// trustedPublisher value: any non-null, non-`false` value counts.
44pub fn evidence_for(meta: &VersionMetadata) -> Option<TrustEvidence> {
45    if meta
46        .npm_user
47        .as_ref()
48        .and_then(|u| u.trusted_publisher.as_ref())
49        .is_some_and(is_truthy)
50    {
51        return Some(TrustEvidence::TrustedPublisher);
52    }
53    if meta
54        .dist
55        .as_ref()
56        .and_then(|d| d.attestations.as_ref())
57        .and_then(|a| a.provenance.as_ref())
58        .is_some_and(is_truthy)
59    {
60        return Some(TrustEvidence::Provenance);
61    }
62    None
63}
64
65/// JS-style truthiness for `_npmUser.trustedPublisher`. Pnpm's check
66/// is `manifest._npmUser?.trustedPublisher`, which evaluates the value
67/// in a boolean context — so we have to reject every JS falsy value,
68/// not just `null`/`false`. Falsy: `null`, `false`, `0`, `""`, `NaN`.
69/// Truthy: every object (including `{}`), every array (including `[]`),
70/// every non-zero number, every non-empty string. In practice the
71/// field is always either an object or absent, but matching JS
72/// semantics keeps a hostile registry from sneaking trust evidence
73/// past us via `0` or an empty string.
74fn is_truthy(v: &serde_json::Value) -> bool {
75    match v {
76        serde_json::Value::Null => false,
77        serde_json::Value::Bool(b) => *b,
78        serde_json::Value::Number(n) => n.as_f64().is_none_or(|f| f != 0.0 && !f.is_nan()),
79        serde_json::Value::String(s) => !s.is_empty(),
80        serde_json::Value::Array(_) | serde_json::Value::Object(_) => true,
81    }
82}
83
84#[derive(Debug)]
85pub enum TrustCheckError {
86    Downgrade(TrustDowngradeDetails),
87    MissingTime(MissingTimeDetails),
88}
89
90#[derive(Debug)]
91pub struct TrustDowngradeDetails {
92    pub name: String,
93    pub picked_version: String,
94    pub current_evidence: Option<TrustEvidence>,
95    pub prior_evidence: TrustEvidence,
96    pub prior_version: String,
97}
98
99#[derive(Debug)]
100pub struct MissingTimeDetails {
101    pub name: String,
102    pub version: String,
103}
104
105/// Run the trust-downgrade check. Returns `Ok(())` when the picked
106/// version is acceptable (excluded, missing-evidence-everywhere, older
107/// than `ignore_after_minutes`, or carrying evidence at least as strong
108/// as the strongest prior version's). Errors otherwise.
109///
110/// Step ordering matters: exclude check runs *before* the time lookup
111/// so an excluded `name@version` does not surface a `MissingTime` error
112/// when the registry omits the `time` field. Verified against pnpm's
113/// `does not fail with ERR_PNPM_MISSING_TIME when ... excluded` tests.
114pub fn check_no_downgrade(
115    packument: &Packument,
116    picked_version: &str,
117    picked_meta: &VersionMetadata,
118    exclude: &TrustExcludeRules,
119    ignore_after_minutes: Option<u64>,
120) -> Result<(), TrustCheckError> {
121    let picked_parsed = node_semver::Version::parse(picked_version).ok();
122
123    if let Some(ref pv) = picked_parsed {
124        if exclude.matches(&packument.name, pv) {
125            return Ok(());
126        }
127    } else if exclude.matches_name_only(&packument.name) {
128        return Ok(());
129    }
130
131    // Registry doesn't publish `time` at all — local Verdaccio fixtures,
132    // some private mirrors, ancient registry forks. Without per-version
133    // publish times we can't compare evidence chronologically, so skip
134    // the check rather than fail every install. This degrades the
135    // protection but preserves install behavior against compliant
136    // registries (npmjs.org, JSR, modern Verdaccio). Diverges from
137    // pnpm's strict-throw behavior because trustPolicy is default-on
138    // in aube — strict-throw against the long tail of registries that
139    // omit `time` would make aube unusable on first install.
140    if packument.time.is_empty() {
141        return Ok(());
142    }
143
144    let Some(picked_time) = packument.time.get(picked_version) else {
145        return Err(TrustCheckError::MissingTime(MissingTimeDetails {
146            name: packument.name.clone(),
147            version: picked_version.to_string(),
148        }));
149    };
150
151    if let Some(minutes) = ignore_after_minutes
152        && minutes > 0
153        && let Some(cutoff) = cutoff_iso8601(minutes)
154        && picked_time.as_str() < cutoff.as_str()
155    {
156        return Ok(());
157    }
158
159    // pnpm v10.24.0+: when the picked version is a stable release,
160    // ignore prior prerelease evidence — a trusted alpha shouldn't
161    // block a stable that omits attestation.
162    let exclude_prereleases = picked_parsed
163        .as_ref()
164        .map(|v| v.pre_release.is_empty())
165        .unwrap_or(false);
166
167    let mut best: Option<(TrustEvidence, &str)> = None;
168    for (other_ver, other_meta) in &packument.versions {
169        if other_ver == picked_version {
170            continue;
171        }
172        let Some(other_time) = packument.time.get(other_ver) else {
173            continue;
174        };
175        if other_time.as_str() >= picked_time.as_str() {
176            continue;
177        }
178        if exclude_prereleases
179            && let Ok(parsed) = node_semver::Version::parse(other_ver)
180            && !parsed.pre_release.is_empty()
181        {
182            continue;
183        }
184        let Some(evidence) = evidence_for(other_meta) else {
185            continue;
186        };
187        match best {
188            None => best = Some((evidence, other_ver.as_str())),
189            Some((cur, _)) if evidence.rank() > cur.rank() => {
190                best = Some((evidence, other_ver.as_str()));
191            }
192            _ => {}
193        }
194        if matches!(best, Some((TrustEvidence::TrustedPublisher, _))) {
195            break;
196        }
197    }
198
199    let Some((prior_evidence, prior_version)) = best else {
200        return Ok(());
201    };
202
203    let current = evidence_for(picked_meta);
204    let current_rank = current.map_or(0, TrustEvidence::rank);
205    if current_rank < prior_evidence.rank() {
206        return Err(TrustCheckError::Downgrade(TrustDowngradeDetails {
207            name: packument.name.clone(),
208            picked_version: picked_version.to_string(),
209            current_evidence: current,
210            prior_evidence,
211            prior_version: prior_version.to_string(),
212        }));
213    }
214    Ok(())
215}
216
217fn cutoff_iso8601(minutes_ago: u64) -> Option<String> {
218    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
219    let cutoff_secs = now.saturating_sub(minutes_ago * 60);
220    Some(crate::types::format_iso8601_utc(cutoff_secs))
221}
222
223/// Parsed `trustPolicyExclude` rules. Mirrors pnpm's
224/// `createPackageVersionPolicy` (config/version-policy/src/index.ts).
225/// Each rule is `<name>` (matches all versions, supports `*` glob in
226/// the name) or `<name>@<exact-version>[ || <exact-version>]…` (no
227/// ranges, no name globs combined with versions).
228pub const DEFAULT_TRUST_POLICY_EXCLUDES: &[&str] = &[
229    "chokidar",
230    "eslint-config-prettier",
231    "eslint-import-resolver-typescript",
232    "react-redux",
233    "reselect",
234    "semver",
235    "ua-parser-js",
236    "undici-types",
237    "vite",
238];
239
240#[derive(Debug, Clone)]
241pub struct TrustExcludeRules {
242    rules: Vec<TrustExcludeRule>,
243}
244
245impl Default for TrustExcludeRules {
246    fn default() -> Self {
247        Self::from_name_excludes(DEFAULT_TRUST_POLICY_EXCLUDES)
248    }
249}
250
251#[derive(Debug, Clone)]
252struct TrustExcludeRule {
253    name_matcher: NameMatcher,
254    /// `None` → rule matches every version of any name match.
255    /// `Some(versions)` → rule matches only those exact versions.
256    exact_versions: Option<Vec<node_semver::Version>>,
257}
258
259#[derive(Debug, Clone)]
260enum NameMatcher {
261    Exact(String),
262    Glob(GlobMatcher),
263    Any,
264}
265
266#[derive(Debug, Clone)]
267struct GlobMatcher {
268    parts: Vec<String>,
269    leading_wildcard: bool,
270    trailing_wildcard: bool,
271}
272
273#[derive(Debug, thiserror::Error)]
274pub enum TrustExcludeParseError {
275    #[error(
276        "invalid trustPolicyExclude pattern `{pattern}`: only exact versions are allowed in version unions, ranges (^/~/>=) are not supported"
277    )]
278    InvalidVersionUnion { pattern: String },
279    #[error(
280        "invalid trustPolicyExclude pattern `{pattern}`: name patterns (`*`) cannot be combined with version unions"
281    )]
282    NameGlobWithVersions { pattern: String },
283}
284
285impl TrustExcludeRules {
286    fn from_name_excludes(names: &[&str]) -> Self {
287        Self {
288            rules: names
289                .iter()
290                .map(|name| TrustExcludeRule {
291                    name_matcher: NameMatcher::compile(name),
292                    exact_versions: None,
293                })
294                .collect(),
295        }
296    }
297
298    pub fn with_defaults_and_user_rules(user_rules: Self) -> Self {
299        let mut rules = Self::default();
300        rules.rules.extend(user_rules.rules);
301        rules
302    }
303
304    pub fn parse<I, S>(patterns: I) -> Result<Self, TrustExcludeParseError>
305    where
306        I: IntoIterator<Item = S>,
307        S: AsRef<str>,
308    {
309        let mut rules = Vec::new();
310        for pattern in patterns {
311            let pattern = pattern.as_ref();
312            if pattern.is_empty() {
313                continue;
314            }
315            rules.push(parse_one(pattern)?);
316        }
317        Ok(Self { rules })
318    }
319
320    /// Parse a list of patterns, keeping every rule that succeeds and
321    /// returning the per-pattern errors for everything that didn't.
322    /// Lets the caller log malformed entries individually without
323    /// dropping the rules that did parse — a strict batch `parse` would
324    /// turn one typo into a silent security regression where every
325    /// exclude vanishes.
326    pub fn parse_lossy<I, S>(patterns: I) -> (Self, Vec<TrustExcludeParseError>)
327    where
328        I: IntoIterator<Item = S>,
329        S: AsRef<str>,
330    {
331        let mut rules = Vec::new();
332        let mut errors = Vec::new();
333        for pattern in patterns {
334            let pattern = pattern.as_ref();
335            if pattern.is_empty() {
336                continue;
337            }
338            match parse_one(pattern) {
339                Ok(rule) => rules.push(rule),
340                Err(err) => errors.push(err),
341            }
342        }
343        (Self { rules }, errors)
344    }
345
346    fn matches(&self, name: &str, version: &node_semver::Version) -> bool {
347        for rule in &self.rules {
348            if !rule.name_matcher.matches(name) {
349                continue;
350            }
351            match &rule.exact_versions {
352                None => return true,
353                Some(versions) => {
354                    if versions.iter().any(|v| v == version) {
355                        return true;
356                    }
357                }
358            }
359        }
360        false
361    }
362
363    /// Used when the picked version string fails semver parse — only a
364    /// no-version rule can match in that case (pnpm behavior:
365    /// `evaluateVersionPolicy` returns `true` for name-only rules
366    /// before the version array branch is taken).
367    fn matches_name_only(&self, name: &str) -> bool {
368        self.rules
369            .iter()
370            .any(|r| r.exact_versions.is_none() && r.name_matcher.matches(name))
371    }
372}
373
374fn parse_one(pattern: &str) -> Result<TrustExcludeRule, TrustExcludeParseError> {
375    let scoped = pattern.starts_with('@');
376    let at_index = if scoped {
377        pattern[1..].find('@').map(|i| i + 1)
378    } else {
379        pattern.find('@')
380    };
381
382    let (name_part, versions_part) = match at_index {
383        Some(i) => (&pattern[..i], Some(&pattern[i + 1..])),
384        None => (pattern, None),
385    };
386
387    let exact_versions = match versions_part {
388        None => None,
389        Some(versions_str) => {
390            if name_part.contains('*') {
391                return Err(TrustExcludeParseError::NameGlobWithVersions {
392                    pattern: pattern.to_string(),
393                });
394            }
395            let mut parsed = Vec::new();
396            for chunk in versions_str.split("||") {
397                let trimmed = chunk.trim();
398                if trimmed.is_empty() {
399                    return Err(TrustExcludeParseError::InvalidVersionUnion {
400                        pattern: pattern.to_string(),
401                    });
402                }
403                let v = node_semver::Version::parse(trimmed).map_err(|_| {
404                    TrustExcludeParseError::InvalidVersionUnion {
405                        pattern: pattern.to_string(),
406                    }
407                })?;
408                parsed.push(v);
409            }
410            Some(parsed)
411        }
412    };
413
414    Ok(TrustExcludeRule {
415        name_matcher: NameMatcher::compile(name_part),
416        exact_versions,
417    })
418}
419
420impl NameMatcher {
421    fn compile(pattern: &str) -> Self {
422        if pattern == "*" {
423            return Self::Any;
424        }
425        if !pattern.contains('*') {
426            return Self::Exact(pattern.to_string());
427        }
428        let parts: Vec<String> = pattern.split('*').map(str::to_string).collect();
429        Self::Glob(GlobMatcher {
430            leading_wildcard: parts.first().is_some_and(String::is_empty),
431            trailing_wildcard: parts.last().is_some_and(String::is_empty),
432            parts: parts.into_iter().filter(|s| !s.is_empty()).collect(),
433        })
434    }
435
436    fn matches(&self, input: &str) -> bool {
437        match self {
438            Self::Any => true,
439            Self::Exact(s) => s == input,
440            Self::Glob(g) => g.matches(input),
441        }
442    }
443}
444
445impl GlobMatcher {
446    fn matches(&self, input: &str) -> bool {
447        if self.parts.is_empty() {
448            return true;
449        }
450        let mut cursor = 0usize;
451        for (i, segment) in self.parts.iter().enumerate() {
452            let search_window = &input[cursor..];
453            let is_first = i == 0;
454            let is_last = i == self.parts.len() - 1;
455            if is_first && !self.leading_wildcard {
456                if !search_window.starts_with(segment.as_str()) {
457                    return false;
458                }
459                cursor += segment.len();
460            } else if is_last && !self.trailing_wildcard {
461                if !search_window.ends_with(segment.as_str()) {
462                    return false;
463                }
464                if search_window.len() < segment.len() {
465                    return false;
466                }
467                cursor = input.len();
468            } else {
469                let Some(idx) = search_window.find(segment.as_str()) else {
470                    return false;
471                };
472                cursor += idx + segment.len();
473            }
474        }
475        true
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use aube_registry::{Attestations, Dist, NpmUser};
483    use std::collections::BTreeMap;
484
485    fn version(name: &str, ver: &str) -> VersionMetadata {
486        VersionMetadata {
487            name: name.to_string(),
488            version: ver.to_string(),
489            dependencies: BTreeMap::new(),
490            dev_dependencies: BTreeMap::new(),
491            peer_dependencies: BTreeMap::new(),
492            peer_dependencies_meta: BTreeMap::new(),
493            optional_dependencies: BTreeMap::new(),
494            bundled_dependencies: None,
495            dist: Some(Dist {
496                tarball: format!("https://r/{name}/-/{name}-{ver}.tgz"),
497                integrity: None,
498                shasum: None,
499                attestations: None,
500            }),
501            os: vec![],
502            cpu: vec![],
503            libc: vec![],
504            engines: BTreeMap::new(),
505            license: None,
506            funding_url: None,
507            bin: BTreeMap::new(),
508            has_install_script: false,
509            deprecated: None,
510            npm_user: None,
511        }
512    }
513
514    fn with_provenance(mut v: VersionMetadata) -> VersionMetadata {
515        let dist = v.dist.as_mut().unwrap();
516        dist.attestations = Some(Attestations {
517            provenance: Some(serde_json::json!({"predicateType": "slsa"})),
518        });
519        v
520    }
521
522    fn with_trusted_publisher(mut v: VersionMetadata) -> VersionMetadata {
523        v.npm_user = Some(NpmUser {
524            trusted_publisher: Some(serde_json::json!({"id": "gh"})),
525        });
526        v
527    }
528
529    fn packument(name: &str, versions: Vec<(&str, &str, VersionMetadata)>) -> Packument {
530        let mut p = Packument {
531            name: name.to_string(),
532            modified: None,
533            versions: BTreeMap::new(),
534            dist_tags: BTreeMap::new(),
535            time: BTreeMap::new(),
536        };
537        for (ver, time, meta) in versions {
538            p.versions.insert(ver.to_string(), meta);
539            p.time.insert(ver.to_string(), time.to_string());
540        }
541        p
542    }
543
544    #[test]
545    fn evidence_trusted_publisher_outranks_provenance() {
546        let v = with_trusted_publisher(with_provenance(version("foo", "1.0.0")));
547        assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
548    }
549
550    #[test]
551    fn evidence_provenance_only() {
552        let v = with_provenance(version("foo", "1.0.0"));
553        assert_eq!(evidence_for(&v), Some(TrustEvidence::Provenance));
554    }
555
556    #[test]
557    fn evidence_npm_user_without_trusted_publisher_is_none() {
558        let mut v = version("foo", "1.0.0");
559        v.npm_user = Some(NpmUser {
560            trusted_publisher: None,
561        });
562        assert_eq!(evidence_for(&v), None);
563    }
564
565    #[test]
566    fn evidence_falsy_trusted_publisher_is_none() {
567        let mut v = version("foo", "1.0.0");
568        for falsy in [
569            serde_json::Value::Bool(false),
570            serde_json::Value::Null,
571            serde_json::json!(0),
572            serde_json::json!(0.0),
573            serde_json::json!(""),
574        ] {
575            v.npm_user = Some(NpmUser {
576                trusted_publisher: Some(falsy.clone()),
577            });
578            assert_eq!(evidence_for(&v), None, "{falsy:?} should be falsy");
579        }
580    }
581
582    #[test]
583    fn evidence_falsy_provenance_is_none() {
584        // Symmetric with the trustedPublisher truthiness check —
585        // `dist.attestations.provenance` is also evaluated in JS
586        // boolean context in pnpm, so falsy values must not count
587        // as evidence. Real registries always emit an object here,
588        // but this guards against a hostile registry shipping
589        // `provenance: false` to bypass the no-downgrade check.
590        let mut v = version("foo", "1.0.0");
591        for falsy in [
592            serde_json::Value::Bool(false),
593            serde_json::Value::Null,
594            serde_json::json!(0),
595            serde_json::json!(""),
596        ] {
597            v.dist.as_mut().unwrap().attestations = Some(Attestations {
598                provenance: Some(falsy.clone()),
599            });
600            assert_eq!(evidence_for(&v), None, "{falsy:?} should be falsy");
601        }
602    }
603
604    #[test]
605    fn evidence_truthy_non_object_trusted_publisher_counts() {
606        // JS truthy values that aren't objects: non-zero numbers,
607        // non-empty strings, empty arrays/objects. Real registries only
608        // ever emit an object here, but match JS semantics for
609        // defense-in-depth against a hostile/buggy registry.
610        let mut v = version("foo", "1.0.0");
611        for truthy in [
612            serde_json::json!(true),
613            serde_json::json!(1),
614            serde_json::json!("oidc:gh"),
615            serde_json::json!([]),
616            serde_json::json!({}),
617        ] {
618            v.npm_user = Some(NpmUser {
619                trusted_publisher: Some(truthy.clone()),
620            });
621            assert_eq!(
622                evidence_for(&v),
623                Some(TrustEvidence::TrustedPublisher),
624                "{truthy:?} should be truthy"
625            );
626        }
627    }
628
629    #[test]
630    fn evidence_none_when_neither() {
631        let v = version("foo", "1.0.0");
632        assert_eq!(evidence_for(&v), None);
633    }
634
635    #[test]
636    fn no_evidence_anywhere_passes() {
637        let p = packument(
638            "foo",
639            vec![
640                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
641                ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
642            ],
643        );
644        let picked = p.versions.get("2.0.0").unwrap();
645        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
646        assert!(result.is_ok());
647    }
648
649    #[test]
650    fn first_attested_version_passes() {
651        let p = packument(
652            "foo",
653            vec![
654                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
655                (
656                    "2.0.0",
657                    "2025-02-01T00:00:00.000Z",
658                    with_provenance(version("foo", "2.0.0")),
659                ),
660            ],
661        );
662        let picked = p.versions.get("1.0.0").unwrap();
663        let result = check_no_downgrade(&p, "1.0.0", picked, &TrustExcludeRules::default(), None);
664        assert!(
665            result.is_ok(),
666            "version 1.0.0 was published first; it has nothing prior to compare against"
667        );
668    }
669
670    #[test]
671    fn downgrade_provenance_to_none_fails() {
672        let p = packument(
673            "foo",
674            vec![
675                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
676                (
677                    "2.0.0",
678                    "2025-02-01T00:00:00.000Z",
679                    with_provenance(version("foo", "2.0.0")),
680                ),
681                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
682            ],
683        );
684        let picked = p.versions.get("3.0.0").unwrap();
685        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
686            .expect_err("3.0.0 should fail: prior version had provenance, this one has none");
687        match err {
688            TrustCheckError::Downgrade(d) => {
689                assert_eq!(d.prior_evidence, TrustEvidence::Provenance);
690                assert_eq!(d.prior_version, "2.0.0");
691                assert_eq!(d.current_evidence, None);
692            }
693            _ => panic!("expected Downgrade"),
694        }
695    }
696
697    #[test]
698    fn downgrade_trusted_publisher_to_provenance_fails() {
699        let p = packument(
700            "foo",
701            vec![
702                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
703                (
704                    "2.0.0",
705                    "2025-02-01T00:00:00.000Z",
706                    with_trusted_publisher(version("foo", "2.0.0")),
707                ),
708                (
709                    "3.0.0",
710                    "2025-03-01T00:00:00.000Z",
711                    with_provenance(version("foo", "3.0.0")),
712                ),
713            ],
714        );
715        let picked = p.versions.get("3.0.0").unwrap();
716        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
717            .expect_err("trustedPublisher → provenance is a downgrade");
718        match err {
719            TrustCheckError::Downgrade(d) => {
720                assert_eq!(d.prior_evidence, TrustEvidence::TrustedPublisher);
721                assert_eq!(d.current_evidence, Some(TrustEvidence::Provenance));
722            }
723            _ => panic!("expected Downgrade"),
724        }
725    }
726
727    #[test]
728    fn same_trust_level_passes() {
729        let p = packument(
730            "foo",
731            vec![
732                (
733                    "2.0.0",
734                    "2025-02-01T00:00:00.000Z",
735                    with_trusted_publisher(version("foo", "2.0.0")),
736                ),
737                (
738                    "3.0.0",
739                    "2025-03-01T00:00:00.000Z",
740                    with_trusted_publisher(version("foo", "3.0.0")),
741                ),
742            ],
743        );
744        let picked = p.versions.get("3.0.0").unwrap();
745        let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
746        assert!(result.is_ok());
747    }
748
749    #[test]
750    fn prior_prerelease_ignored_when_picking_stable() {
751        let p = packument(
752            "foo",
753            vec![
754                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
755                (
756                    "2.0.0-0",
757                    "2025-02-01T00:00:00.000Z",
758                    with_provenance(version("foo", "2.0.0-0")),
759                ),
760                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
761            ],
762        );
763        let picked = p.versions.get("3.0.0").unwrap();
764        let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
765        assert!(
766            result.is_ok(),
767            "trusted prerelease shouldn't block a stable that omits attestation"
768        );
769    }
770
771    #[test]
772    fn prior_prerelease_counts_when_picking_prerelease() {
773        let p = packument(
774            "foo",
775            vec![
776                (
777                    "2.0.0-0",
778                    "2025-02-01T00:00:00.000Z",
779                    with_provenance(version("foo", "2.0.0-0")),
780                ),
781                (
782                    "3.0.0-0",
783                    "2025-03-01T00:00:00.000Z",
784                    version("foo", "3.0.0-0"),
785                ),
786            ],
787        );
788        let picked = p.versions.get("3.0.0-0").unwrap();
789        let result = check_no_downgrade(&p, "3.0.0-0", picked, &TrustExcludeRules::default(), None);
790        assert!(
791            result.is_err(),
792            "prerelease pick should compare against prior prereleases"
793        );
794    }
795
796    /// Registries that don't publish `time` at all (Verdaccio without
797    /// the `--store-info` middleware, private mirrors that strip it,
798    /// old registry forks) must not break every install. Verified by
799    /// constructing a packument with versions but no `time` map.
800    #[test]
801    fn empty_time_map_skips_check() {
802        let p = Packument {
803            name: "foo".to_string(),
804            modified: None,
805            versions: {
806                let mut m = BTreeMap::new();
807                m.insert(
808                    "1.0.0".to_string(),
809                    with_provenance(version("foo", "1.0.0")),
810                );
811                m.insert("2.0.0".to_string(), version("foo", "2.0.0"));
812                m
813            },
814            dist_tags: BTreeMap::new(),
815            time: BTreeMap::new(), // Empty — registry doesn't ship time at all.
816        };
817        let picked = p.versions.get("2.0.0").unwrap();
818        // Would normally be a downgrade (2.0.0 lost provenance), but
819        // without `time` we can't establish chronology and degrade safely.
820        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
821        assert!(result.is_ok(), "empty time map should skip the check");
822    }
823
824    #[test]
825    fn missing_time_for_picked_version_errors() {
826        let mut p = packument(
827            "foo",
828            vec![
829                (
830                    "1.0.0",
831                    "2025-01-01T00:00:00.000Z",
832                    with_provenance(version("foo", "1.0.0")),
833                ),
834                ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
835            ],
836        );
837        // Drop the time entry for 2.0.0.
838        p.time.remove("2.0.0");
839        let picked = p.versions.get("2.0.0").unwrap();
840        let err = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None)
841            .expect_err("missing time should error");
842        assert!(matches!(err, TrustCheckError::MissingTime(_)));
843    }
844
845    #[test]
846    fn exclude_name_at_version_bypasses_missing_time() {
847        // No time field anywhere — would normally error.
848        let p = Packument {
849            name: "baz".to_string(),
850            modified: None,
851            versions: {
852                let mut m = BTreeMap::new();
853                m.insert("1.0.0".to_string(), version("baz", "1.0.0"));
854                m
855            },
856            dist_tags: BTreeMap::new(),
857            time: BTreeMap::new(),
858        };
859        let picked = p.versions.get("1.0.0").unwrap();
860        let exclude = TrustExcludeRules::parse(["baz@1.0.0"]).unwrap();
861        let result = check_no_downgrade(&p, "1.0.0", picked, &exclude, None);
862        assert!(result.is_ok(), "excluded version must skip the time lookup");
863    }
864
865    #[test]
866    fn exclude_name_only_bypasses_missing_time() {
867        let p = Packument {
868            name: "qux".to_string(),
869            modified: None,
870            versions: {
871                let mut m = BTreeMap::new();
872                m.insert("2.0.0".to_string(), version("qux", "2.0.0"));
873                m
874            },
875            dist_tags: BTreeMap::new(),
876            time: BTreeMap::new(),
877        };
878        let picked = p.versions.get("2.0.0").unwrap();
879        let exclude = TrustExcludeRules::parse(["qux"]).unwrap();
880        let result = check_no_downgrade(&p, "2.0.0", picked, &exclude, None);
881        assert!(result.is_ok());
882    }
883
884    #[test]
885    fn exclude_blocks_downgrade_failure() {
886        let p = packument(
887            "foo",
888            vec![
889                (
890                    "2.0.0",
891                    "2025-02-01T00:00:00.000Z",
892                    with_provenance(version("foo", "2.0.0")),
893                ),
894                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
895            ],
896        );
897        let picked = p.versions.get("3.0.0").unwrap();
898        let exclude = TrustExcludeRules::parse(["foo@3.0.0"]).unwrap();
899        let result = check_no_downgrade(&p, "3.0.0", picked, &exclude, None);
900        assert!(result.is_ok(), "exclude should bypass the downgrade");
901    }
902
903    #[test]
904    fn ignore_after_skips_old_versions() {
905        let p = packument(
906            "foo",
907            vec![
908                (
909                    "2.0.0",
910                    "2025-02-01T00:00:00.000Z",
911                    with_provenance(version("foo", "2.0.0")),
912                ),
913                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
914            ],
915        );
916        let picked = p.versions.get("3.0.0").unwrap();
917        // 1 minute cutoff — both versions are way older, should skip.
918        let result =
919            check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), Some(1));
920        assert!(result.is_ok());
921    }
922
923    // ---------- TrustExcludeRules parsing ----------
924
925    #[test]
926    fn exclude_parses_name_only() {
927        let r = TrustExcludeRules::parse(["foo"]).unwrap();
928        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
929        assert!(r.matches("foo", &node_semver::Version::parse("99.0.0").unwrap()));
930        assert!(!r.matches("bar", &node_semver::Version::parse("1.0.0").unwrap()));
931    }
932
933    #[test]
934    fn default_excludes_known_provenance_churn_packages() {
935        let r = TrustExcludeRules::default();
936        for package in DEFAULT_TRUST_POLICY_EXCLUDES {
937            assert!(
938                r.matches(package, &node_semver::Version::parse("1.0.0").unwrap()),
939                "{package} should be globally excluded"
940            );
941        }
942        assert!(!r.matches("left-pad", &node_semver::Version::parse("1.0.0").unwrap()));
943    }
944
945    #[test]
946    fn exclude_parses_name_at_version() {
947        let r = TrustExcludeRules::parse(["foo@1.0.0"]).unwrap();
948        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
949        assert!(!r.matches("foo", &node_semver::Version::parse("1.0.1").unwrap()));
950    }
951
952    #[test]
953    fn exclude_parses_version_union() {
954        let r = TrustExcludeRules::parse(["foo@1.0.0 || 2.0.0 || 3.0.0"]).unwrap();
955        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
956        assert!(r.matches("foo", &node_semver::Version::parse("2.0.0").unwrap()));
957        assert!(r.matches("foo", &node_semver::Version::parse("3.0.0").unwrap()));
958        assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
959    }
960
961    #[test]
962    fn exclude_parses_scoped_name() {
963        let r = TrustExcludeRules::parse(["@babel/core@7.20.0"]).unwrap();
964        assert!(r.matches(
965            "@babel/core",
966            &node_semver::Version::parse("7.20.0").unwrap()
967        ));
968        assert!(!r.matches(
969            "@babel/core",
970            &node_semver::Version::parse("7.20.1").unwrap()
971        ));
972    }
973
974    #[test]
975    fn exclude_parses_scoped_name_only() {
976        let r = TrustExcludeRules::parse(["@babel/core"]).unwrap();
977        assert!(r.matches(
978            "@babel/core",
979            &node_semver::Version::parse("9.9.9").unwrap()
980        ));
981    }
982
983    #[test]
984    fn exclude_parses_glob() {
985        let r = TrustExcludeRules::parse(["is-*"]).unwrap();
986        assert!(r.matches("is-odd", &node_semver::Version::parse("1.0.0").unwrap()));
987        assert!(r.matches("is-even", &node_semver::Version::parse("1.0.0").unwrap()));
988        assert!(!r.matches("lodash", &node_semver::Version::parse("1.0.0").unwrap()));
989    }
990
991    #[test]
992    fn exclude_parses_star_matches_all() {
993        let r = TrustExcludeRules::parse(["*"]).unwrap();
994        assert!(r.matches("anything", &node_semver::Version::parse("0.0.1").unwrap()));
995    }
996
997    #[test]
998    fn exclude_rejects_range_operators() {
999        for bad in ["foo@^1.0.0", "foo@~1.0.0", "foo@>=1.0.0"] {
1000            let err = TrustExcludeRules::parse([bad]).expect_err(bad);
1001            assert!(matches!(
1002                err,
1003                TrustExcludeParseError::InvalidVersionUnion { .. }
1004            ));
1005        }
1006    }
1007
1008    #[test]
1009    fn exclude_rejects_glob_with_version() {
1010        let err = TrustExcludeRules::parse(["is-*@1.0.0"]).expect_err("glob+version");
1011        assert!(matches!(
1012            err,
1013            TrustExcludeParseError::NameGlobWithVersions { .. }
1014        ));
1015    }
1016
1017    #[test]
1018    fn parse_lossy_keeps_valid_drops_invalid() {
1019        let (rules, errors) = TrustExcludeRules::parse_lossy([
1020            "good",
1021            "bad@^1.0.0",
1022            "@scope/also-good@1.0.0",
1023            "is-*@nope",
1024        ]);
1025        // Two valid rules survive; two invalid surface as separate errors.
1026        assert!(rules.matches("good", &node_semver::Version::parse("1.0.0").unwrap()));
1027        assert!(rules.matches(
1028            "@scope/also-good",
1029            &node_semver::Version::parse("1.0.0").unwrap()
1030        ));
1031        assert_eq!(errors.len(), 2, "two malformed entries reported");
1032    }
1033
1034    #[test]
1035    fn exclude_skips_empty_patterns() {
1036        // npm config arrays sometimes include empty entries; ignore them.
1037        let r = TrustExcludeRules::parse(["", "foo", ""]).unwrap();
1038        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1039    }
1040}