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 compact_evidence_for(meta: &aube_registry::VersionTrustMetadata) -> Option<TrustEvidence> {
77 if meta.approver.as_ref().is_some_and(is_approver) {
78 return Some(TrustEvidence::StagedPublish);
79 }
80 if meta
81 .npm_user
82 .as_ref()
83 .and_then(|user| user.trusted_publisher.as_ref())
84 .is_some_and(is_trusted_publisher)
85 {
86 return Some(TrustEvidence::TrustedPublisher);
87 }
88 if meta
89 .dist
90 .as_ref()
91 .and_then(|dist| dist.attestations.as_ref())
92 .and_then(|attestations| attestations.provenance.as_ref())
93 .is_some_and(is_provenance)
94 {
95 return Some(TrustEvidence::Provenance);
96 }
97 None
98}
99
100fn is_approver(v: &serde_json::Value) -> bool {
101 match v {
102 serde_json::Value::Null => false,
103 serde_json::Value::String(s) => !s.is_empty(),
104 serde_json::Value::Array(a) => a.iter().any(is_approver),
105 serde_json::Value::Object(o) => o.values().any(is_approver),
106 serde_json::Value::Bool(b) => *b,
107 serde_json::Value::Number(n) => {
108 n.as_i64().is_some_and(|i| i != 0)
109 || n.as_u64().is_some_and(|u| u != 0)
110 || n.as_f64().is_some_and(|f| f != 0.0)
111 }
112 }
113}
114
115fn is_trusted_publisher(v: &serde_json::Value) -> bool {
116 v.as_object()
117 .and_then(|o| o.get("id"))
118 .and_then(|id| id.as_str())
119 .is_some_and(|id| !id.is_empty())
120}
121
122fn is_provenance(v: &serde_json::Value) -> bool {
123 v.as_object()
124 .and_then(|o| o.get("predicateType"))
125 .and_then(|predicate| predicate.as_str())
126 .is_some_and(|predicate| {
127 predicate
128 .strip_prefix("https://slsa.dev/provenance/v")
129 .and_then(|suffix| suffix.chars().next())
130 .is_some_and(|c| c.is_ascii_digit())
131 })
132}
133
134#[derive(Debug)]
135pub enum TrustCheckError {
136 Downgrade(TrustDowngradeDetails),
137 MissingTime(MissingTimeDetails),
138}
139
140#[derive(Debug)]
141pub struct TrustDowngradeDetails {
142 pub name: String,
143 pub picked_version: String,
144 pub current_evidence: Option<TrustEvidence>,
145 pub prior_evidence: TrustEvidence,
146 pub prior_version: String,
147}
148
149#[derive(Debug)]
150pub struct MissingTimeDetails {
151 pub name: String,
152 pub version: String,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct PriorTrustEvidence {
159 pub version: String,
160 pub evidence: TrustEvidence,
161}
162
163pub fn strongest_prior_evidence(
170 packument: &Packument,
171 picked_version: &str,
172) -> Option<PriorTrustEvidence> {
173 let picked_time = packument.time.get(picked_version)?;
174 let exclude_prereleases = node_semver::Version::parse(picked_version)
175 .map(|v| v.pre_release.is_empty())
176 .unwrap_or(false);
177
178 let mut best: Option<PriorTrustEvidence> = None;
179 for (other_ver, other_meta) in &packument.versions {
180 if other_ver == picked_version {
181 continue;
182 }
183 let Some(other_time) = packument.time.get(other_ver) else {
184 continue;
185 };
186 if other_time.as_str() >= picked_time.as_str() {
187 continue;
188 }
189 if exclude_prereleases
190 && let Ok(parsed) = node_semver::Version::parse(other_ver)
191 && !parsed.pre_release.is_empty()
192 {
193 continue;
194 }
195 let Some(evidence) = evidence_for(other_meta) else {
196 continue;
197 };
198 match best {
199 None => {
200 best = Some(PriorTrustEvidence {
201 version: other_ver.clone(),
202 evidence,
203 });
204 }
205 Some(ref current) if evidence.rank() > current.evidence.rank() => {
206 best = Some(PriorTrustEvidence {
207 version: other_ver.clone(),
208 evidence,
209 });
210 }
211 _ => {}
212 }
213 if matches!(
214 best,
215 Some(PriorTrustEvidence {
216 evidence: TrustEvidence::StagedPublish,
217 ..
218 })
219 ) {
220 break;
221 }
222 }
223 best
224}
225
226pub fn check_no_downgrade(
236 packument: &Packument,
237 picked_version: &str,
238 picked_meta: &VersionMetadata,
239 exclude: &TrustExcludeRules,
240 ignore_after_minutes: Option<u64>,
241) -> Result<(), TrustCheckError> {
242 let picked_parsed = node_semver::Version::parse(picked_version).ok();
243
244 if let Some(ref pv) = picked_parsed {
245 if exclude.matches(&packument.name, pv) {
246 return Ok(());
247 }
248 } else if exclude.matches_name_only(&packument.name) {
249 return Ok(());
250 }
251
252 if packument.time.is_empty() {
262 return Ok(());
263 }
264
265 let Some(picked_time) = packument.time.get(picked_version) else {
266 return Err(TrustCheckError::MissingTime(MissingTimeDetails {
267 name: packument.name.clone(),
268 version: picked_version.to_string(),
269 }));
270 };
271
272 if let Some(minutes) = ignore_after_minutes
273 && minutes > 0
274 && let Some(cutoff) = cutoff_iso8601(minutes)
275 && picked_time.as_str() < cutoff.as_str()
276 {
277 return Ok(());
278 }
279
280 let Some(prior) = strongest_prior_evidence(packument, picked_version) else {
281 return Ok(());
282 };
283
284 let current = evidence_for(picked_meta);
285 let current_rank = current.map_or(0, TrustEvidence::rank);
286 if current_rank < prior.evidence.rank() {
287 return Err(TrustCheckError::Downgrade(TrustDowngradeDetails {
288 name: packument.name.clone(),
289 picked_version: picked_version.to_string(),
290 current_evidence: current,
291 prior_evidence: prior.evidence,
292 prior_version: prior.version,
293 }));
294 }
295 Ok(())
296}
297
298pub fn check_no_downgrade_compact(
302 packument: &Packument,
303 picked_version: &str,
304 picked_meta: &VersionMetadata,
305 history: &std::collections::BTreeMap<String, aube_registry::VersionTrustMetadata>,
306 exclude: &TrustExcludeRules,
307 ignore_after_minutes: Option<u64>,
308) -> Result<(), TrustCheckError> {
309 let picked_parsed = node_semver::Version::parse(picked_version).ok();
310 if let Some(ref version) = picked_parsed {
311 if exclude.matches(&packument.name, version) {
312 return Ok(());
313 }
314 } else if exclude.matches_name_only(&packument.name) {
315 return Ok(());
316 }
317 if packument.time.is_empty() {
318 return Ok(());
319 }
320 let Some(picked_time) = packument.time.get(picked_version) else {
321 return Err(TrustCheckError::MissingTime(MissingTimeDetails {
322 name: packument.name.clone(),
323 version: picked_version.to_string(),
324 }));
325 };
326 if let Some(minutes) = ignore_after_minutes
327 && minutes > 0
328 && let Some(cutoff) = cutoff_iso8601(minutes)
329 && picked_time.as_str() < cutoff.as_str()
330 {
331 return Ok(());
332 }
333
334 let exclude_prereleases = picked_parsed
335 .as_ref()
336 .map(|version| version.pre_release.is_empty())
337 .unwrap_or(false);
338 let mut strongest: Option<PriorTrustEvidence> = None;
339 for (version, meta) in history {
340 if version == picked_version {
341 continue;
342 }
343 let Some(published_at) = packument.time.get(version) else {
344 continue;
345 };
346 if published_at >= picked_time {
347 continue;
348 }
349 if exclude_prereleases
350 && let Ok(parsed) = node_semver::Version::parse(version)
351 && !parsed.pre_release.is_empty()
352 {
353 continue;
354 }
355 let Some(evidence) = compact_evidence_for(meta) else {
356 continue;
357 };
358 if strongest
359 .as_ref()
360 .is_none_or(|current| evidence.rank() > current.evidence.rank())
361 {
362 strongest = Some(PriorTrustEvidence {
363 version: version.clone(),
364 evidence,
365 });
366 }
367 if strongest
368 .as_ref()
369 .is_some_and(|prior| prior.evidence == TrustEvidence::StagedPublish)
370 {
371 break;
372 }
373 }
374 let Some(prior) = strongest else {
375 return Ok(());
376 };
377 let current = evidence_for(picked_meta);
378 if current.map_or(0, TrustEvidence::rank) < prior.evidence.rank() {
379 return Err(TrustCheckError::Downgrade(TrustDowngradeDetails {
380 name: packument.name.clone(),
381 picked_version: picked_version.to_string(),
382 current_evidence: current,
383 prior_evidence: prior.evidence,
384 prior_version: prior.version,
385 }));
386 }
387 Ok(())
388}
389
390fn cutoff_iso8601(minutes_ago: u64) -> Option<String> {
391 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
392 let cutoff_secs = now.saturating_sub(minutes_ago * 60);
393 Some(crate::types::format_iso8601_utc(cutoff_secs))
394}
395
396pub const DEFAULT_TRUST_POLICY_EXCLUDES: &[&str] = &[
402 "@octokit/endpoint",
407 "@hono/node-server@1.19.15",
411 "chokidar",
412 "eslint-config-prettier",
413 "eslint-import-resolver-typescript",
414 "react-redux",
415 "reselect",
416 "semver",
417 "ua-parser-js",
418 "undici",
419 "undici-types",
420 "vite",
421];
422
423pub type PackageVersionPolicy = TrustExcludeRules;
440
441#[derive(Debug, Clone)]
442pub struct TrustExcludeRules {
443 rules: Vec<TrustExcludeRule>,
444}
445
446impl Default for TrustExcludeRules {
447 fn default() -> Self {
448 Self::parse(DEFAULT_TRUST_POLICY_EXCLUDES)
459 .expect("DEFAULT_TRUST_POLICY_EXCLUDES must be valid exclude patterns")
460 }
461}
462
463#[derive(Debug, Clone)]
464struct TrustExcludeRule {
465 name_matcher: NameMatcher,
466 version_ranges: Option<Vec<node_semver::Range>>,
469}
470
471#[derive(Debug, Clone)]
472enum NameMatcher {
473 Exact(String),
474 Glob(GlobMatcher),
475 Any,
476}
477
478#[derive(Debug, Clone)]
479struct GlobMatcher {
480 parts: Vec<String>,
481 leading_wildcard: bool,
482 trailing_wildcard: bool,
483}
484
485#[derive(Debug, thiserror::Error, miette::Diagnostic)]
489pub enum TrustExcludeParseError {
490 #[error("invalid exclude pattern `{pattern}`: version selectors must be valid semver ranges")]
491 #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_INVALID_VERSION_UNION))]
492 InvalidVersionUnion { pattern: String },
493 #[error(
494 "invalid exclude pattern `{pattern}`: name patterns (`*`) cannot be combined with version unions"
495 )]
496 #[diagnostic(code(ERR_AUBE_TRUST_EXCLUDE_NAME_GLOB_WITH_VERSIONS))]
497 NameGlobWithVersions { pattern: String },
498}
499
500impl TrustExcludeRules {
501 pub fn empty() -> Self {
506 Self { rules: Vec::new() }
507 }
508
509 pub fn is_empty(&self) -> bool {
510 self.rules.is_empty()
511 }
512
513 pub fn len(&self) -> usize {
514 self.rules.len()
515 }
516
517 pub fn excludes(&self, name: &str, version: &str) -> bool {
519 match node_semver::Version::parse(version) {
520 Ok(version) => self.matches(name, &version),
521 Err(_) => self.matches_name_only(name),
522 }
523 }
524
525 pub fn with_defaults_and_user_rules(user_rules: Self) -> Self {
526 let mut rules = Self::default();
527 rules.rules.extend(user_rules.rules);
528 rules
529 }
530
531 pub fn parse<I, S>(patterns: I) -> Result<Self, TrustExcludeParseError>
532 where
533 I: IntoIterator<Item = S>,
534 S: AsRef<str>,
535 {
536 let mut rules = Vec::new();
537 for pattern in patterns {
538 let pattern = pattern.as_ref();
539 if pattern.is_empty() {
540 continue;
541 }
542 rules.push(parse_one(pattern)?);
543 }
544 Ok(Self { rules })
545 }
546
547 pub fn parse_lossy<I, S>(patterns: I) -> (Self, Vec<TrustExcludeParseError>)
554 where
555 I: IntoIterator<Item = S>,
556 S: AsRef<str>,
557 {
558 let mut rules = Vec::new();
559 let mut errors = Vec::new();
560 for pattern in patterns {
561 let pattern = pattern.as_ref();
562 if pattern.is_empty() {
563 continue;
564 }
565 match parse_one(pattern) {
566 Ok(rule) => rules.push(rule),
567 Err(err) => errors.push(err),
568 }
569 }
570 (Self { rules }, errors)
571 }
572
573 pub(crate) fn matches(&self, name: &str, version: &node_semver::Version) -> bool {
574 for rule in &self.rules {
575 if !rule.name_matcher.matches(name) {
576 continue;
577 }
578 match &rule.version_ranges {
579 None => return true,
580 Some(ranges) => {
581 if ranges.iter().any(|r| version.satisfies(r)) {
582 return true;
583 }
584 }
585 }
586 }
587 false
588 }
589
590 pub(crate) fn matches_name_only(&self, name: &str) -> bool {
595 self.rules
596 .iter()
597 .any(|r| r.version_ranges.is_none() && r.name_matcher.matches(name))
598 }
599}
600
601fn split_name_and_versions(pattern: &str) -> (&str, Option<&str>) {
604 let at_index = match pattern.strip_prefix('@') {
605 Some(rest) => rest.find('@').map(|i| i + 1),
608 None => pattern.find('@'),
609 };
610 match at_index {
611 Some(i) => (&pattern[..i], Some(&pattern[i + 1..])),
612 None => (pattern, None),
613 }
614}
615
616fn parse_one(pattern: &str) -> Result<TrustExcludeRule, TrustExcludeParseError> {
617 let (name_part, versions_part) = split_name_and_versions(pattern);
618
619 let version_ranges = match versions_part {
620 None => None,
621 Some(versions_str) => {
622 if name_part.contains('*') {
623 return Err(TrustExcludeParseError::NameGlobWithVersions {
624 pattern: pattern.to_string(),
625 });
626 }
627 let mut parsed = Vec::new();
628 for chunk in versions_str.split("||") {
629 let trimmed = chunk.trim();
630 if trimmed.is_empty() {
631 return Err(TrustExcludeParseError::InvalidVersionUnion {
632 pattern: pattern.to_string(),
633 });
634 }
635 let r = node_semver::Range::parse(trimmed).map_err(|_| {
636 TrustExcludeParseError::InvalidVersionUnion {
637 pattern: pattern.to_string(),
638 }
639 })?;
640 parsed.push(r);
641 }
642 Some(parsed)
643 }
644 };
645
646 Ok(TrustExcludeRule {
647 name_matcher: NameMatcher::compile(name_part),
648 version_ranges,
649 })
650}
651
652impl NameMatcher {
653 fn compile(pattern: &str) -> Self {
654 if pattern == "*" {
655 return Self::Any;
656 }
657 if !pattern.contains('*') {
658 return Self::Exact(pattern.to_string());
659 }
660 let parts: Vec<String> = pattern.split('*').map(str::to_string).collect();
661 Self::Glob(GlobMatcher {
662 leading_wildcard: parts.first().is_some_and(String::is_empty),
663 trailing_wildcard: parts.last().is_some_and(String::is_empty),
664 parts: parts.into_iter().filter(|s| !s.is_empty()).collect(),
665 })
666 }
667
668 fn matches(&self, input: &str) -> bool {
669 match self {
670 Self::Any => true,
671 Self::Exact(s) => s == input,
672 Self::Glob(g) => g.matches(input),
673 }
674 }
675}
676
677impl GlobMatcher {
678 fn matches(&self, input: &str) -> bool {
679 if self.parts.is_empty() {
680 return true;
681 }
682 let mut cursor = 0usize;
683 for (i, segment) in self.parts.iter().enumerate() {
684 let search_window = &input[cursor..];
685 let is_first = i == 0;
686 let is_last = i == self.parts.len() - 1;
687 if is_first && !self.leading_wildcard {
688 if !search_window.starts_with(segment.as_str()) {
689 return false;
690 }
691 cursor += segment.len();
692 } else if is_last && !self.trailing_wildcard {
693 if !search_window.ends_with(segment.as_str()) {
694 return false;
695 }
696 if search_window.len() < segment.len() {
697 return false;
698 }
699 cursor = input.len();
700 } else {
701 let Some(idx) = search_window.find(segment.as_str()) else {
702 return false;
703 };
704 cursor += idx + segment.len();
705 }
706 }
707 true
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use aube_registry::{Attestations, Dist, NpmUser};
715 use std::collections::BTreeMap;
716
717 fn version(name: &str, ver: &str) -> VersionMetadata {
718 VersionMetadata {
719 name: name.to_string(),
720 version: ver.to_string(),
721 dependencies: BTreeMap::new(),
722 dev_dependencies: BTreeMap::new(),
723 peer_dependencies: BTreeMap::new(),
724 peer_dependencies_meta: BTreeMap::new(),
725 optional_dependencies: BTreeMap::new(),
726 bundled_dependencies: None,
727 dist: Some(Dist {
728 tarball: format!("https://r/{name}/-/{name}-{ver}.tgz"),
729 integrity: None,
730 shasum: None,
731 unpacked_size: None,
732 attestations: None,
733 }),
734 os: vec![],
735 cpu: vec![],
736 libc: vec![],
737 engines: BTreeMap::new(),
738 license: None,
739 funding_url: None,
740 bin: BTreeMap::new(),
741 has_install_script: false,
742 deprecated: None,
743 approver: None,
744 npm_user: None,
745 }
746 }
747
748 fn with_provenance(mut v: VersionMetadata) -> VersionMetadata {
749 let dist = v.dist.as_mut().unwrap();
750 dist.attestations = Some(Attestations {
751 provenance: Some(serde_json::json!({
752 "predicateType": "https://slsa.dev/provenance/v1"
753 })),
754 });
755 v
756 }
757
758 fn with_trusted_publisher(mut v: VersionMetadata) -> VersionMetadata {
759 v.npm_user = Some(NpmUser {
760 trusted_publisher: Some(serde_json::json!({"id": "gh"})),
761 });
762 v
763 }
764
765 fn with_staged_publish(mut v: VersionMetadata) -> VersionMetadata {
766 v.approver = Some(serde_json::json!({"name": "release-manager"}));
767 v
768 }
769
770 fn packument(name: &str, versions: Vec<(&str, &str, VersionMetadata)>) -> Packument {
771 let mut p = Packument {
772 name: name.to_string(),
773 modified: None,
774 versions: BTreeMap::new(),
775 dist_tags: BTreeMap::new(),
776 time: BTreeMap::new(),
777 };
778 for (ver, time, meta) in versions {
779 p.versions.insert(ver.to_string(), meta);
780 p.time.insert(ver.to_string(), time.to_string());
781 }
782 p
783 }
784
785 #[test]
786 fn evidence_trusted_publisher_outranks_provenance() {
787 let v = with_trusted_publisher(with_provenance(version("foo", "1.0.0")));
788 assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
789 }
790
791 #[test]
792 fn evidence_staged_publish_outranks_trusted_publisher() {
793 let v = with_staged_publish(with_trusted_publisher(with_provenance(version(
794 "foo", "1.0.0",
795 ))));
796 assert_eq!(evidence_for(&v), Some(TrustEvidence::StagedPublish));
797 }
798
799 #[test]
800 fn evidence_provenance_only() {
801 let v = with_provenance(version("foo", "1.0.0"));
802 assert_eq!(evidence_for(&v), Some(TrustEvidence::Provenance));
803 }
804
805 #[test]
806 fn evidence_npm_user_without_trusted_publisher_is_none() {
807 let mut v = version("foo", "1.0.0");
808 v.npm_user = Some(NpmUser {
809 trusted_publisher: None,
810 });
811 assert_eq!(evidence_for(&v), None);
812 }
813
814 #[test]
815 fn evidence_malformed_trusted_publisher_is_none() {
816 let mut v = version("foo", "1.0.0");
817 for malformed in [
818 serde_json::Value::Bool(false),
819 serde_json::Value::Null,
820 serde_json::json!(0),
821 serde_json::json!(0.0),
822 serde_json::json!(""),
823 serde_json::json!([]),
824 serde_json::json!({}),
825 serde_json::json!({"id": ""}),
826 ] {
827 v.npm_user = Some(NpmUser {
828 trusted_publisher: Some(malformed.clone()),
829 });
830 assert_eq!(
831 evidence_for(&v),
832 None,
833 "{malformed:?} should not count as trusted-publisher evidence"
834 );
835 }
836 }
837
838 #[test]
839 fn evidence_empty_approver_is_none() {
840 let mut v = version("foo", "1.0.0");
841 for malformed in [
842 serde_json::Value::Bool(false),
843 serde_json::Value::Null,
844 serde_json::json!(0),
845 serde_json::json!(0.0),
846 serde_json::json!(""),
847 serde_json::json!([]),
848 serde_json::json!([null]),
849 serde_json::json!([false]),
850 serde_json::json!([""]),
851 serde_json::json!([[], {}]),
852 serde_json::json!({}),
853 serde_json::json!({"name": null}),
854 serde_json::json!({"name": null, "email": null}),
855 serde_json::json!({"name": ""}),
856 serde_json::json!({"nested": {}}),
857 ] {
858 v.approver = Some(malformed.clone());
859 assert_eq!(
860 evidence_for(&v),
861 None,
862 "{malformed:?} should not count as staged-publish evidence"
863 );
864 }
865 }
866
867 #[test]
868 fn evidence_truthy_scalar_approver_counts() {
869 let mut v = version("foo", "1.0.0");
870 for approver in [
871 serde_json::Value::Bool(true),
872 serde_json::json!(1),
873 serde_json::json!("release-manager"),
874 serde_json::json!(["release-manager"]),
875 serde_json::json!({"name": "release-manager"}),
876 ] {
877 v.approver = Some(approver.clone());
878 assert_eq!(
879 evidence_for(&v),
880 Some(TrustEvidence::StagedPublish),
881 "{approver:?} should count as staged-publish evidence"
882 );
883 }
884 }
885
886 #[test]
887 fn evidence_malformed_provenance_is_none() {
888 let mut v = version("foo", "1.0.0");
889 for malformed in [
890 serde_json::Value::Bool(false),
891 serde_json::Value::Null,
892 serde_json::json!(0),
893 serde_json::json!(""),
894 serde_json::json!([]),
895 serde_json::json!({}),
896 serde_json::json!({"predicateType": ""}),
897 serde_json::json!({"predicateType": "https://slsa.dev/provenance/"}),
898 serde_json::json!({"predicateType": "https://slsa.dev/provenance/v"}),
899 serde_json::json!({"predicateType": "https://slsa.dev/provenance/latest"}),
900 serde_json::json!({"predicateType": "https://example.com/provenance/v1"}),
901 ] {
902 v.dist.as_mut().unwrap().attestations = Some(Attestations {
903 provenance: Some(malformed.clone()),
904 });
905 assert_eq!(
906 evidence_for(&v),
907 None,
908 "{malformed:?} should not count as provenance evidence"
909 );
910 }
911 }
912
913 #[test]
914 fn evidence_structured_trusted_publisher_counts() {
915 let mut v = version("foo", "1.0.0");
916 v.npm_user = Some(NpmUser {
917 trusted_publisher: Some(serde_json::json!({
918 "id": "github",
919 "oidcConfigId": "oidc:example"
920 })),
921 });
922 assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
923 }
924
925 #[test]
926 fn evidence_none_when_neither() {
927 let v = version("foo", "1.0.0");
928 assert_eq!(evidence_for(&v), None);
929 }
930
931 #[test]
932 fn no_evidence_anywhere_passes() {
933 let p = packument(
934 "foo",
935 vec![
936 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
937 ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
938 ],
939 );
940 let picked = p.versions.get("2.0.0").unwrap();
941 let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
942 assert!(result.is_ok());
943 }
944
945 #[test]
946 fn first_attested_version_passes() {
947 let p = packument(
948 "foo",
949 vec![
950 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
951 (
952 "2.0.0",
953 "2025-02-01T00:00:00.000Z",
954 with_provenance(version("foo", "2.0.0")),
955 ),
956 ],
957 );
958 let picked = p.versions.get("1.0.0").unwrap();
959 let result = check_no_downgrade(&p, "1.0.0", picked, &TrustExcludeRules::default(), None);
960 assert!(
961 result.is_ok(),
962 "version 1.0.0 was published first; it has nothing prior to compare against"
963 );
964 }
965
966 #[test]
967 fn downgrade_provenance_to_none_fails() {
968 let p = packument(
969 "foo",
970 vec![
971 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
972 (
973 "2.0.0",
974 "2025-02-01T00:00:00.000Z",
975 with_provenance(version("foo", "2.0.0")),
976 ),
977 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
978 ],
979 );
980 let picked = p.versions.get("3.0.0").unwrap();
981 let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
982 .expect_err("3.0.0 should fail: prior version had provenance, this one has none");
983 match err {
984 TrustCheckError::Downgrade(d) => {
985 assert_eq!(d.prior_evidence, TrustEvidence::Provenance);
986 assert_eq!(d.prior_version, "2.0.0");
987 assert_eq!(d.current_evidence, None);
988 }
989 _ => panic!("expected Downgrade"),
990 }
991 }
992
993 #[test]
994 fn compact_history_preserves_downgrade_detection() {
995 let p = packument(
996 "foo",
997 vec![("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0"))],
998 );
999 let history = BTreeMap::from([
1000 (
1001 "2.0.0".to_string(),
1002 aube_registry::VersionTrustMetadata {
1003 approver: None,
1004 npm_user: None,
1005 dist: Some(aube_registry::VersionTrustDist {
1006 attestations: Some(Attestations {
1007 provenance: Some(serde_json::json!({
1008 "predicateType": "https://slsa.dev/provenance/v1"
1009 })),
1010 }),
1011 }),
1012 },
1013 ),
1014 (
1015 "3.0.0".to_string(),
1016 aube_registry::VersionTrustMetadata {
1017 approver: None,
1018 npm_user: None,
1019 dist: None,
1020 },
1021 ),
1022 ]);
1023 let mut p = p;
1024 p.time
1025 .insert("2.0.0".to_string(), "2025-02-01T00:00:00.000Z".to_string());
1026 let picked = &p.versions["3.0.0"];
1027
1028 let err = check_no_downgrade_compact(
1029 &p,
1030 "3.0.0",
1031 picked,
1032 &history,
1033 &TrustExcludeRules::default(),
1034 None,
1035 )
1036 .expect_err("compact prior provenance must still block a downgrade");
1037 let TrustCheckError::Downgrade(details) = err else {
1038 panic!("expected Downgrade");
1039 };
1040 assert_eq!(details.prior_version, "2.0.0");
1041 assert_eq!(details.prior_evidence, TrustEvidence::Provenance);
1042 }
1043
1044 #[test]
1045 fn downgrade_trusted_publisher_to_provenance_fails() {
1046 let p = packument(
1047 "foo",
1048 vec![
1049 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
1050 (
1051 "2.0.0",
1052 "2025-02-01T00:00:00.000Z",
1053 with_trusted_publisher(version("foo", "2.0.0")),
1054 ),
1055 (
1056 "3.0.0",
1057 "2025-03-01T00:00:00.000Z",
1058 with_provenance(version("foo", "3.0.0")),
1059 ),
1060 ],
1061 );
1062 let picked = p.versions.get("3.0.0").unwrap();
1063 let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
1064 .expect_err("trustedPublisher → provenance is a downgrade");
1065 match err {
1066 TrustCheckError::Downgrade(d) => {
1067 assert_eq!(d.prior_evidence, TrustEvidence::TrustedPublisher);
1068 assert_eq!(d.current_evidence, Some(TrustEvidence::Provenance));
1069 }
1070 _ => panic!("expected Downgrade"),
1071 }
1072 }
1073
1074 #[test]
1075 fn downgrade_staged_publish_to_trusted_publisher_fails() {
1076 let p = packument(
1077 "foo",
1078 vec![
1079 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
1080 (
1081 "2.0.0",
1082 "2025-02-01T00:00:00.000Z",
1083 with_staged_publish(version("foo", "2.0.0")),
1084 ),
1085 (
1086 "3.0.0",
1087 "2025-03-01T00:00:00.000Z",
1088 with_trusted_publisher(version("foo", "3.0.0")),
1089 ),
1090 ],
1091 );
1092 let picked = p.versions.get("3.0.0").unwrap();
1093 let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
1094 .expect_err("staged publish → trusted publisher is a downgrade");
1095 match err {
1096 TrustCheckError::Downgrade(d) => {
1097 assert_eq!(d.prior_evidence, TrustEvidence::StagedPublish);
1098 assert_eq!(d.prior_version, "2.0.0");
1099 assert_eq!(d.current_evidence, Some(TrustEvidence::TrustedPublisher));
1100 }
1101 _ => panic!("expected Downgrade"),
1102 }
1103 }
1104
1105 #[test]
1106 fn staged_publish_after_trusted_publisher_passes() {
1107 let p = packument(
1108 "foo",
1109 vec![
1110 (
1111 "1.0.0",
1112 "2025-01-01T00:00:00.000Z",
1113 with_trusted_publisher(version("foo", "1.0.0")),
1114 ),
1115 (
1116 "2.0.0",
1117 "2025-02-01T00:00:00.000Z",
1118 with_staged_publish(version("foo", "2.0.0")),
1119 ),
1120 ],
1121 );
1122 let picked = p.versions.get("2.0.0").unwrap();
1123 let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
1124 assert!(result.is_ok());
1125 }
1126
1127 #[test]
1128 fn same_trust_level_passes() {
1129 let p = packument(
1130 "foo",
1131 vec![
1132 (
1133 "2.0.0",
1134 "2025-02-01T00:00:00.000Z",
1135 with_trusted_publisher(version("foo", "2.0.0")),
1136 ),
1137 (
1138 "3.0.0",
1139 "2025-03-01T00:00:00.000Z",
1140 with_trusted_publisher(version("foo", "3.0.0")),
1141 ),
1142 ],
1143 );
1144 let picked = p.versions.get("3.0.0").unwrap();
1145 let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
1146 assert!(result.is_ok());
1147 }
1148
1149 #[test]
1150 fn prior_prerelease_ignored_when_picking_stable() {
1151 let p = packument(
1152 "foo",
1153 vec![
1154 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
1155 (
1156 "2.0.0-0",
1157 "2025-02-01T00:00:00.000Z",
1158 with_provenance(version("foo", "2.0.0-0")),
1159 ),
1160 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1161 ],
1162 );
1163 let picked = p.versions.get("3.0.0").unwrap();
1164 let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
1165 assert!(
1166 result.is_ok(),
1167 "trusted prerelease shouldn't block a stable that omits attestation"
1168 );
1169 }
1170
1171 #[test]
1172 fn prior_prerelease_counts_when_picking_prerelease() {
1173 let p = packument(
1174 "foo",
1175 vec![
1176 (
1177 "2.0.0-0",
1178 "2025-02-01T00:00:00.000Z",
1179 with_provenance(version("foo", "2.0.0-0")),
1180 ),
1181 (
1182 "3.0.0-0",
1183 "2025-03-01T00:00:00.000Z",
1184 version("foo", "3.0.0-0"),
1185 ),
1186 ],
1187 );
1188 let picked = p.versions.get("3.0.0-0").unwrap();
1189 let result = check_no_downgrade(&p, "3.0.0-0", picked, &TrustExcludeRules::default(), None);
1190 assert!(
1191 result.is_err(),
1192 "prerelease pick should compare against prior prereleases"
1193 );
1194 }
1195
1196 #[test]
1201 fn empty_time_map_skips_check() {
1202 let p = Packument {
1203 name: "foo".to_string(),
1204 modified: None,
1205 versions: {
1206 let mut m = BTreeMap::new();
1207 m.insert(
1208 "1.0.0".to_string(),
1209 with_provenance(version("foo", "1.0.0")),
1210 );
1211 m.insert("2.0.0".to_string(), version("foo", "2.0.0"));
1212 m
1213 },
1214 dist_tags: BTreeMap::new(),
1215 time: BTreeMap::new(), };
1217 let picked = p.versions.get("2.0.0").unwrap();
1218 let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
1221 assert!(result.is_ok(), "empty time map should skip the check");
1222 }
1223
1224 #[test]
1225 fn missing_time_for_picked_version_errors() {
1226 let mut p = packument(
1227 "foo",
1228 vec![
1229 (
1230 "1.0.0",
1231 "2025-01-01T00:00:00.000Z",
1232 with_provenance(version("foo", "1.0.0")),
1233 ),
1234 ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
1235 ],
1236 );
1237 p.time.remove("2.0.0");
1239 let picked = p.versions.get("2.0.0").unwrap();
1240 let err = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None)
1241 .expect_err("missing time should error");
1242 assert!(matches!(err, TrustCheckError::MissingTime(_)));
1243 }
1244
1245 #[test]
1246 fn exclude_name_at_version_bypasses_missing_time() {
1247 let p = Packument {
1249 name: "baz".to_string(),
1250 modified: None,
1251 versions: {
1252 let mut m = BTreeMap::new();
1253 m.insert("1.0.0".to_string(), version("baz", "1.0.0"));
1254 m
1255 },
1256 dist_tags: BTreeMap::new(),
1257 time: BTreeMap::new(),
1258 };
1259 let picked = p.versions.get("1.0.0").unwrap();
1260 let exclude = TrustExcludeRules::parse(["baz@1.0.0"]).unwrap();
1261 let result = check_no_downgrade(&p, "1.0.0", picked, &exclude, None);
1262 assert!(result.is_ok(), "excluded version must skip the time lookup");
1263 }
1264
1265 #[test]
1266 fn exclude_name_only_bypasses_missing_time() {
1267 let p = Packument {
1268 name: "qux".to_string(),
1269 modified: None,
1270 versions: {
1271 let mut m = BTreeMap::new();
1272 m.insert("2.0.0".to_string(), version("qux", "2.0.0"));
1273 m
1274 },
1275 dist_tags: BTreeMap::new(),
1276 time: BTreeMap::new(),
1277 };
1278 let picked = p.versions.get("2.0.0").unwrap();
1279 let exclude = TrustExcludeRules::parse(["qux"]).unwrap();
1280 let result = check_no_downgrade(&p, "2.0.0", picked, &exclude, None);
1281 assert!(result.is_ok());
1282 }
1283
1284 #[test]
1285 fn exclude_blocks_downgrade_failure() {
1286 let p = packument(
1287 "foo",
1288 vec![
1289 (
1290 "2.0.0",
1291 "2025-02-01T00:00:00.000Z",
1292 with_provenance(version("foo", "2.0.0")),
1293 ),
1294 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1295 ],
1296 );
1297 let picked = p.versions.get("3.0.0").unwrap();
1298 let exclude = TrustExcludeRules::parse(["foo@3.0.0"]).unwrap();
1299 let result = check_no_downgrade(&p, "3.0.0", picked, &exclude, None);
1300 assert!(result.is_ok(), "exclude should bypass the downgrade");
1301 }
1302
1303 #[test]
1304 fn ignore_after_skips_old_versions() {
1305 let p = packument(
1306 "foo",
1307 vec![
1308 (
1309 "2.0.0",
1310 "2025-02-01T00:00:00.000Z",
1311 with_provenance(version("foo", "2.0.0")),
1312 ),
1313 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
1314 ],
1315 );
1316 let picked = p.versions.get("3.0.0").unwrap();
1317 let result =
1319 check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), Some(1));
1320 assert!(result.is_ok());
1321 }
1322
1323 #[test]
1326 fn exclude_parses_name_only() {
1327 let r = TrustExcludeRules::parse(["foo"]).unwrap();
1328 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1329 assert!(r.matches("foo", &node_semver::Version::parse("99.0.0").unwrap()));
1330 assert!(!r.matches("bar", &node_semver::Version::parse("1.0.0").unwrap()));
1331 }
1332
1333 #[test]
1334 fn default_excludes_known_provenance_churn_packages() {
1335 let r = TrustExcludeRules::default();
1336 assert_eq!(r.len(), DEFAULT_TRUST_POLICY_EXCLUDES.len());
1339 for package in DEFAULT_TRUST_POLICY_EXCLUDES {
1340 let (name, versions) = split_name_and_versions(package);
1341 if versions.is_some() {
1344 continue;
1345 }
1346 assert!(
1347 r.matches(name, &node_semver::Version::parse("1.0.0").unwrap()),
1348 "{name} should be globally excluded"
1349 );
1350 }
1351 assert!(!r.matches("left-pad", &node_semver::Version::parse("1.0.0").unwrap()));
1352 }
1353
1354 #[test]
1355 fn default_excludes_scoped_octokit_endpoint_backport() {
1356 let r = TrustExcludeRules::default();
1362 assert!(r.matches(
1363 "@octokit/endpoint",
1364 &node_semver::Version::parse("9.0.6").unwrap()
1365 ));
1366 assert!(r.matches(
1367 "@octokit/endpoint",
1368 &node_semver::Version::parse("10.1.0").unwrap()
1369 ));
1370 assert!(!r.matches(
1371 "@octokit/core",
1372 &node_semver::Version::parse("9.0.6").unwrap()
1373 ));
1374 }
1375
1376 #[test]
1377 fn default_excludes_scoped_hono_node_server_backport() {
1378 let r = TrustExcludeRules::default();
1382 assert!(r.matches(
1383 "@hono/node-server",
1384 &node_semver::Version::parse("1.19.15").unwrap()
1385 ));
1386 assert!(!r.matches(
1387 "@hono/node-server",
1388 &node_semver::Version::parse("1.19.17").unwrap()
1389 ));
1390 assert!(!r.matches(
1391 "@hono/node-server",
1392 &node_semver::Version::parse("2.0.10").unwrap()
1393 ));
1394 assert!(!r.matches("hono", &node_semver::Version::parse("1.19.15").unwrap()));
1395 }
1396
1397 #[test]
1398 fn exclude_parses_name_at_version() {
1399 let r = TrustExcludeRules::parse(["foo@1.0.0"]).unwrap();
1400 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1401 assert!(!r.matches("foo", &node_semver::Version::parse("1.0.1").unwrap()));
1402 }
1403
1404 #[test]
1405 fn exclude_parses_version_union() {
1406 let r = TrustExcludeRules::parse(["foo@1.0.0 || 2.0.0 || 3.0.0"]).unwrap();
1407 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1408 assert!(r.matches("foo", &node_semver::Version::parse("2.0.0").unwrap()));
1409 assert!(r.matches("foo", &node_semver::Version::parse("3.0.0").unwrap()));
1410 assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1411 }
1412
1413 #[test]
1414 fn exclude_parses_scoped_name() {
1415 let r = TrustExcludeRules::parse(["@babel/core@7.20.0"]).unwrap();
1416 assert!(r.matches(
1417 "@babel/core",
1418 &node_semver::Version::parse("7.20.0").unwrap()
1419 ));
1420 assert!(!r.matches(
1421 "@babel/core",
1422 &node_semver::Version::parse("7.20.1").unwrap()
1423 ));
1424 }
1425
1426 #[test]
1427 fn exclude_parses_scoped_name_only() {
1428 let r = TrustExcludeRules::parse(["@babel/core"]).unwrap();
1429 assert!(r.matches(
1430 "@babel/core",
1431 &node_semver::Version::parse("9.9.9").unwrap()
1432 ));
1433 }
1434
1435 #[test]
1436 fn exclude_parses_glob() {
1437 let r = TrustExcludeRules::parse(["is-*"]).unwrap();
1438 assert!(r.matches("is-odd", &node_semver::Version::parse("1.0.0").unwrap()));
1439 assert!(r.matches("is-even", &node_semver::Version::parse("1.0.0").unwrap()));
1440 assert!(!r.matches("lodash", &node_semver::Version::parse("1.0.0").unwrap()));
1441 }
1442
1443 #[test]
1444 fn exclude_parses_star_matches_all() {
1445 let r = TrustExcludeRules::parse(["*"]).unwrap();
1446 assert!(r.matches("anything", &node_semver::Version::parse("0.0.1").unwrap()));
1447 }
1448
1449 #[test]
1450 fn exclude_parses_version_ranges() {
1451 let r = TrustExcludeRules::parse(["foo@^1.0.0 || ~2.1.0 || >=3.0.0 <4.0.0"]).unwrap();
1452 assert!(r.matches("foo", &node_semver::Version::parse("1.2.3").unwrap()));
1453 assert!(r.matches("foo", &node_semver::Version::parse("2.1.9").unwrap()));
1454 assert!(r.matches("foo", &node_semver::Version::parse("3.5.0").unwrap()));
1455 assert!(!r.matches("foo", &node_semver::Version::parse("2.2.0").unwrap()));
1456 assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
1457 }
1458
1459 #[test]
1460 fn exclude_rejects_invalid_version_ranges() {
1461 let err = TrustExcludeRules::parse(["foo@definitely-not-a-range"]).expect_err("bad range");
1462 assert!(matches!(
1463 err,
1464 TrustExcludeParseError::InvalidVersionUnion { .. }
1465 ));
1466 }
1467
1468 #[test]
1469 fn exclude_rejects_glob_with_version() {
1470 let err = TrustExcludeRules::parse(["is-*@1.0.0"]).expect_err("glob+version");
1471 assert!(matches!(
1472 err,
1473 TrustExcludeParseError::NameGlobWithVersions { .. }
1474 ));
1475 }
1476
1477 #[test]
1478 fn parse_lossy_keeps_valid_drops_invalid() {
1479 let (rules, errors) = TrustExcludeRules::parse_lossy([
1480 "good",
1481 "bad@definitely-not-a-range",
1482 "@scope/also-good@1.0.0",
1483 "is-*@nope",
1484 ]);
1485 assert!(rules.matches("good", &node_semver::Version::parse("1.0.0").unwrap()));
1487 assert!(rules.matches(
1488 "@scope/also-good",
1489 &node_semver::Version::parse("1.0.0").unwrap()
1490 ));
1491 assert_eq!(errors.len(), 2, "two malformed entries reported");
1492 }
1493
1494 #[test]
1495 fn exclude_skips_empty_patterns() {
1496 let r = TrustExcludeRules::parse(["", "foo", ""]).unwrap();
1498 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1499 }
1500}