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