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