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