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