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