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 compact_evidence_for(meta: &aube_registry::VersionTrustMetadata) -> Option<TrustEvidence> {
77    if meta.approver.as_ref().is_some_and(is_approver) {
78        return Some(TrustEvidence::StagedPublish);
79    }
80    if meta
81        .npm_user
82        .as_ref()
83        .and_then(|user| user.trusted_publisher.as_ref())
84        .is_some_and(is_trusted_publisher)
85    {
86        return Some(TrustEvidence::TrustedPublisher);
87    }
88    if meta
89        .dist
90        .as_ref()
91        .and_then(|dist| dist.attestations.as_ref())
92        .and_then(|attestations| attestations.provenance.as_ref())
93        .is_some_and(is_provenance)
94    {
95        return Some(TrustEvidence::Provenance);
96    }
97    None
98}
99
100fn is_approver(v: &serde_json::Value) -> bool {
101    match v {
102        serde_json::Value::Null => false,
103        serde_json::Value::String(s) => !s.is_empty(),
104        serde_json::Value::Array(a) => a.iter().any(is_approver),
105        serde_json::Value::Object(o) => o.values().any(is_approver),
106        serde_json::Value::Bool(b) => *b,
107        serde_json::Value::Number(n) => {
108            n.as_i64().is_some_and(|i| i != 0)
109                || n.as_u64().is_some_and(|u| u != 0)
110                || n.as_f64().is_some_and(|f| f != 0.0)
111        }
112    }
113}
114
115fn is_trusted_publisher(v: &serde_json::Value) -> bool {
116    v.as_object()
117        .and_then(|o| o.get("id"))
118        .and_then(|id| id.as_str())
119        .is_some_and(|id| !id.is_empty())
120}
121
122fn is_provenance(v: &serde_json::Value) -> bool {
123    v.as_object()
124        .and_then(|o| o.get("predicateType"))
125        .and_then(|predicate| predicate.as_str())
126        .is_some_and(|predicate| {
127            predicate
128                .strip_prefix("https://slsa.dev/provenance/v")
129                .and_then(|suffix| suffix.chars().next())
130                .is_some_and(|c| c.is_ascii_digit())
131        })
132}
133
134#[derive(Debug)]
135pub enum TrustCheckError {
136    Downgrade(TrustDowngradeDetails),
137    MissingTime(MissingTimeDetails),
138}
139
140#[derive(Debug)]
141pub struct TrustDowngradeDetails {
142    pub name: String,
143    pub picked_version: String,
144    pub current_evidence: Option<TrustEvidence>,
145    pub prior_evidence: TrustEvidence,
146    pub prior_version: String,
147}
148
149#[derive(Debug)]
150pub struct MissingTimeDetails {
151    pub name: String,
152    pub version: String,
153}
154
155/// The strongest trust evidence carried by a version published before the
156/// selected release.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct PriorTrustEvidence {
159    pub version: String,
160    pub evidence: TrustEvidence,
161}
162
163/// Find the strongest trust evidence on a release published before
164/// `picked_version`.
165///
166/// Stable releases ignore evidence from prereleases, matching
167/// [`check_no_downgrade`]. Returns `None` when the selected version has no
168/// publish time or no earlier release carries trust evidence.
169pub fn strongest_prior_evidence(
170    packument: &Packument,
171    picked_version: &str,
172) -> Option<PriorTrustEvidence> {
173    let picked_time = packument.time.get(picked_version)?;
174    let exclude_prereleases = node_semver::Version::parse(picked_version)
175        .map(|v| v.pre_release.is_empty())
176        .unwrap_or(false);
177
178    let mut best: Option<PriorTrustEvidence> = None;
179    for (other_ver, other_meta) in &packument.versions {
180        if other_ver == picked_version {
181            continue;
182        }
183        let Some(other_time) = packument.time.get(other_ver) else {
184            continue;
185        };
186        if other_time.as_str() >= picked_time.as_str() {
187            continue;
188        }
189        if exclude_prereleases
190            && let Ok(parsed) = node_semver::Version::parse(other_ver)
191            && !parsed.pre_release.is_empty()
192        {
193            continue;
194        }
195        let Some(evidence) = evidence_for(other_meta) else {
196            continue;
197        };
198        match best {
199            None => {
200                best = Some(PriorTrustEvidence {
201                    version: other_ver.clone(),
202                    evidence,
203                });
204            }
205            Some(ref current) if evidence.rank() > current.evidence.rank() => {
206                best = Some(PriorTrustEvidence {
207                    version: other_ver.clone(),
208                    evidence,
209                });
210            }
211            _ => {}
212        }
213        if matches!(
214            best,
215            Some(PriorTrustEvidence {
216                evidence: TrustEvidence::StagedPublish,
217                ..
218            })
219        ) {
220            break;
221        }
222    }
223    best
224}
225
226/// Run the trust-downgrade check. Returns `Ok(())` when the picked
227/// version is acceptable (excluded, missing-evidence-everywhere, older
228/// than `ignore_after_minutes`, or carrying evidence at least as strong
229/// as the strongest prior version's). Errors otherwise.
230///
231/// Step ordering matters: exclude check runs *before* the time lookup
232/// so an excluded `name@version` does not surface a `MissingTime` error
233/// when the registry omits the `time` field. Verified against pnpm's
234/// `does not fail with ERR_PNPM_MISSING_TIME when ... excluded` tests.
235pub fn check_no_downgrade(
236    packument: &Packument,
237    picked_version: &str,
238    picked_meta: &VersionMetadata,
239    exclude: &TrustExcludeRules,
240    ignore_after_minutes: Option<u64>,
241) -> Result<(), TrustCheckError> {
242    let picked_parsed = node_semver::Version::parse(picked_version).ok();
243
244    if let Some(ref pv) = picked_parsed {
245        if exclude.matches(&packument.name, pv) {
246            return Ok(());
247        }
248    } else if exclude.matches_name_only(&packument.name) {
249        return Ok(());
250    }
251
252    // Registry doesn't publish `time` at all — local Verdaccio fixtures,
253    // some private mirrors, ancient registry forks. Without per-version
254    // publish times we can't compare evidence chronologically, so skip
255    // the check rather than fail every install. This degrades the
256    // protection but preserves install behavior against compliant
257    // registries (npmjs.org, JSR, modern Verdaccio). Diverges from
258    // pnpm's strict-throw behavior because trustPolicy is default-on
259    // in aube — strict-throw against the long tail of registries that
260    // omit `time` would make aube unusable on first install.
261    if packument.time.is_empty() {
262        return Ok(());
263    }
264
265    let Some(picked_time) = packument.time.get(picked_version) else {
266        return Err(TrustCheckError::MissingTime(MissingTimeDetails {
267            name: packument.name.clone(),
268            version: picked_version.to_string(),
269        }));
270    };
271
272    if let Some(minutes) = ignore_after_minutes
273        && minutes > 0
274        && let Some(cutoff) = cutoff_iso8601(minutes)
275        && picked_time.as_str() < cutoff.as_str()
276    {
277        return Ok(());
278    }
279
280    let Some(prior) = strongest_prior_evidence(packument, picked_version) else {
281        return Ok(());
282    };
283
284    let current = evidence_for(picked_meta);
285    let current_rank = current.map_or(0, TrustEvidence::rank);
286    if current_rank < prior.evidence.rank() {
287        return Err(TrustCheckError::Downgrade(TrustDowngradeDetails {
288            name: packument.name.clone(),
289            picked_version: picked_version.to_string(),
290            current_evidence: current,
291            prior_evidence: prior.evidence,
292            prior_version: prior.version,
293        }));
294    }
295    Ok(())
296}
297
298/// Trust-policy check using the compact history fetched for an exact
299/// dependency. The selected release remains full [`VersionMetadata`]; only
300/// historical releases use the evidence-only representation.
301pub fn check_no_downgrade_compact(
302    packument: &Packument,
303    picked_version: &str,
304    picked_meta: &VersionMetadata,
305    history: &std::collections::BTreeMap<String, aube_registry::VersionTrustMetadata>,
306    exclude: &TrustExcludeRules,
307    ignore_after_minutes: Option<u64>,
308) -> Result<(), TrustCheckError> {
309    check_no_downgrade_over_history(
310        &packument.name,
311        &packument.time,
312        picked_version,
313        evidence_for(picked_meta),
314        history,
315        exclude,
316        ignore_after_minutes,
317    )
318}
319
320/// Trust-policy check over a standalone compact trust history — no full
321/// [`Packument`] or [`VersionMetadata`] in hand. Used by the lockfile
322/// validator, which fetches [`aube_registry::PackumentTrustHistory`]
323/// per name; `history.versions` here *includes* the picked version
324/// (the shared loop skips it when ranking prior evidence).
325pub fn check_no_downgrade_history(
326    name: &str,
327    history: &aube_registry::PackumentTrustHistory,
328    picked_version: &str,
329    picked_meta: &aube_registry::VersionTrustMetadata,
330    exclude: &TrustExcludeRules,
331    ignore_after_minutes: Option<u64>,
332) -> Result<(), TrustCheckError> {
333    check_no_downgrade_over_history(
334        name,
335        &history.time,
336        picked_version,
337        compact_evidence_for(picked_meta),
338        &history.versions,
339        exclude,
340        ignore_after_minutes,
341    )
342}
343
344/// Shared core for the compact-history trust checks: rank the strongest
345/// prior evidence in `history` and reject a picked version that weakens
346/// it. `history` may or may not contain `picked_version` itself — the
347/// ranking loop always skips it.
348fn check_no_downgrade_over_history(
349    name: &str,
350    time: &std::collections::BTreeMap<String, String>,
351    picked_version: &str,
352    picked_evidence: Option<TrustEvidence>,
353    history: &std::collections::BTreeMap<String, aube_registry::VersionTrustMetadata>,
354    exclude: &TrustExcludeRules,
355    ignore_after_minutes: Option<u64>,
356) -> Result<(), TrustCheckError> {
357    let picked_parsed = node_semver::Version::parse(picked_version).ok();
358    if let Some(ref version) = picked_parsed {
359        if exclude.matches(name, version) {
360            return Ok(());
361        }
362    } else if exclude.matches_name_only(name) {
363        return Ok(());
364    }
365    if time.is_empty() {
366        return Ok(());
367    }
368    let Some(picked_time) = time.get(picked_version) else {
369        return Err(TrustCheckError::MissingTime(MissingTimeDetails {
370            name: name.to_string(),
371            version: picked_version.to_string(),
372        }));
373    };
374    if let Some(minutes) = ignore_after_minutes
375        && minutes > 0
376        && let Some(cutoff) = cutoff_iso8601(minutes)
377        && picked_time.as_str() < cutoff.as_str()
378    {
379        return Ok(());
380    }
381
382    let exclude_prereleases = picked_parsed
383        .as_ref()
384        .map(|version| version.pre_release.is_empty())
385        .unwrap_or(false);
386    let mut strongest: Option<PriorTrustEvidence> = None;
387    for (version, meta) in history {
388        if version == picked_version {
389            continue;
390        }
391        let Some(published_at) = time.get(version) else {
392            continue;
393        };
394        if published_at >= picked_time {
395            continue;
396        }
397        if exclude_prereleases
398            && let Ok(parsed) = node_semver::Version::parse(version)
399            && !parsed.pre_release.is_empty()
400        {
401            continue;
402        }
403        let Some(evidence) = compact_evidence_for(meta) else {
404            continue;
405        };
406        if strongest
407            .as_ref()
408            .is_none_or(|current| evidence.rank() > current.evidence.rank())
409        {
410            strongest = Some(PriorTrustEvidence {
411                version: version.clone(),
412                evidence,
413            });
414        }
415        if strongest
416            .as_ref()
417            .is_some_and(|prior| prior.evidence == TrustEvidence::StagedPublish)
418        {
419            break;
420        }
421    }
422    let Some(prior) = strongest else {
423        return Ok(());
424    };
425    if picked_evidence.map_or(0, TrustEvidence::rank) < prior.evidence.rank() {
426        return Err(TrustCheckError::Downgrade(TrustDowngradeDetails {
427            name: name.to_string(),
428            picked_version: picked_version.to_string(),
429            current_evidence: picked_evidence,
430            prior_evidence: prior.evidence,
431            prior_version: prior.version,
432        }));
433    }
434    Ok(())
435}
436
437fn cutoff_iso8601(minutes_ago: u64) -> Option<String> {
438    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
439    let cutoff_secs = now.saturating_sub(minutes_ago * 60);
440    Some(crate::types::format_iso8601_utc(cutoff_secs))
441}
442
443/// Parsed `trustPolicyExclude` rules. Mirrors pnpm's
444/// `createPackageVersionPolicy` (config/version-policy/src/index.ts).
445/// Each rule is `<name>` (matches all versions, supports `*` glob in
446/// the name) or `<name>@<semver-range>[ || <semver-range>]…` (no name
447/// globs combined with versions).
448pub const DEFAULT_TRUST_POLICY_EXCLUDES: &[&str] = &[
449    // @octokit maintains several major-version lines in parallel and backports
450    // fixes to older lines without provenance attestation. A backport (e.g.
451    // @octokit/endpoint@9.0.6) is published after an attested newer major
452    // (10.1.0), so the no-downgrade check flags the legitimate older release.
453    "@octokit/endpoint",
454    // @hono/node-server@1.19.15 (2026-07-24) was hand-published without
455    // attestation after the trusted 2.0.10 release (2026-07-15). Trusted
456    // publishing resumed with 1.19.17, so keep the exception version-scoped.
457    "@hono/node-server@1.19.15",
458    "chokidar",
459    "eslint-config-prettier",
460    "eslint-import-resolver-typescript",
461    "react-redux",
462    "reselect",
463    "semver",
464    "ua-parser-js",
465    "undici",
466    "undici-types",
467    "vite",
468];
469
470/// A parsed package-version policy: a set of `<name>[@<semver-range>…]`
471/// rules with `*` name globs. pnpm backs both `trustPolicyExclude` and
472/// `minimumReleaseAgeExclude` with the same `createPackageVersionPolicy`
473/// engine, so we do too — `TrustExcludeRules` is the neutral
474/// [`PackageVersionPolicy`] type seeded with the trust defaults, while
475/// `minimumReleaseAgeExclude` builds an empty one from user rules only.
476///
477/// # Warning
478///
479/// Because this is an alias for [`TrustExcludeRules`],
480/// `PackageVersionPolicy::default()` runs that type's [`Default`] impl,
481/// which seeds [`DEFAULT_TRUST_POLICY_EXCLUDES`] (40+ well-known
482/// packages). For the age gate that is wrong — it would silently exempt
483/// those packages from `minimumReleaseAge`. Age-gate call sites must use
484/// [`TrustExcludeRules::empty`]; never `default()`, `#[derive(Default)]`
485/// on a containing struct, or `unwrap_or_default()`.
486pub type PackageVersionPolicy = TrustExcludeRules;
487
488#[derive(Debug, Clone)]
489pub struct TrustExcludeRules {
490    rules: Vec<TrustExcludeRule>,
491}
492
493impl Default for TrustExcludeRules {
494    fn default() -> Self {
495        // Parse the entries rather than assuming each is a bare name, so a
496        // future `name@range` default actually works. The previous
497        // `from_name_excludes` hardcoded `version_ranges: None`, which would
498        // have compiled such an entry into a name matcher for the literal
499        // string `"name@range"` — silently matching nothing and dropping the
500        // exemption rather than erroring. Every current entry is a bare name,
501        // for which this is behavior-identical. The list is a compile-time
502        // constant, so a malformed entry is an authoring bug;
503        // `default_excludes_known_provenance_churn_packages` asserts each one
504        // parses.
505        Self::parse(DEFAULT_TRUST_POLICY_EXCLUDES)
506            .expect("DEFAULT_TRUST_POLICY_EXCLUDES must be valid exclude patterns")
507    }
508}
509
510#[derive(Debug, Clone)]
511struct TrustExcludeRule {
512    name_matcher: NameMatcher,
513    /// `None` → rule matches every version of any name match.
514    /// `Some(ranges)` → rule matches any version satisfying one range.
515    version_ranges: Option<Vec<node_semver::Range>>,
516}
517
518#[derive(Debug, Clone)]
519enum NameMatcher {
520    Exact(String),
521    Glob(GlobMatcher),
522    Any,
523}
524
525#[derive(Debug, Clone)]
526struct GlobMatcher {
527    parts: Vec<String>,
528    leading_wildcard: bool,
529    trailing_wildcard: bool,
530}
531
532// Shared by both `trustPolicyExclude` and `minimumReleaseAgeExclude`, so
533// the message text stays setting-neutral — the caller's log line names
534// the specific setting the bad entry came from.
535#[derive(Debug, thiserror::Error, miette::Diagnostic)]
536pub enum TrustExcludeParseError {
537    #[error("invalid exclude pattern `{pattern}`: version selectors must be valid semver ranges")]
538    #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_INVALID_VERSION_UNION))]
539    InvalidVersionUnion { pattern: String },
540    #[error(
541        "invalid exclude pattern `{pattern}`: name patterns (`*`) cannot be combined with version unions"
542    )]
543    #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_NAME_GLOB_WITH_VERSIONS))]
544    NameGlobWithVersions { pattern: String },
545}
546
547impl TrustExcludeRules {
548    /// A policy that matches nothing. Used as the
549    /// `minimumReleaseAgeExclude` default — unlike [`Default`], which
550    /// seeds the trust-specific exclude list, the age gate must start
551    /// with no exemptions.
552    pub fn empty() -> Self {
553        Self { rules: Vec::new() }
554    }
555
556    pub fn is_empty(&self) -> bool {
557        self.rules.is_empty()
558    }
559
560    pub fn len(&self) -> usize {
561        self.rules.len()
562    }
563
564    /// Return whether this policy excludes `name@version`.
565    pub fn excludes(&self, name: &str, version: &str) -> bool {
566        match node_semver::Version::parse(version) {
567            Ok(version) => self.matches(name, &version),
568            Err(_) => self.matches_name_only(name),
569        }
570    }
571
572    pub fn with_defaults_and_user_rules(user_rules: Self) -> Self {
573        let mut rules = Self::default();
574        rules.rules.extend(user_rules.rules);
575        rules
576    }
577
578    pub fn parse<I, S>(patterns: I) -> Result<Self, TrustExcludeParseError>
579    where
580        I: IntoIterator<Item = S>,
581        S: AsRef<str>,
582    {
583        let mut rules = Vec::new();
584        for pattern in patterns {
585            let pattern = pattern.as_ref();
586            if pattern.is_empty() {
587                continue;
588            }
589            rules.push(parse_one(pattern)?);
590        }
591        Ok(Self { rules })
592    }
593
594    /// Parse a list of patterns, keeping every rule that succeeds and
595    /// returning the per-pattern errors for everything that didn't.
596    /// Lets the caller log malformed entries individually without
597    /// dropping the rules that did parse — a strict batch `parse` would
598    /// turn one typo into a silent security regression where every
599    /// exclude vanishes.
600    pub fn parse_lossy<I, S>(patterns: I) -> (Self, Vec<TrustExcludeParseError>)
601    where
602        I: IntoIterator<Item = S>,
603        S: AsRef<str>,
604    {
605        let mut rules = Vec::new();
606        let mut errors = Vec::new();
607        for pattern in patterns {
608            let pattern = pattern.as_ref();
609            if pattern.is_empty() {
610                continue;
611            }
612            match parse_one(pattern) {
613                Ok(rule) => rules.push(rule),
614                Err(err) => errors.push(err),
615            }
616        }
617        (Self { rules }, errors)
618    }
619
620    pub(crate) fn matches(&self, name: &str, version: &node_semver::Version) -> bool {
621        for rule in &self.rules {
622            if !rule.name_matcher.matches(name) {
623                continue;
624            }
625            match &rule.version_ranges {
626                None => return true,
627                Some(ranges) => {
628                    if ranges.iter().any(|r| version.satisfies(r)) {
629                        return true;
630                    }
631                }
632            }
633        }
634        false
635    }
636
637    /// Used when the picked version string fails semver parse — only a
638    /// no-version rule can match in that case (pnpm behavior:
639    /// `evaluateVersionPolicy` returns `true` for name-only rules
640    /// before the version array branch is taken).
641    pub(crate) fn matches_name_only(&self, name: &str) -> bool {
642        self.rules
643            .iter()
644            .any(|r| r.version_ranges.is_none() && r.name_matcher.matches(name))
645    }
646}
647
648/// Split `<name>[@<versions>]` on the separator that isn't a scope marker,
649/// so a scoped name's leading `@` isn't mistaken for a version selector.
650fn split_name_and_versions(pattern: &str) -> (&str, Option<&str>) {
651    let at_index = match pattern.strip_prefix('@') {
652        // Scoped name: the leading `@` is the scope marker, so the version
653        // separator is the next `@`, offset back past the one we stripped.
654        Some(rest) => rest.find('@').map(|i| i + 1),
655        None => pattern.find('@'),
656    };
657    match at_index {
658        Some(i) => (&pattern[..i], Some(&pattern[i + 1..])),
659        None => (pattern, None),
660    }
661}
662
663fn parse_one(pattern: &str) -> Result<TrustExcludeRule, TrustExcludeParseError> {
664    let (name_part, versions_part) = split_name_and_versions(pattern);
665
666    let version_ranges = match versions_part {
667        None => None,
668        Some(versions_str) => {
669            if name_part.contains('*') {
670                return Err(TrustExcludeParseError::NameGlobWithVersions {
671                    pattern: pattern.to_string(),
672                });
673            }
674            let mut parsed = Vec::new();
675            for chunk in versions_str.split("||") {
676                let trimmed = chunk.trim();
677                if trimmed.is_empty() {
678                    return Err(TrustExcludeParseError::InvalidVersionUnion {
679                        pattern: pattern.to_string(),
680                    });
681                }
682                let r = node_semver::Range::parse(trimmed).map_err(|_| {
683                    TrustExcludeParseError::InvalidVersionUnion {
684                        pattern: pattern.to_string(),
685                    }
686                })?;
687                parsed.push(r);
688            }
689            Some(parsed)
690        }
691    };
692
693    Ok(TrustExcludeRule {
694        name_matcher: NameMatcher::compile(name_part),
695        version_ranges,
696    })
697}
698
699impl NameMatcher {
700    fn compile(pattern: &str) -> Self {
701        if pattern == "*" {
702            return Self::Any;
703        }
704        if !pattern.contains('*') {
705            return Self::Exact(pattern.to_string());
706        }
707        let parts: Vec<String> = pattern.split('*').map(str::to_string).collect();
708        Self::Glob(GlobMatcher {
709            leading_wildcard: parts.first().is_some_and(String::is_empty),
710            trailing_wildcard: parts.last().is_some_and(String::is_empty),
711            parts: parts.into_iter().filter(|s| !s.is_empty()).collect(),
712        })
713    }
714
715    fn matches(&self, input: &str) -> bool {
716        match self {
717            Self::Any => true,
718            Self::Exact(s) => s == input,
719            Self::Glob(g) => g.matches(input),
720        }
721    }
722}
723
724impl GlobMatcher {
725    fn matches(&self, input: &str) -> bool {
726        if self.parts.is_empty() {
727            return true;
728        }
729        let mut cursor = 0usize;
730        for (i, segment) in self.parts.iter().enumerate() {
731            let search_window = &input[cursor..];
732            let is_first = i == 0;
733            let is_last = i == self.parts.len() - 1;
734            if is_first && !self.leading_wildcard {
735                if !search_window.starts_with(segment.as_str()) {
736                    return false;
737                }
738                cursor += segment.len();
739            } else if is_last && !self.trailing_wildcard {
740                if !search_window.ends_with(segment.as_str()) {
741                    return false;
742                }
743                if search_window.len() < segment.len() {
744                    return false;
745                }
746                cursor = input.len();
747            } else {
748                let Some(idx) = search_window.find(segment.as_str()) else {
749                    return false;
750                };
751                cursor += idx + segment.len();
752            }
753        }
754        true
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use aube_registry::{Attestations, Dist, NpmUser};
762    use std::collections::BTreeMap;
763
764    fn version(name: &str, ver: &str) -> VersionMetadata {
765        VersionMetadata {
766            name: name.to_string(),
767            version: ver.to_string(),
768            dependencies: BTreeMap::new(),
769            dev_dependencies: BTreeMap::new(),
770            peer_dependencies: BTreeMap::new(),
771            peer_dependencies_meta: BTreeMap::new(),
772            optional_dependencies: BTreeMap::new(),
773            bundled_dependencies: None,
774            dist: Some(Dist {
775                tarball: format!("https://r/{name}/-/{name}-{ver}.tgz"),
776                integrity: None,
777                shasum: None,
778                unpacked_size: None,
779                attestations: None,
780            }),
781            os: vec![],
782            cpu: vec![],
783            libc: vec![],
784            engines: BTreeMap::new(),
785            license: None,
786            funding_url: None,
787            bin: BTreeMap::new(),
788            has_install_script: false,
789            deprecated: None,
790            approver: None,
791            npm_user: None,
792        }
793    }
794
795    fn with_provenance(mut v: VersionMetadata) -> VersionMetadata {
796        let dist = v.dist.as_mut().unwrap();
797        dist.attestations = Some(Attestations {
798            provenance: Some(serde_json::json!({
799                "predicateType": "https://slsa.dev/provenance/v1"
800            })),
801        });
802        v
803    }
804
805    fn with_trusted_publisher(mut v: VersionMetadata) -> VersionMetadata {
806        v.npm_user = Some(NpmUser {
807            trusted_publisher: Some(serde_json::json!({"id": "gh"})),
808        });
809        v
810    }
811
812    fn with_staged_publish(mut v: VersionMetadata) -> VersionMetadata {
813        v.approver = Some(serde_json::json!({"name": "release-manager"}));
814        v
815    }
816
817    fn packument(name: &str, versions: Vec<(&str, &str, VersionMetadata)>) -> Packument {
818        let mut p = Packument {
819            name: name.to_string(),
820            modified: None,
821            versions: BTreeMap::new(),
822            dist_tags: BTreeMap::new(),
823            time: BTreeMap::new(),
824        };
825        for (ver, time, meta) in versions {
826            p.versions.insert(ver.to_string(), meta);
827            p.time.insert(ver.to_string(), time.to_string());
828        }
829        p
830    }
831
832    #[test]
833    fn evidence_trusted_publisher_outranks_provenance() {
834        let v = with_trusted_publisher(with_provenance(version("foo", "1.0.0")));
835        assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
836    }
837
838    #[test]
839    fn evidence_staged_publish_outranks_trusted_publisher() {
840        let v = with_staged_publish(with_trusted_publisher(with_provenance(version(
841            "foo", "1.0.0",
842        ))));
843        assert_eq!(evidence_for(&v), Some(TrustEvidence::StagedPublish));
844    }
845
846    #[test]
847    fn evidence_provenance_only() {
848        let v = with_provenance(version("foo", "1.0.0"));
849        assert_eq!(evidence_for(&v), Some(TrustEvidence::Provenance));
850    }
851
852    #[test]
853    fn evidence_npm_user_without_trusted_publisher_is_none() {
854        let mut v = version("foo", "1.0.0");
855        v.npm_user = Some(NpmUser {
856            trusted_publisher: None,
857        });
858        assert_eq!(evidence_for(&v), None);
859    }
860
861    #[test]
862    fn evidence_malformed_trusted_publisher_is_none() {
863        let mut v = version("foo", "1.0.0");
864        for malformed in [
865            serde_json::Value::Bool(false),
866            serde_json::Value::Null,
867            serde_json::json!(0),
868            serde_json::json!(0.0),
869            serde_json::json!(""),
870            serde_json::json!([]),
871            serde_json::json!({}),
872            serde_json::json!({"id": ""}),
873        ] {
874            v.npm_user = Some(NpmUser {
875                trusted_publisher: Some(malformed.clone()),
876            });
877            assert_eq!(
878                evidence_for(&v),
879                None,
880                "{malformed:?} should not count as trusted-publisher evidence"
881            );
882        }
883    }
884
885    #[test]
886    fn evidence_empty_approver_is_none() {
887        let mut v = version("foo", "1.0.0");
888        for malformed in [
889            serde_json::Value::Bool(false),
890            serde_json::Value::Null,
891            serde_json::json!(0),
892            serde_json::json!(0.0),
893            serde_json::json!(""),
894            serde_json::json!([]),
895            serde_json::json!([null]),
896            serde_json::json!([false]),
897            serde_json::json!([""]),
898            serde_json::json!([[], {}]),
899            serde_json::json!({}),
900            serde_json::json!({"name": null}),
901            serde_json::json!({"name": null, "email": null}),
902            serde_json::json!({"name": ""}),
903            serde_json::json!({"nested": {}}),
904        ] {
905            v.approver = Some(malformed.clone());
906            assert_eq!(
907                evidence_for(&v),
908                None,
909                "{malformed:?} should not count as staged-publish evidence"
910            );
911        }
912    }
913
914    #[test]
915    fn evidence_truthy_scalar_approver_counts() {
916        let mut v = version("foo", "1.0.0");
917        for approver in [
918            serde_json::Value::Bool(true),
919            serde_json::json!(1),
920            serde_json::json!("release-manager"),
921            serde_json::json!(["release-manager"]),
922            serde_json::json!({"name": "release-manager"}),
923        ] {
924            v.approver = Some(approver.clone());
925            assert_eq!(
926                evidence_for(&v),
927                Some(TrustEvidence::StagedPublish),
928                "{approver:?} should count as staged-publish evidence"
929            );
930        }
931    }
932
933    #[test]
934    fn evidence_malformed_provenance_is_none() {
935        let mut v = version("foo", "1.0.0");
936        for malformed in [
937            serde_json::Value::Bool(false),
938            serde_json::Value::Null,
939            serde_json::json!(0),
940            serde_json::json!(""),
941            serde_json::json!([]),
942            serde_json::json!({}),
943            serde_json::json!({"predicateType": ""}),
944            serde_json::json!({"predicateType": "https://slsa.dev/provenance/"}),
945            serde_json::json!({"predicateType": "https://slsa.dev/provenance/v"}),
946            serde_json::json!({"predicateType": "https://slsa.dev/provenance/latest"}),
947            serde_json::json!({"predicateType": "https://example.com/provenance/v1"}),
948        ] {
949            v.dist.as_mut().unwrap().attestations = Some(Attestations {
950                provenance: Some(malformed.clone()),
951            });
952            assert_eq!(
953                evidence_for(&v),
954                None,
955                "{malformed:?} should not count as provenance evidence"
956            );
957        }
958    }
959
960    #[test]
961    fn evidence_structured_trusted_publisher_counts() {
962        let mut v = version("foo", "1.0.0");
963        v.npm_user = Some(NpmUser {
964            trusted_publisher: Some(serde_json::json!({
965                "id": "github",
966                "oidcConfigId": "oidc:example"
967            })),
968        });
969        assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
970    }
971
972    #[test]
973    fn evidence_none_when_neither() {
974        let v = version("foo", "1.0.0");
975        assert_eq!(evidence_for(&v), None);
976    }
977
978    #[test]
979    fn no_evidence_anywhere_passes() {
980        let p = packument(
981            "foo",
982            vec![
983                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
984                ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
985            ],
986        );
987        let picked = p.versions.get("2.0.0").unwrap();
988        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
989        assert!(result.is_ok());
990    }
991
992    #[test]
993    fn first_attested_version_passes() {
994        let p = packument(
995            "foo",
996            vec![
997                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
998                (
999                    "2.0.0",
1000                    "2025-02-01T00:00:00.000Z",
1001                    with_provenance(version("foo", "2.0.0")),
1002                ),
1003            ],
1004        );
1005        let picked = p.versions.get("1.0.0").unwrap();
1006        let result = check_no_downgrade(&p, "1.0.0", picked, &TrustExcludeRules::default(), None);
1007        assert!(
1008            result.is_ok(),
1009            "version 1.0.0 was published first; it has nothing prior to compare against"
1010        );
1011    }
1012
1013    #[test]
1014    fn downgrade_provenance_to_none_fails() {
1015        let p = packument(
1016            "foo",
1017            vec![
1018                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
1019                (
1020                    "2.0.0",
1021                    "2025-02-01T00:00:00.000Z",
1022                    with_provenance(version("foo", "2.0.0")),
1023                ),
1024                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1025            ],
1026        );
1027        let picked = p.versions.get("3.0.0").unwrap();
1028        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
1029            .expect_err("3.0.0 should fail: prior version had provenance, this one has none");
1030        match err {
1031            TrustCheckError::Downgrade(d) => {
1032                assert_eq!(d.prior_evidence, TrustEvidence::Provenance);
1033                assert_eq!(d.prior_version, "2.0.0");
1034                assert_eq!(d.current_evidence, None);
1035            }
1036            _ => panic!("expected Downgrade"),
1037        }
1038    }
1039
1040    #[test]
1041    fn compact_history_preserves_downgrade_detection() {
1042        let p = packument(
1043            "foo",
1044            vec![("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0"))],
1045        );
1046        let history = BTreeMap::from([
1047            (
1048                "2.0.0".to_string(),
1049                aube_registry::VersionTrustMetadata {
1050                    approver: None,
1051                    npm_user: None,
1052                    dist: Some(aube_registry::VersionTrustDist {
1053                        attestations: Some(Attestations {
1054                            provenance: Some(serde_json::json!({
1055                                "predicateType": "https://slsa.dev/provenance/v1"
1056                            })),
1057                        }),
1058                    }),
1059                },
1060            ),
1061            (
1062                "3.0.0".to_string(),
1063                aube_registry::VersionTrustMetadata {
1064                    approver: None,
1065                    npm_user: None,
1066                    dist: None,
1067                },
1068            ),
1069        ]);
1070        let mut p = p;
1071        p.time
1072            .insert("2.0.0".to_string(), "2025-02-01T00:00:00.000Z".to_string());
1073        let picked = &p.versions["3.0.0"];
1074
1075        let err = check_no_downgrade_compact(
1076            &p,
1077            "3.0.0",
1078            picked,
1079            &history,
1080            &TrustExcludeRules::default(),
1081            None,
1082        )
1083        .expect_err("compact prior provenance must still block a downgrade");
1084        let TrustCheckError::Downgrade(details) = err else {
1085            panic!("expected Downgrade");
1086        };
1087        assert_eq!(details.prior_version, "2.0.0");
1088        assert_eq!(details.prior_evidence, TrustEvidence::Provenance);
1089    }
1090
1091    #[test]
1092    fn standalone_history_detects_downgrade_and_skips_picked_version() {
1093        let trust_meta =
1094            |dist: Option<aube_registry::VersionTrustDist>| aube_registry::VersionTrustMetadata {
1095                approver: None,
1096                npm_user: None,
1097                dist,
1098            };
1099        let provenance_dist = aube_registry::VersionTrustDist {
1100            attestations: Some(Attestations {
1101                provenance: Some(serde_json::json!({
1102                    "predicateType": "https://slsa.dev/provenance/v1"
1103                })),
1104            }),
1105        };
1106        // Unlike the compact map, the standalone history includes the
1107        // picked version itself — the ranking loop must skip it even
1108        // when it carries evidence.
1109        let history = aube_registry::PackumentTrustHistory {
1110            time: BTreeMap::from([
1111                ("2.0.0".to_string(), "2025-02-01T00:00:00.000Z".to_string()),
1112                ("3.0.0".to_string(), "2025-03-01T00:00:00.000Z".to_string()),
1113            ]),
1114            versions: BTreeMap::from([
1115                ("2.0.0".to_string(), trust_meta(Some(provenance_dist))),
1116                ("3.0.0".to_string(), trust_meta(None)),
1117            ]),
1118        };
1119
1120        let picked = &history.versions["3.0.0"];
1121        let err = check_no_downgrade_history(
1122            "foo",
1123            &history,
1124            "3.0.0",
1125            picked,
1126            &TrustExcludeRules::default(),
1127            None,
1128        )
1129        .expect_err("prior provenance must block a downgrade");
1130        let TrustCheckError::Downgrade(details) = err else {
1131            panic!("expected Downgrade");
1132        };
1133        assert_eq!(details.name, "foo");
1134        assert_eq!(details.prior_version, "2.0.0");
1135        assert_eq!(details.prior_evidence, TrustEvidence::Provenance);
1136        assert_eq!(details.current_evidence, None);
1137
1138        // Same history, but picking the version that carries the
1139        // evidence: no prior outranks it, so the check passes.
1140        let picked = &history.versions["2.0.0"];
1141        check_no_downgrade_history(
1142            "foo",
1143            &history,
1144            "2.0.0",
1145            picked,
1146            &TrustExcludeRules::default(),
1147            None,
1148        )
1149        .expect("strongest evidence so far must pass");
1150    }
1151
1152    #[test]
1153    fn downgrade_trusted_publisher_to_provenance_fails() {
1154        let p = packument(
1155            "foo",
1156            vec![
1157                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
1158                (
1159                    "2.0.0",
1160                    "2025-02-01T00:00:00.000Z",
1161                    with_trusted_publisher(version("foo", "2.0.0")),
1162                ),
1163                (
1164                    "3.0.0",
1165                    "2025-03-01T00:00:00.000Z",
1166                    with_provenance(version("foo", "3.0.0")),
1167                ),
1168            ],
1169        );
1170        let picked = p.versions.get("3.0.0").unwrap();
1171        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
1172            .expect_err("trustedPublisher → provenance is a downgrade");
1173        match err {
1174            TrustCheckError::Downgrade(d) => {
1175                assert_eq!(d.prior_evidence, TrustEvidence::TrustedPublisher);
1176                assert_eq!(d.current_evidence, Some(TrustEvidence::Provenance));
1177            }
1178            _ => panic!("expected Downgrade"),
1179        }
1180    }
1181
1182    #[test]
1183    fn downgrade_staged_publish_to_trusted_publisher_fails() {
1184        let p = packument(
1185            "foo",
1186            vec![
1187                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
1188                (
1189                    "2.0.0",
1190                    "2025-02-01T00:00:00.000Z",
1191                    with_staged_publish(version("foo", "2.0.0")),
1192                ),
1193                (
1194                    "3.0.0",
1195                    "2025-03-01T00:00:00.000Z",
1196                    with_trusted_publisher(version("foo", "3.0.0")),
1197                ),
1198            ],
1199        );
1200        let picked = p.versions.get("3.0.0").unwrap();
1201        let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
1202            .expect_err("staged publish → trusted publisher is a downgrade");
1203        match err {
1204            TrustCheckError::Downgrade(d) => {
1205                assert_eq!(d.prior_evidence, TrustEvidence::StagedPublish);
1206                assert_eq!(d.prior_version, "2.0.0");
1207                assert_eq!(d.current_evidence, Some(TrustEvidence::TrustedPublisher));
1208            }
1209            _ => panic!("expected Downgrade"),
1210        }
1211    }
1212
1213    #[test]
1214    fn staged_publish_after_trusted_publisher_passes() {
1215        let p = packument(
1216            "foo",
1217            vec![
1218                (
1219                    "1.0.0",
1220                    "2025-01-01T00:00:00.000Z",
1221                    with_trusted_publisher(version("foo", "1.0.0")),
1222                ),
1223                (
1224                    "2.0.0",
1225                    "2025-02-01T00:00:00.000Z",
1226                    with_staged_publish(version("foo", "2.0.0")),
1227                ),
1228            ],
1229        );
1230        let picked = p.versions.get("2.0.0").unwrap();
1231        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
1232        assert!(result.is_ok());
1233    }
1234
1235    #[test]
1236    fn same_trust_level_passes() {
1237        let p = packument(
1238            "foo",
1239            vec![
1240                (
1241                    "2.0.0",
1242                    "2025-02-01T00:00:00.000Z",
1243                    with_trusted_publisher(version("foo", "2.0.0")),
1244                ),
1245                (
1246                    "3.0.0",
1247                    "2025-03-01T00:00:00.000Z",
1248                    with_trusted_publisher(version("foo", "3.0.0")),
1249                ),
1250            ],
1251        );
1252        let picked = p.versions.get("3.0.0").unwrap();
1253        let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
1254        assert!(result.is_ok());
1255    }
1256
1257    #[test]
1258    fn prior_prerelease_ignored_when_picking_stable() {
1259        let p = packument(
1260            "foo",
1261            vec![
1262                ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
1263                (
1264                    "2.0.0-0",
1265                    "2025-02-01T00:00:00.000Z",
1266                    with_provenance(version("foo", "2.0.0-0")),
1267                ),
1268                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1269            ],
1270        );
1271        let picked = p.versions.get("3.0.0").unwrap();
1272        let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
1273        assert!(
1274            result.is_ok(),
1275            "trusted prerelease shouldn't block a stable that omits attestation"
1276        );
1277    }
1278
1279    #[test]
1280    fn prior_prerelease_counts_when_picking_prerelease() {
1281        let p = packument(
1282            "foo",
1283            vec![
1284                (
1285                    "2.0.0-0",
1286                    "2025-02-01T00:00:00.000Z",
1287                    with_provenance(version("foo", "2.0.0-0")),
1288                ),
1289                (
1290                    "3.0.0-0",
1291                    "2025-03-01T00:00:00.000Z",
1292                    version("foo", "3.0.0-0"),
1293                ),
1294            ],
1295        );
1296        let picked = p.versions.get("3.0.0-0").unwrap();
1297        let result = check_no_downgrade(&p, "3.0.0-0", picked, &TrustExcludeRules::default(), None);
1298        assert!(
1299            result.is_err(),
1300            "prerelease pick should compare against prior prereleases"
1301        );
1302    }
1303
1304    /// Registries that don't publish `time` at all (Verdaccio without
1305    /// the `--store-info` middleware, private mirrors that strip it,
1306    /// old registry forks) must not break every install. Verified by
1307    /// constructing a packument with versions but no `time` map.
1308    #[test]
1309    fn empty_time_map_skips_check() {
1310        let p = Packument {
1311            name: "foo".to_string(),
1312            modified: None,
1313            versions: {
1314                let mut m = BTreeMap::new();
1315                m.insert(
1316                    "1.0.0".to_string(),
1317                    with_provenance(version("foo", "1.0.0")),
1318                );
1319                m.insert("2.0.0".to_string(), version("foo", "2.0.0"));
1320                m
1321            },
1322            dist_tags: BTreeMap::new(),
1323            time: BTreeMap::new(), // Empty — registry doesn't ship time at all.
1324        };
1325        let picked = p.versions.get("2.0.0").unwrap();
1326        // Would normally be a downgrade (2.0.0 lost provenance), but
1327        // without `time` we can't establish chronology and degrade safely.
1328        let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
1329        assert!(result.is_ok(), "empty time map should skip the check");
1330    }
1331
1332    #[test]
1333    fn missing_time_for_picked_version_errors() {
1334        let mut p = packument(
1335            "foo",
1336            vec![
1337                (
1338                    "1.0.0",
1339                    "2025-01-01T00:00:00.000Z",
1340                    with_provenance(version("foo", "1.0.0")),
1341                ),
1342                ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
1343            ],
1344        );
1345        // Drop the time entry for 2.0.0.
1346        p.time.remove("2.0.0");
1347        let picked = p.versions.get("2.0.0").unwrap();
1348        let err = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None)
1349            .expect_err("missing time should error");
1350        assert!(matches!(err, TrustCheckError::MissingTime(_)));
1351    }
1352
1353    #[test]
1354    fn exclude_name_at_version_bypasses_missing_time() {
1355        // No time field anywhere — would normally error.
1356        let p = Packument {
1357            name: "baz".to_string(),
1358            modified: None,
1359            versions: {
1360                let mut m = BTreeMap::new();
1361                m.insert("1.0.0".to_string(), version("baz", "1.0.0"));
1362                m
1363            },
1364            dist_tags: BTreeMap::new(),
1365            time: BTreeMap::new(),
1366        };
1367        let picked = p.versions.get("1.0.0").unwrap();
1368        let exclude = TrustExcludeRules::parse(["baz@1.0.0"]).unwrap();
1369        let result = check_no_downgrade(&p, "1.0.0", picked, &exclude, None);
1370        assert!(result.is_ok(), "excluded version must skip the time lookup");
1371    }
1372
1373    #[test]
1374    fn exclude_name_only_bypasses_missing_time() {
1375        let p = Packument {
1376            name: "qux".to_string(),
1377            modified: None,
1378            versions: {
1379                let mut m = BTreeMap::new();
1380                m.insert("2.0.0".to_string(), version("qux", "2.0.0"));
1381                m
1382            },
1383            dist_tags: BTreeMap::new(),
1384            time: BTreeMap::new(),
1385        };
1386        let picked = p.versions.get("2.0.0").unwrap();
1387        let exclude = TrustExcludeRules::parse(["qux"]).unwrap();
1388        let result = check_no_downgrade(&p, "2.0.0", picked, &exclude, None);
1389        assert!(result.is_ok());
1390    }
1391
1392    #[test]
1393    fn exclude_blocks_downgrade_failure() {
1394        let p = packument(
1395            "foo",
1396            vec![
1397                (
1398                    "2.0.0",
1399                    "2025-02-01T00:00:00.000Z",
1400                    with_provenance(version("foo", "2.0.0")),
1401                ),
1402                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1403            ],
1404        );
1405        let picked = p.versions.get("3.0.0").unwrap();
1406        let exclude = TrustExcludeRules::parse(["foo@3.0.0"]).unwrap();
1407        let result = check_no_downgrade(&p, "3.0.0", picked, &exclude, None);
1408        assert!(result.is_ok(), "exclude should bypass the downgrade");
1409    }
1410
1411    #[test]
1412    fn ignore_after_skips_old_versions() {
1413        let p = packument(
1414            "foo",
1415            vec![
1416                (
1417                    "2.0.0",
1418                    "2025-02-01T00:00:00.000Z",
1419                    with_provenance(version("foo", "2.0.0")),
1420                ),
1421                ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1422            ],
1423        );
1424        let picked = p.versions.get("3.0.0").unwrap();
1425        // 1 minute cutoff — both versions are way older, should skip.
1426        let result =
1427            check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), Some(1));
1428        assert!(result.is_ok());
1429    }
1430
1431    // ---------- TrustExcludeRules parsing ----------
1432
1433    #[test]
1434    fn exclude_parses_name_only() {
1435        let r = TrustExcludeRules::parse(["foo"]).unwrap();
1436        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1437        assert!(r.matches("foo", &node_semver::Version::parse("99.0.0").unwrap()));
1438        assert!(!r.matches("bar", &node_semver::Version::parse("1.0.0").unwrap()));
1439    }
1440
1441    #[test]
1442    fn default_excludes_known_provenance_churn_packages() {
1443        let r = TrustExcludeRules::default();
1444        // Every entry parses into exactly one rule — a malformed default
1445        // would otherwise silently drop protection or panic at first use.
1446        assert_eq!(r.len(), DEFAULT_TRUST_POLICY_EXCLUDES.len());
1447        for package in DEFAULT_TRUST_POLICY_EXCLUDES {
1448            let (name, versions) = split_name_and_versions(package);
1449            // Bare-name entries exempt every version. Version-scoped entries
1450            // deliberately do not, so they get their own targeted tests.
1451            if versions.is_some() {
1452                continue;
1453            }
1454            assert!(
1455                r.matches(name, &node_semver::Version::parse("1.0.0").unwrap()),
1456                "{name} should be globally excluded"
1457            );
1458        }
1459        assert!(!r.matches("left-pad", &node_semver::Version::parse("1.0.0").unwrap()));
1460    }
1461
1462    #[test]
1463    fn default_excludes_scoped_octokit_endpoint_backport() {
1464        // Regression: @octokit backports fixes to older major lines without
1465        // provenance, so a legitimate older release (e.g. 9.0.6) published
1466        // after an attested newer major (10.1.0) tripped no-downgrade. The
1467        // scoped name must match every version, and the leading `@` must not
1468        // be misparsed as a version separator.
1469        let r = TrustExcludeRules::default();
1470        assert!(r.matches(
1471            "@octokit/endpoint",
1472            &node_semver::Version::parse("9.0.6").unwrap()
1473        ));
1474        assert!(r.matches(
1475            "@octokit/endpoint",
1476            &node_semver::Version::parse("10.1.0").unwrap()
1477        ));
1478        assert!(!r.matches(
1479            "@octokit/core",
1480            &node_semver::Version::parse("9.0.6").unwrap()
1481        ));
1482    }
1483
1484    #[test]
1485    fn default_excludes_scoped_hono_node_server_backport() {
1486        // Regression: 1.19.15 was published without provenance after the
1487        // attested 2.0.10 release. Trusted publishing resumed with 1.19.17,
1488        // so the default must not disable protection for future releases.
1489        let r = TrustExcludeRules::default();
1490        assert!(r.matches(
1491            "@hono/node-server",
1492            &node_semver::Version::parse("1.19.15").unwrap()
1493        ));
1494        assert!(!r.matches(
1495            "@hono/node-server",
1496            &node_semver::Version::parse("1.19.17").unwrap()
1497        ));
1498        assert!(!r.matches(
1499            "@hono/node-server",
1500            &node_semver::Version::parse("2.0.10").unwrap()
1501        ));
1502        assert!(!r.matches("hono", &node_semver::Version::parse("1.19.15").unwrap()));
1503    }
1504
1505    #[test]
1506    fn exclude_parses_name_at_version() {
1507        let r = TrustExcludeRules::parse(["foo@1.0.0"]).unwrap();
1508        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1509        assert!(!r.matches("foo", &node_semver::Version::parse("1.0.1").unwrap()));
1510    }
1511
1512    #[test]
1513    fn exclude_parses_version_union() {
1514        let r = TrustExcludeRules::parse(["foo@1.0.0 || 2.0.0 || 3.0.0"]).unwrap();
1515        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1516        assert!(r.matches("foo", &node_semver::Version::parse("2.0.0").unwrap()));
1517        assert!(r.matches("foo", &node_semver::Version::parse("3.0.0").unwrap()));
1518        assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1519    }
1520
1521    #[test]
1522    fn exclude_parses_scoped_name() {
1523        let r = TrustExcludeRules::parse(["@babel/core@7.20.0"]).unwrap();
1524        assert!(r.matches(
1525            "@babel/core",
1526            &node_semver::Version::parse("7.20.0").unwrap()
1527        ));
1528        assert!(!r.matches(
1529            "@babel/core",
1530            &node_semver::Version::parse("7.20.1").unwrap()
1531        ));
1532    }
1533
1534    #[test]
1535    fn exclude_parses_scoped_name_only() {
1536        let r = TrustExcludeRules::parse(["@babel/core"]).unwrap();
1537        assert!(r.matches(
1538            "@babel/core",
1539            &node_semver::Version::parse("9.9.9").unwrap()
1540        ));
1541    }
1542
1543    #[test]
1544    fn exclude_parses_glob() {
1545        let r = TrustExcludeRules::parse(["is-*"]).unwrap();
1546        assert!(r.matches("is-odd", &node_semver::Version::parse("1.0.0").unwrap()));
1547        assert!(r.matches("is-even", &node_semver::Version::parse("1.0.0").unwrap()));
1548        assert!(!r.matches("lodash", &node_semver::Version::parse("1.0.0").unwrap()));
1549    }
1550
1551    #[test]
1552    fn exclude_parses_star_matches_all() {
1553        let r = TrustExcludeRules::parse(["*"]).unwrap();
1554        assert!(r.matches("anything", &node_semver::Version::parse("0.0.1").unwrap()));
1555    }
1556
1557    #[test]
1558    fn exclude_parses_version_ranges() {
1559        let r = TrustExcludeRules::parse(["foo@^1.0.0 || ~2.1.0 || >=3.0.0 <4.0.0"]).unwrap();
1560        assert!(r.matches("foo", &node_semver::Version::parse("1.2.3").unwrap()));
1561        assert!(r.matches("foo", &node_semver::Version::parse("2.1.9").unwrap()));
1562        assert!(r.matches("foo", &node_semver::Version::parse("3.5.0").unwrap()));
1563        assert!(!r.matches("foo", &node_semver::Version::parse("2.2.0").unwrap()));
1564        assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1565    }
1566
1567    #[test]
1568    fn exclude_rejects_invalid_version_ranges() {
1569        let err = TrustExcludeRules::parse(["foo@definitely-not-a-range"]).expect_err("bad range");
1570        assert!(matches!(
1571            err,
1572            TrustExcludeParseError::InvalidVersionUnion { .. }
1573        ));
1574    }
1575
1576    #[test]
1577    fn exclude_rejects_glob_with_version() {
1578        let err = TrustExcludeRules::parse(["is-*@1.0.0"]).expect_err("glob+version");
1579        assert!(matches!(
1580            err,
1581            TrustExcludeParseError::NameGlobWithVersions { .. }
1582        ));
1583    }
1584
1585    #[test]
1586    fn parse_lossy_keeps_valid_drops_invalid() {
1587        let (rules, errors) = TrustExcludeRules::parse_lossy([
1588            "good",
1589            "bad@definitely-not-a-range",
1590            "@scope/also-good@1.0.0",
1591            "is-*@nope",
1592        ]);
1593        // Two valid rules survive; two invalid surface as separate errors.
1594        assert!(rules.matches("good", &node_semver::Version::parse("1.0.0").unwrap()));
1595        assert!(rules.matches(
1596            "@scope/also-good",
1597            &node_semver::Version::parse("1.0.0").unwrap()
1598        ));
1599        assert_eq!(errors.len(), 2, "two malformed entries reported");
1600    }
1601
1602    #[test]
1603    fn exclude_skips_empty_patterns() {
1604        // npm config arrays sometimes include empty entries; ignore them.
1605        let r = TrustExcludeRules::parse(["", "foo", ""]).unwrap();
1606        assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1607    }
1608}