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