Skip to main content

aube_resolver/
trust.rs

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