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