Skip to main content

aube_resolver/
trust.rs

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