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