1use aube_registry::{Packument, VersionMetadata};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21#[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
49pub 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
131pub fn check_no_downgrade(
141 packument: &Packument,
142 picked_version: &str,
143 picked_meta: &VersionMetadata,
144 exclude: &TrustExcludeRules,
145 ignore_after_minutes: Option<u64>,
146) -> Result<(), TrustCheckError> {
147 let picked_parsed = node_semver::Version::parse(picked_version).ok();
148
149 if let Some(ref pv) = picked_parsed {
150 if exclude.matches(&packument.name, pv) {
151 return Ok(());
152 }
153 } else if exclude.matches_name_only(&packument.name) {
154 return Ok(());
155 }
156
157 if packument.time.is_empty() {
167 return Ok(());
168 }
169
170 let Some(picked_time) = packument.time.get(picked_version) else {
171 return Err(TrustCheckError::MissingTime(MissingTimeDetails {
172 name: packument.name.clone(),
173 version: picked_version.to_string(),
174 }));
175 };
176
177 if let Some(minutes) = ignore_after_minutes
178 && minutes > 0
179 && let Some(cutoff) = cutoff_iso8601(minutes)
180 && picked_time.as_str() < cutoff.as_str()
181 {
182 return Ok(());
183 }
184
185 let exclude_prereleases = picked_parsed
189 .as_ref()
190 .map(|v| v.pre_release.is_empty())
191 .unwrap_or(false);
192
193 let mut best: Option<(TrustEvidence, &str)> = None;
194 for (other_ver, other_meta) in &packument.versions {
195 if other_ver == picked_version {
196 continue;
197 }
198 let Some(other_time) = packument.time.get(other_ver) else {
199 continue;
200 };
201 if other_time.as_str() >= picked_time.as_str() {
202 continue;
203 }
204 if exclude_prereleases
205 && let Ok(parsed) = node_semver::Version::parse(other_ver)
206 && !parsed.pre_release.is_empty()
207 {
208 continue;
209 }
210 let Some(evidence) = evidence_for(other_meta) else {
211 continue;
212 };
213 match best {
214 None => best = Some((evidence, other_ver.as_str())),
215 Some((cur, _)) if evidence.rank() > cur.rank() => {
216 best = Some((evidence, other_ver.as_str()));
217 }
218 _ => {}
219 }
220 if matches!(best, Some((TrustEvidence::StagedPublish, _))) {
221 break;
222 }
223 }
224
225 let Some((prior_evidence, prior_version)) = best else {
226 return Ok(());
227 };
228
229 let current = evidence_for(picked_meta);
230 let current_rank = current.map_or(0, TrustEvidence::rank);
231 if current_rank < prior_evidence.rank() {
232 return Err(TrustCheckError::Downgrade(TrustDowngradeDetails {
233 name: packument.name.clone(),
234 picked_version: picked_version.to_string(),
235 current_evidence: current,
236 prior_evidence,
237 prior_version: prior_version.to_string(),
238 }));
239 }
240 Ok(())
241}
242
243fn cutoff_iso8601(minutes_ago: u64) -> Option<String> {
244 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
245 let cutoff_secs = now.saturating_sub(minutes_ago * 60);
246 Some(crate::types::format_iso8601_utc(cutoff_secs))
247}
248
249pub const DEFAULT_TRUST_POLICY_EXCLUDES: &[&str] = &[
255 "chokidar",
256 "eslint-config-prettier",
257 "eslint-import-resolver-typescript",
258 "react-redux",
259 "reselect",
260 "semver",
261 "ua-parser-js",
262 "undici-types",
263 "vite",
264];
265
266pub type PackageVersionPolicy = TrustExcludeRules;
283
284#[derive(Debug, Clone)]
285pub struct TrustExcludeRules {
286 rules: Vec<TrustExcludeRule>,
287}
288
289impl Default for TrustExcludeRules {
290 fn default() -> Self {
291 Self::from_name_excludes(DEFAULT_TRUST_POLICY_EXCLUDES)
292 }
293}
294
295#[derive(Debug, Clone)]
296struct TrustExcludeRule {
297 name_matcher: NameMatcher,
298 exact_versions: Option<Vec<node_semver::Version>>,
301}
302
303#[derive(Debug, Clone)]
304enum NameMatcher {
305 Exact(String),
306 Glob(GlobMatcher),
307 Any,
308}
309
310#[derive(Debug, Clone)]
311struct GlobMatcher {
312 parts: Vec<String>,
313 leading_wildcard: bool,
314 trailing_wildcard: bool,
315}
316
317#[derive(Debug, thiserror::Error, miette::Diagnostic)]
321pub enum TrustExcludeParseError {
322 #[error(
323 "invalid exclude pattern `{pattern}`: only exact versions are allowed in version unions, ranges (^/~/>=) are not supported"
324 )]
325 #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_INVALID_VERSION_UNION))]
326 InvalidVersionUnion { pattern: String },
327 #[error(
328 "invalid exclude pattern `{pattern}`: name patterns (`*`) cannot be combined with version unions"
329 )]
330 #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_NAME_GLOB_WITH_VERSIONS))]
331 NameGlobWithVersions { pattern: String },
332}
333
334impl TrustExcludeRules {
335 pub fn empty() -> Self {
340 Self { rules: Vec::new() }
341 }
342
343 pub fn is_empty(&self) -> bool {
344 self.rules.is_empty()
345 }
346
347 pub fn len(&self) -> usize {
348 self.rules.len()
349 }
350
351 fn from_name_excludes(names: &[&str]) -> Self {
352 Self {
353 rules: names
354 .iter()
355 .map(|name| TrustExcludeRule {
356 name_matcher: NameMatcher::compile(name),
357 exact_versions: None,
358 })
359 .collect(),
360 }
361 }
362
363 pub fn with_defaults_and_user_rules(user_rules: Self) -> Self {
364 let mut rules = Self::default();
365 rules.rules.extend(user_rules.rules);
366 rules
367 }
368
369 pub fn parse<I, S>(patterns: I) -> Result<Self, TrustExcludeParseError>
370 where
371 I: IntoIterator<Item = S>,
372 S: AsRef<str>,
373 {
374 let mut rules = Vec::new();
375 for pattern in patterns {
376 let pattern = pattern.as_ref();
377 if pattern.is_empty() {
378 continue;
379 }
380 rules.push(parse_one(pattern)?);
381 }
382 Ok(Self { rules })
383 }
384
385 pub fn parse_lossy<I, S>(patterns: I) -> (Self, Vec<TrustExcludeParseError>)
392 where
393 I: IntoIterator<Item = S>,
394 S: AsRef<str>,
395 {
396 let mut rules = Vec::new();
397 let mut errors = Vec::new();
398 for pattern in patterns {
399 let pattern = pattern.as_ref();
400 if pattern.is_empty() {
401 continue;
402 }
403 match parse_one(pattern) {
404 Ok(rule) => rules.push(rule),
405 Err(err) => errors.push(err),
406 }
407 }
408 (Self { rules }, errors)
409 }
410
411 pub(crate) fn matches(&self, name: &str, version: &node_semver::Version) -> bool {
412 for rule in &self.rules {
413 if !rule.name_matcher.matches(name) {
414 continue;
415 }
416 match &rule.exact_versions {
417 None => return true,
418 Some(versions) => {
419 if versions.iter().any(|v| v == version) {
420 return true;
421 }
422 }
423 }
424 }
425 false
426 }
427
428 pub(crate) fn matches_name_only(&self, name: &str) -> bool {
433 self.rules
434 .iter()
435 .any(|r| r.exact_versions.is_none() && r.name_matcher.matches(name))
436 }
437}
438
439fn parse_one(pattern: &str) -> Result<TrustExcludeRule, TrustExcludeParseError> {
440 let scoped = pattern.starts_with('@');
441 let at_index = if scoped {
442 pattern[1..].find('@').map(|i| i + 1)
443 } else {
444 pattern.find('@')
445 };
446
447 let (name_part, versions_part) = match at_index {
448 Some(i) => (&pattern[..i], Some(&pattern[i + 1..])),
449 None => (pattern, None),
450 };
451
452 let exact_versions = match versions_part {
453 None => None,
454 Some(versions_str) => {
455 if name_part.contains('*') {
456 return Err(TrustExcludeParseError::NameGlobWithVersions {
457 pattern: pattern.to_string(),
458 });
459 }
460 let mut parsed = Vec::new();
461 for chunk in versions_str.split("||") {
462 let trimmed = chunk.trim();
463 if trimmed.is_empty() {
464 return Err(TrustExcludeParseError::InvalidVersionUnion {
465 pattern: pattern.to_string(),
466 });
467 }
468 let v = node_semver::Version::parse(trimmed).map_err(|_| {
469 TrustExcludeParseError::InvalidVersionUnion {
470 pattern: pattern.to_string(),
471 }
472 })?;
473 parsed.push(v);
474 }
475 Some(parsed)
476 }
477 };
478
479 Ok(TrustExcludeRule {
480 name_matcher: NameMatcher::compile(name_part),
481 exact_versions,
482 })
483}
484
485impl NameMatcher {
486 fn compile(pattern: &str) -> Self {
487 if pattern == "*" {
488 return Self::Any;
489 }
490 if !pattern.contains('*') {
491 return Self::Exact(pattern.to_string());
492 }
493 let parts: Vec<String> = pattern.split('*').map(str::to_string).collect();
494 Self::Glob(GlobMatcher {
495 leading_wildcard: parts.first().is_some_and(String::is_empty),
496 trailing_wildcard: parts.last().is_some_and(String::is_empty),
497 parts: parts.into_iter().filter(|s| !s.is_empty()).collect(),
498 })
499 }
500
501 fn matches(&self, input: &str) -> bool {
502 match self {
503 Self::Any => true,
504 Self::Exact(s) => s == input,
505 Self::Glob(g) => g.matches(input),
506 }
507 }
508}
509
510impl GlobMatcher {
511 fn matches(&self, input: &str) -> bool {
512 if self.parts.is_empty() {
513 return true;
514 }
515 let mut cursor = 0usize;
516 for (i, segment) in self.parts.iter().enumerate() {
517 let search_window = &input[cursor..];
518 let is_first = i == 0;
519 let is_last = i == self.parts.len() - 1;
520 if is_first && !self.leading_wildcard {
521 if !search_window.starts_with(segment.as_str()) {
522 return false;
523 }
524 cursor += segment.len();
525 } else if is_last && !self.trailing_wildcard {
526 if !search_window.ends_with(segment.as_str()) {
527 return false;
528 }
529 if search_window.len() < segment.len() {
530 return false;
531 }
532 cursor = input.len();
533 } else {
534 let Some(idx) = search_window.find(segment.as_str()) else {
535 return false;
536 };
537 cursor += idx + segment.len();
538 }
539 }
540 true
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use aube_registry::{Attestations, Dist, NpmUser};
548 use std::collections::BTreeMap;
549
550 fn version(name: &str, ver: &str) -> VersionMetadata {
551 VersionMetadata {
552 name: name.to_string(),
553 version: ver.to_string(),
554 dependencies: BTreeMap::new(),
555 dev_dependencies: BTreeMap::new(),
556 peer_dependencies: BTreeMap::new(),
557 peer_dependencies_meta: BTreeMap::new(),
558 optional_dependencies: BTreeMap::new(),
559 bundled_dependencies: None,
560 dist: Some(Dist {
561 tarball: format!("https://r/{name}/-/{name}-{ver}.tgz"),
562 integrity: None,
563 shasum: None,
564 unpacked_size: None,
565 attestations: None,
566 }),
567 os: vec![],
568 cpu: vec![],
569 libc: vec![],
570 engines: BTreeMap::new(),
571 license: None,
572 funding_url: None,
573 bin: BTreeMap::new(),
574 has_install_script: false,
575 deprecated: None,
576 approver: None,
577 npm_user: None,
578 }
579 }
580
581 fn with_provenance(mut v: VersionMetadata) -> VersionMetadata {
582 let dist = v.dist.as_mut().unwrap();
583 dist.attestations = Some(Attestations {
584 provenance: Some(serde_json::json!({
585 "predicateType": "https://slsa.dev/provenance/v1"
586 })),
587 });
588 v
589 }
590
591 fn with_trusted_publisher(mut v: VersionMetadata) -> VersionMetadata {
592 v.npm_user = Some(NpmUser {
593 trusted_publisher: Some(serde_json::json!({"id": "gh"})),
594 });
595 v
596 }
597
598 fn with_staged_publish(mut v: VersionMetadata) -> VersionMetadata {
599 v.approver = Some(serde_json::json!({"name": "release-manager"}));
600 v
601 }
602
603 fn packument(name: &str, versions: Vec<(&str, &str, VersionMetadata)>) -> Packument {
604 let mut p = Packument {
605 name: name.to_string(),
606 modified: None,
607 versions: BTreeMap::new(),
608 dist_tags: BTreeMap::new(),
609 time: BTreeMap::new(),
610 };
611 for (ver, time, meta) in versions {
612 p.versions.insert(ver.to_string(), meta);
613 p.time.insert(ver.to_string(), time.to_string());
614 }
615 p
616 }
617
618 #[test]
619 fn evidence_trusted_publisher_outranks_provenance() {
620 let v = with_trusted_publisher(with_provenance(version("foo", "1.0.0")));
621 assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
622 }
623
624 #[test]
625 fn evidence_staged_publish_outranks_trusted_publisher() {
626 let v = with_staged_publish(with_trusted_publisher(with_provenance(version(
627 "foo", "1.0.0",
628 ))));
629 assert_eq!(evidence_for(&v), Some(TrustEvidence::StagedPublish));
630 }
631
632 #[test]
633 fn evidence_provenance_only() {
634 let v = with_provenance(version("foo", "1.0.0"));
635 assert_eq!(evidence_for(&v), Some(TrustEvidence::Provenance));
636 }
637
638 #[test]
639 fn evidence_npm_user_without_trusted_publisher_is_none() {
640 let mut v = version("foo", "1.0.0");
641 v.npm_user = Some(NpmUser {
642 trusted_publisher: None,
643 });
644 assert_eq!(evidence_for(&v), None);
645 }
646
647 #[test]
648 fn evidence_malformed_trusted_publisher_is_none() {
649 let mut v = version("foo", "1.0.0");
650 for malformed in [
651 serde_json::Value::Bool(false),
652 serde_json::Value::Null,
653 serde_json::json!(0),
654 serde_json::json!(0.0),
655 serde_json::json!(""),
656 serde_json::json!([]),
657 serde_json::json!({}),
658 serde_json::json!({"id": ""}),
659 ] {
660 v.npm_user = Some(NpmUser {
661 trusted_publisher: Some(malformed.clone()),
662 });
663 assert_eq!(
664 evidence_for(&v),
665 None,
666 "{malformed:?} should not count as trusted-publisher evidence"
667 );
668 }
669 }
670
671 #[test]
672 fn evidence_empty_approver_is_none() {
673 let mut v = version("foo", "1.0.0");
674 for malformed in [
675 serde_json::Value::Bool(false),
676 serde_json::Value::Null,
677 serde_json::json!(0),
678 serde_json::json!(0.0),
679 serde_json::json!(""),
680 serde_json::json!([]),
681 serde_json::json!([null]),
682 serde_json::json!([false]),
683 serde_json::json!([""]),
684 serde_json::json!([[], {}]),
685 serde_json::json!({}),
686 serde_json::json!({"name": null}),
687 serde_json::json!({"name": null, "email": null}),
688 serde_json::json!({"name": ""}),
689 serde_json::json!({"nested": {}}),
690 ] {
691 v.approver = Some(malformed.clone());
692 assert_eq!(
693 evidence_for(&v),
694 None,
695 "{malformed:?} should not count as staged-publish evidence"
696 );
697 }
698 }
699
700 #[test]
701 fn evidence_truthy_scalar_approver_counts() {
702 let mut v = version("foo", "1.0.0");
703 for approver in [
704 serde_json::Value::Bool(true),
705 serde_json::json!(1),
706 serde_json::json!("release-manager"),
707 serde_json::json!(["release-manager"]),
708 serde_json::json!({"name": "release-manager"}),
709 ] {
710 v.approver = Some(approver.clone());
711 assert_eq!(
712 evidence_for(&v),
713 Some(TrustEvidence::StagedPublish),
714 "{approver:?} should count as staged-publish evidence"
715 );
716 }
717 }
718
719 #[test]
720 fn evidence_malformed_provenance_is_none() {
721 let mut v = version("foo", "1.0.0");
722 for malformed in [
723 serde_json::Value::Bool(false),
724 serde_json::Value::Null,
725 serde_json::json!(0),
726 serde_json::json!(""),
727 serde_json::json!([]),
728 serde_json::json!({}),
729 serde_json::json!({"predicateType": ""}),
730 serde_json::json!({"predicateType": "https://slsa.dev/provenance/"}),
731 serde_json::json!({"predicateType": "https://slsa.dev/provenance/v"}),
732 serde_json::json!({"predicateType": "https://slsa.dev/provenance/latest"}),
733 serde_json::json!({"predicateType": "https://example.com/provenance/v1"}),
734 ] {
735 v.dist.as_mut().unwrap().attestations = Some(Attestations {
736 provenance: Some(malformed.clone()),
737 });
738 assert_eq!(
739 evidence_for(&v),
740 None,
741 "{malformed:?} should not count as provenance evidence"
742 );
743 }
744 }
745
746 #[test]
747 fn evidence_structured_trusted_publisher_counts() {
748 let mut v = version("foo", "1.0.0");
749 v.npm_user = Some(NpmUser {
750 trusted_publisher: Some(serde_json::json!({
751 "id": "github",
752 "oidcConfigId": "oidc:example"
753 })),
754 });
755 assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
756 }
757
758 #[test]
759 fn evidence_none_when_neither() {
760 let v = version("foo", "1.0.0");
761 assert_eq!(evidence_for(&v), None);
762 }
763
764 #[test]
765 fn no_evidence_anywhere_passes() {
766 let p = packument(
767 "foo",
768 vec![
769 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
770 ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
771 ],
772 );
773 let picked = p.versions.get("2.0.0").unwrap();
774 let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
775 assert!(result.is_ok());
776 }
777
778 #[test]
779 fn first_attested_version_passes() {
780 let p = packument(
781 "foo",
782 vec![
783 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
784 (
785 "2.0.0",
786 "2025-02-01T00:00:00.000Z",
787 with_provenance(version("foo", "2.0.0")),
788 ),
789 ],
790 );
791 let picked = p.versions.get("1.0.0").unwrap();
792 let result = check_no_downgrade(&p, "1.0.0", picked, &TrustExcludeRules::default(), None);
793 assert!(
794 result.is_ok(),
795 "version 1.0.0 was published first; it has nothing prior to compare against"
796 );
797 }
798
799 #[test]
800 fn downgrade_provenance_to_none_fails() {
801 let p = packument(
802 "foo",
803 vec![
804 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
805 (
806 "2.0.0",
807 "2025-02-01T00:00:00.000Z",
808 with_provenance(version("foo", "2.0.0")),
809 ),
810 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
811 ],
812 );
813 let picked = p.versions.get("3.0.0").unwrap();
814 let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
815 .expect_err("3.0.0 should fail: prior version had provenance, this one has none");
816 match err {
817 TrustCheckError::Downgrade(d) => {
818 assert_eq!(d.prior_evidence, TrustEvidence::Provenance);
819 assert_eq!(d.prior_version, "2.0.0");
820 assert_eq!(d.current_evidence, None);
821 }
822 _ => panic!("expected Downgrade"),
823 }
824 }
825
826 #[test]
827 fn downgrade_trusted_publisher_to_provenance_fails() {
828 let p = packument(
829 "foo",
830 vec![
831 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
832 (
833 "2.0.0",
834 "2025-02-01T00:00:00.000Z",
835 with_trusted_publisher(version("foo", "2.0.0")),
836 ),
837 (
838 "3.0.0",
839 "2025-03-01T00:00:00.000Z",
840 with_provenance(version("foo", "3.0.0")),
841 ),
842 ],
843 );
844 let picked = p.versions.get("3.0.0").unwrap();
845 let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
846 .expect_err("trustedPublisher → provenance is a downgrade");
847 match err {
848 TrustCheckError::Downgrade(d) => {
849 assert_eq!(d.prior_evidence, TrustEvidence::TrustedPublisher);
850 assert_eq!(d.current_evidence, Some(TrustEvidence::Provenance));
851 }
852 _ => panic!("expected Downgrade"),
853 }
854 }
855
856 #[test]
857 fn downgrade_staged_publish_to_trusted_publisher_fails() {
858 let p = packument(
859 "foo",
860 vec![
861 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
862 (
863 "2.0.0",
864 "2025-02-01T00:00:00.000Z",
865 with_staged_publish(version("foo", "2.0.0")),
866 ),
867 (
868 "3.0.0",
869 "2025-03-01T00:00:00.000Z",
870 with_trusted_publisher(version("foo", "3.0.0")),
871 ),
872 ],
873 );
874 let picked = p.versions.get("3.0.0").unwrap();
875 let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
876 .expect_err("staged publish → trusted publisher is a downgrade");
877 match err {
878 TrustCheckError::Downgrade(d) => {
879 assert_eq!(d.prior_evidence, TrustEvidence::StagedPublish);
880 assert_eq!(d.prior_version, "2.0.0");
881 assert_eq!(d.current_evidence, Some(TrustEvidence::TrustedPublisher));
882 }
883 _ => panic!("expected Downgrade"),
884 }
885 }
886
887 #[test]
888 fn staged_publish_after_trusted_publisher_passes() {
889 let p = packument(
890 "foo",
891 vec![
892 (
893 "1.0.0",
894 "2025-01-01T00:00:00.000Z",
895 with_trusted_publisher(version("foo", "1.0.0")),
896 ),
897 (
898 "2.0.0",
899 "2025-02-01T00:00:00.000Z",
900 with_staged_publish(version("foo", "2.0.0")),
901 ),
902 ],
903 );
904 let picked = p.versions.get("2.0.0").unwrap();
905 let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
906 assert!(result.is_ok());
907 }
908
909 #[test]
910 fn same_trust_level_passes() {
911 let p = packument(
912 "foo",
913 vec![
914 (
915 "2.0.0",
916 "2025-02-01T00:00:00.000Z",
917 with_trusted_publisher(version("foo", "2.0.0")),
918 ),
919 (
920 "3.0.0",
921 "2025-03-01T00:00:00.000Z",
922 with_trusted_publisher(version("foo", "3.0.0")),
923 ),
924 ],
925 );
926 let picked = p.versions.get("3.0.0").unwrap();
927 let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
928 assert!(result.is_ok());
929 }
930
931 #[test]
932 fn prior_prerelease_ignored_when_picking_stable() {
933 let p = packument(
934 "foo",
935 vec![
936 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
937 (
938 "2.0.0-0",
939 "2025-02-01T00:00:00.000Z",
940 with_provenance(version("foo", "2.0.0-0")),
941 ),
942 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
943 ],
944 );
945 let picked = p.versions.get("3.0.0").unwrap();
946 let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
947 assert!(
948 result.is_ok(),
949 "trusted prerelease shouldn't block a stable that omits attestation"
950 );
951 }
952
953 #[test]
954 fn prior_prerelease_counts_when_picking_prerelease() {
955 let p = packument(
956 "foo",
957 vec![
958 (
959 "2.0.0-0",
960 "2025-02-01T00:00:00.000Z",
961 with_provenance(version("foo", "2.0.0-0")),
962 ),
963 (
964 "3.0.0-0",
965 "2025-03-01T00:00:00.000Z",
966 version("foo", "3.0.0-0"),
967 ),
968 ],
969 );
970 let picked = p.versions.get("3.0.0-0").unwrap();
971 let result = check_no_downgrade(&p, "3.0.0-0", picked, &TrustExcludeRules::default(), None);
972 assert!(
973 result.is_err(),
974 "prerelease pick should compare against prior prereleases"
975 );
976 }
977
978 #[test]
983 fn empty_time_map_skips_check() {
984 let p = Packument {
985 name: "foo".to_string(),
986 modified: None,
987 versions: {
988 let mut m = BTreeMap::new();
989 m.insert(
990 "1.0.0".to_string(),
991 with_provenance(version("foo", "1.0.0")),
992 );
993 m.insert("2.0.0".to_string(), version("foo", "2.0.0"));
994 m
995 },
996 dist_tags: BTreeMap::new(),
997 time: BTreeMap::new(), };
999 let picked = p.versions.get("2.0.0").unwrap();
1000 let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
1003 assert!(result.is_ok(), "empty time map should skip the check");
1004 }
1005
1006 #[test]
1007 fn missing_time_for_picked_version_errors() {
1008 let mut p = packument(
1009 "foo",
1010 vec![
1011 (
1012 "1.0.0",
1013 "2025-01-01T00:00:00.000Z",
1014 with_provenance(version("foo", "1.0.0")),
1015 ),
1016 ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
1017 ],
1018 );
1019 p.time.remove("2.0.0");
1021 let picked = p.versions.get("2.0.0").unwrap();
1022 let err = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None)
1023 .expect_err("missing time should error");
1024 assert!(matches!(err, TrustCheckError::MissingTime(_)));
1025 }
1026
1027 #[test]
1028 fn exclude_name_at_version_bypasses_missing_time() {
1029 let p = Packument {
1031 name: "baz".to_string(),
1032 modified: None,
1033 versions: {
1034 let mut m = BTreeMap::new();
1035 m.insert("1.0.0".to_string(), version("baz", "1.0.0"));
1036 m
1037 },
1038 dist_tags: BTreeMap::new(),
1039 time: BTreeMap::new(),
1040 };
1041 let picked = p.versions.get("1.0.0").unwrap();
1042 let exclude = TrustExcludeRules::parse(["baz@1.0.0"]).unwrap();
1043 let result = check_no_downgrade(&p, "1.0.0", picked, &exclude, None);
1044 assert!(result.is_ok(), "excluded version must skip the time lookup");
1045 }
1046
1047 #[test]
1048 fn exclude_name_only_bypasses_missing_time() {
1049 let p = Packument {
1050 name: "qux".to_string(),
1051 modified: None,
1052 versions: {
1053 let mut m = BTreeMap::new();
1054 m.insert("2.0.0".to_string(), version("qux", "2.0.0"));
1055 m
1056 },
1057 dist_tags: BTreeMap::new(),
1058 time: BTreeMap::new(),
1059 };
1060 let picked = p.versions.get("2.0.0").unwrap();
1061 let exclude = TrustExcludeRules::parse(["qux"]).unwrap();
1062 let result = check_no_downgrade(&p, "2.0.0", picked, &exclude, None);
1063 assert!(result.is_ok());
1064 }
1065
1066 #[test]
1067 fn exclude_blocks_downgrade_failure() {
1068 let p = packument(
1069 "foo",
1070 vec![
1071 (
1072 "2.0.0",
1073 "2025-02-01T00:00:00.000Z",
1074 with_provenance(version("foo", "2.0.0")),
1075 ),
1076 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1077 ],
1078 );
1079 let picked = p.versions.get("3.0.0").unwrap();
1080 let exclude = TrustExcludeRules::parse(["foo@3.0.0"]).unwrap();
1081 let result = check_no_downgrade(&p, "3.0.0", picked, &exclude, None);
1082 assert!(result.is_ok(), "exclude should bypass the downgrade");
1083 }
1084
1085 #[test]
1086 fn ignore_after_skips_old_versions() {
1087 let p = packument(
1088 "foo",
1089 vec![
1090 (
1091 "2.0.0",
1092 "2025-02-01T00:00:00.000Z",
1093 with_provenance(version("foo", "2.0.0")),
1094 ),
1095 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1096 ],
1097 );
1098 let picked = p.versions.get("3.0.0").unwrap();
1099 let result =
1101 check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), Some(1));
1102 assert!(result.is_ok());
1103 }
1104
1105 #[test]
1108 fn exclude_parses_name_only() {
1109 let r = TrustExcludeRules::parse(["foo"]).unwrap();
1110 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1111 assert!(r.matches("foo", &node_semver::Version::parse("99.0.0").unwrap()));
1112 assert!(!r.matches("bar", &node_semver::Version::parse("1.0.0").unwrap()));
1113 }
1114
1115 #[test]
1116 fn default_excludes_known_provenance_churn_packages() {
1117 let r = TrustExcludeRules::default();
1118 for package in DEFAULT_TRUST_POLICY_EXCLUDES {
1119 assert!(
1120 r.matches(package, &node_semver::Version::parse("1.0.0").unwrap()),
1121 "{package} should be globally excluded"
1122 );
1123 }
1124 assert!(!r.matches("left-pad", &node_semver::Version::parse("1.0.0").unwrap()));
1125 }
1126
1127 #[test]
1128 fn exclude_parses_name_at_version() {
1129 let r = TrustExcludeRules::parse(["foo@1.0.0"]).unwrap();
1130 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1131 assert!(!r.matches("foo", &node_semver::Version::parse("1.0.1").unwrap()));
1132 }
1133
1134 #[test]
1135 fn exclude_parses_version_union() {
1136 let r = TrustExcludeRules::parse(["foo@1.0.0 || 2.0.0 || 3.0.0"]).unwrap();
1137 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1138 assert!(r.matches("foo", &node_semver::Version::parse("2.0.0").unwrap()));
1139 assert!(r.matches("foo", &node_semver::Version::parse("3.0.0").unwrap()));
1140 assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1141 }
1142
1143 #[test]
1144 fn exclude_parses_scoped_name() {
1145 let r = TrustExcludeRules::parse(["@babel/core@7.20.0"]).unwrap();
1146 assert!(r.matches(
1147 "@babel/core",
1148 &node_semver::Version::parse("7.20.0").unwrap()
1149 ));
1150 assert!(!r.matches(
1151 "@babel/core",
1152 &node_semver::Version::parse("7.20.1").unwrap()
1153 ));
1154 }
1155
1156 #[test]
1157 fn exclude_parses_scoped_name_only() {
1158 let r = TrustExcludeRules::parse(["@babel/core"]).unwrap();
1159 assert!(r.matches(
1160 "@babel/core",
1161 &node_semver::Version::parse("9.9.9").unwrap()
1162 ));
1163 }
1164
1165 #[test]
1166 fn exclude_parses_glob() {
1167 let r = TrustExcludeRules::parse(["is-*"]).unwrap();
1168 assert!(r.matches("is-odd", &node_semver::Version::parse("1.0.0").unwrap()));
1169 assert!(r.matches("is-even", &node_semver::Version::parse("1.0.0").unwrap()));
1170 assert!(!r.matches("lodash", &node_semver::Version::parse("1.0.0").unwrap()));
1171 }
1172
1173 #[test]
1174 fn exclude_parses_star_matches_all() {
1175 let r = TrustExcludeRules::parse(["*"]).unwrap();
1176 assert!(r.matches("anything", &node_semver::Version::parse("0.0.1").unwrap()));
1177 }
1178
1179 #[test]
1180 fn exclude_rejects_range_operators() {
1181 for bad in ["foo@^1.0.0", "foo@~1.0.0", "foo@>=1.0.0"] {
1182 let err = TrustExcludeRules::parse([bad]).expect_err(bad);
1183 assert!(matches!(
1184 err,
1185 TrustExcludeParseError::InvalidVersionUnion { .. }
1186 ));
1187 }
1188 }
1189
1190 #[test]
1191 fn exclude_rejects_glob_with_version() {
1192 let err = TrustExcludeRules::parse(["is-*@1.0.0"]).expect_err("glob+version");
1193 assert!(matches!(
1194 err,
1195 TrustExcludeParseError::NameGlobWithVersions { .. }
1196 ));
1197 }
1198
1199 #[test]
1200 fn parse_lossy_keeps_valid_drops_invalid() {
1201 let (rules, errors) = TrustExcludeRules::parse_lossy([
1202 "good",
1203 "bad@^1.0.0",
1204 "@scope/also-good@1.0.0",
1205 "is-*@nope",
1206 ]);
1207 assert!(rules.matches("good", &node_semver::Version::parse("1.0.0").unwrap()));
1209 assert!(rules.matches(
1210 "@scope/also-good",
1211 &node_semver::Version::parse("1.0.0").unwrap()
1212 ));
1213 assert_eq!(errors.len(), 2, "two malformed entries reported");
1214 }
1215
1216 #[test]
1217 fn exclude_skips_empty_patterns() {
1218 let r = TrustExcludeRules::parse(["", "foo", ""]).unwrap();
1220 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1221 }
1222}