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