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)]
277pub enum TrustExcludeParseError {
278 #[error(
279 "invalid trustPolicyExclude pattern `{pattern}`: only exact versions are allowed in version unions, ranges (^/~/>=) are not supported"
280 )]
281 InvalidVersionUnion { pattern: String },
282 #[error(
283 "invalid trustPolicyExclude pattern `{pattern}`: name patterns (`*`) cannot be combined with version unions"
284 )]
285 NameGlobWithVersions { pattern: String },
286}
287
288impl TrustExcludeRules {
289 fn from_name_excludes(names: &[&str]) -> Self {
290 Self {
291 rules: names
292 .iter()
293 .map(|name| TrustExcludeRule {
294 name_matcher: NameMatcher::compile(name),
295 exact_versions: None,
296 })
297 .collect(),
298 }
299 }
300
301 pub fn with_defaults_and_user_rules(user_rules: Self) -> Self {
302 let mut rules = Self::default();
303 rules.rules.extend(user_rules.rules);
304 rules
305 }
306
307 pub fn parse<I, S>(patterns: I) -> Result<Self, TrustExcludeParseError>
308 where
309 I: IntoIterator<Item = S>,
310 S: AsRef<str>,
311 {
312 let mut rules = Vec::new();
313 for pattern in patterns {
314 let pattern = pattern.as_ref();
315 if pattern.is_empty() {
316 continue;
317 }
318 rules.push(parse_one(pattern)?);
319 }
320 Ok(Self { rules })
321 }
322
323 pub fn parse_lossy<I, S>(patterns: I) -> (Self, Vec<TrustExcludeParseError>)
330 where
331 I: IntoIterator<Item = S>,
332 S: AsRef<str>,
333 {
334 let mut rules = Vec::new();
335 let mut errors = Vec::new();
336 for pattern in patterns {
337 let pattern = pattern.as_ref();
338 if pattern.is_empty() {
339 continue;
340 }
341 match parse_one(pattern) {
342 Ok(rule) => rules.push(rule),
343 Err(err) => errors.push(err),
344 }
345 }
346 (Self { rules }, errors)
347 }
348
349 fn matches(&self, name: &str, version: &node_semver::Version) -> bool {
350 for rule in &self.rules {
351 if !rule.name_matcher.matches(name) {
352 continue;
353 }
354 match &rule.exact_versions {
355 None => return true,
356 Some(versions) => {
357 if versions.iter().any(|v| v == version) {
358 return true;
359 }
360 }
361 }
362 }
363 false
364 }
365
366 fn matches_name_only(&self, name: &str) -> bool {
371 self.rules
372 .iter()
373 .any(|r| r.exact_versions.is_none() && r.name_matcher.matches(name))
374 }
375}
376
377fn parse_one(pattern: &str) -> Result<TrustExcludeRule, TrustExcludeParseError> {
378 let scoped = pattern.starts_with('@');
379 let at_index = if scoped {
380 pattern[1..].find('@').map(|i| i + 1)
381 } else {
382 pattern.find('@')
383 };
384
385 let (name_part, versions_part) = match at_index {
386 Some(i) => (&pattern[..i], Some(&pattern[i + 1..])),
387 None => (pattern, None),
388 };
389
390 let exact_versions = match versions_part {
391 None => None,
392 Some(versions_str) => {
393 if name_part.contains('*') {
394 return Err(TrustExcludeParseError::NameGlobWithVersions {
395 pattern: pattern.to_string(),
396 });
397 }
398 let mut parsed = Vec::new();
399 for chunk in versions_str.split("||") {
400 let trimmed = chunk.trim();
401 if trimmed.is_empty() {
402 return Err(TrustExcludeParseError::InvalidVersionUnion {
403 pattern: pattern.to_string(),
404 });
405 }
406 let v = node_semver::Version::parse(trimmed).map_err(|_| {
407 TrustExcludeParseError::InvalidVersionUnion {
408 pattern: pattern.to_string(),
409 }
410 })?;
411 parsed.push(v);
412 }
413 Some(parsed)
414 }
415 };
416
417 Ok(TrustExcludeRule {
418 name_matcher: NameMatcher::compile(name_part),
419 exact_versions,
420 })
421}
422
423impl NameMatcher {
424 fn compile(pattern: &str) -> Self {
425 if pattern == "*" {
426 return Self::Any;
427 }
428 if !pattern.contains('*') {
429 return Self::Exact(pattern.to_string());
430 }
431 let parts: Vec<String> = pattern.split('*').map(str::to_string).collect();
432 Self::Glob(GlobMatcher {
433 leading_wildcard: parts.first().is_some_and(String::is_empty),
434 trailing_wildcard: parts.last().is_some_and(String::is_empty),
435 parts: parts.into_iter().filter(|s| !s.is_empty()).collect(),
436 })
437 }
438
439 fn matches(&self, input: &str) -> bool {
440 match self {
441 Self::Any => true,
442 Self::Exact(s) => s == input,
443 Self::Glob(g) => g.matches(input),
444 }
445 }
446}
447
448impl GlobMatcher {
449 fn matches(&self, input: &str) -> bool {
450 if self.parts.is_empty() {
451 return true;
452 }
453 let mut cursor = 0usize;
454 for (i, segment) in self.parts.iter().enumerate() {
455 let search_window = &input[cursor..];
456 let is_first = i == 0;
457 let is_last = i == self.parts.len() - 1;
458 if is_first && !self.leading_wildcard {
459 if !search_window.starts_with(segment.as_str()) {
460 return false;
461 }
462 cursor += segment.len();
463 } else if is_last && !self.trailing_wildcard {
464 if !search_window.ends_with(segment.as_str()) {
465 return false;
466 }
467 if search_window.len() < segment.len() {
468 return false;
469 }
470 cursor = input.len();
471 } else {
472 let Some(idx) = search_window.find(segment.as_str()) else {
473 return false;
474 };
475 cursor += idx + segment.len();
476 }
477 }
478 true
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use aube_registry::{Attestations, Dist, NpmUser};
486 use std::collections::BTreeMap;
487
488 fn version(name: &str, ver: &str) -> VersionMetadata {
489 VersionMetadata {
490 name: name.to_string(),
491 version: ver.to_string(),
492 dependencies: BTreeMap::new(),
493 dev_dependencies: BTreeMap::new(),
494 peer_dependencies: BTreeMap::new(),
495 peer_dependencies_meta: BTreeMap::new(),
496 optional_dependencies: BTreeMap::new(),
497 bundled_dependencies: None,
498 dist: Some(Dist {
499 tarball: format!("https://r/{name}/-/{name}-{ver}.tgz"),
500 integrity: None,
501 shasum: None,
502 attestations: None,
503 }),
504 os: vec![],
505 cpu: vec![],
506 libc: vec![],
507 engines: BTreeMap::new(),
508 license: None,
509 funding_url: None,
510 bin: BTreeMap::new(),
511 has_install_script: false,
512 deprecated: None,
513 npm_user: None,
514 }
515 }
516
517 fn with_provenance(mut v: VersionMetadata) -> VersionMetadata {
518 let dist = v.dist.as_mut().unwrap();
519 dist.attestations = Some(Attestations {
520 provenance: Some(serde_json::json!({
521 "predicateType": "https://slsa.dev/provenance/v1"
522 })),
523 });
524 v
525 }
526
527 fn with_trusted_publisher(mut v: VersionMetadata) -> VersionMetadata {
528 v.npm_user = Some(NpmUser {
529 trusted_publisher: Some(serde_json::json!({"id": "gh"})),
530 });
531 v
532 }
533
534 fn packument(name: &str, versions: Vec<(&str, &str, VersionMetadata)>) -> Packument {
535 let mut p = Packument {
536 name: name.to_string(),
537 modified: None,
538 versions: BTreeMap::new(),
539 dist_tags: BTreeMap::new(),
540 time: BTreeMap::new(),
541 };
542 for (ver, time, meta) in versions {
543 p.versions.insert(ver.to_string(), meta);
544 p.time.insert(ver.to_string(), time.to_string());
545 }
546 p
547 }
548
549 #[test]
550 fn evidence_trusted_publisher_outranks_provenance() {
551 let v = with_trusted_publisher(with_provenance(version("foo", "1.0.0")));
552 assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
553 }
554
555 #[test]
556 fn evidence_provenance_only() {
557 let v = with_provenance(version("foo", "1.0.0"));
558 assert_eq!(evidence_for(&v), Some(TrustEvidence::Provenance));
559 }
560
561 #[test]
562 fn evidence_npm_user_without_trusted_publisher_is_none() {
563 let mut v = version("foo", "1.0.0");
564 v.npm_user = Some(NpmUser {
565 trusted_publisher: None,
566 });
567 assert_eq!(evidence_for(&v), None);
568 }
569
570 #[test]
571 fn evidence_malformed_trusted_publisher_is_none() {
572 let mut v = version("foo", "1.0.0");
573 for malformed in [
574 serde_json::Value::Bool(false),
575 serde_json::Value::Null,
576 serde_json::json!(0),
577 serde_json::json!(0.0),
578 serde_json::json!(""),
579 serde_json::json!([]),
580 serde_json::json!({}),
581 serde_json::json!({"id": ""}),
582 ] {
583 v.npm_user = Some(NpmUser {
584 trusted_publisher: Some(malformed.clone()),
585 });
586 assert_eq!(
587 evidence_for(&v),
588 None,
589 "{malformed:?} should not count as trusted-publisher evidence"
590 );
591 }
592 }
593
594 #[test]
595 fn evidence_malformed_provenance_is_none() {
596 let mut v = version("foo", "1.0.0");
597 for malformed in [
598 serde_json::Value::Bool(false),
599 serde_json::Value::Null,
600 serde_json::json!(0),
601 serde_json::json!(""),
602 serde_json::json!([]),
603 serde_json::json!({}),
604 serde_json::json!({"predicateType": ""}),
605 serde_json::json!({"predicateType": "https://slsa.dev/provenance/"}),
606 serde_json::json!({"predicateType": "https://slsa.dev/provenance/v"}),
607 serde_json::json!({"predicateType": "https://slsa.dev/provenance/latest"}),
608 serde_json::json!({"predicateType": "https://example.com/provenance/v1"}),
609 ] {
610 v.dist.as_mut().unwrap().attestations = Some(Attestations {
611 provenance: Some(malformed.clone()),
612 });
613 assert_eq!(
614 evidence_for(&v),
615 None,
616 "{malformed:?} should not count as provenance evidence"
617 );
618 }
619 }
620
621 #[test]
622 fn evidence_structured_trusted_publisher_counts() {
623 let mut v = version("foo", "1.0.0");
624 v.npm_user = Some(NpmUser {
625 trusted_publisher: Some(serde_json::json!({
626 "id": "github",
627 "oidcConfigId": "oidc:example"
628 })),
629 });
630 assert_eq!(evidence_for(&v), Some(TrustEvidence::TrustedPublisher));
631 }
632
633 #[test]
634 fn evidence_none_when_neither() {
635 let v = version("foo", "1.0.0");
636 assert_eq!(evidence_for(&v), None);
637 }
638
639 #[test]
640 fn no_evidence_anywhere_passes() {
641 let p = packument(
642 "foo",
643 vec![
644 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
645 ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
646 ],
647 );
648 let picked = p.versions.get("2.0.0").unwrap();
649 let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
650 assert!(result.is_ok());
651 }
652
653 #[test]
654 fn first_attested_version_passes() {
655 let p = packument(
656 "foo",
657 vec![
658 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
659 (
660 "2.0.0",
661 "2025-02-01T00:00:00.000Z",
662 with_provenance(version("foo", "2.0.0")),
663 ),
664 ],
665 );
666 let picked = p.versions.get("1.0.0").unwrap();
667 let result = check_no_downgrade(&p, "1.0.0", picked, &TrustExcludeRules::default(), None);
668 assert!(
669 result.is_ok(),
670 "version 1.0.0 was published first; it has nothing prior to compare against"
671 );
672 }
673
674 #[test]
675 fn downgrade_provenance_to_none_fails() {
676 let p = packument(
677 "foo",
678 vec![
679 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
680 (
681 "2.0.0",
682 "2025-02-01T00:00:00.000Z",
683 with_provenance(version("foo", "2.0.0")),
684 ),
685 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
686 ],
687 );
688 let picked = p.versions.get("3.0.0").unwrap();
689 let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
690 .expect_err("3.0.0 should fail: prior version had provenance, this one has none");
691 match err {
692 TrustCheckError::Downgrade(d) => {
693 assert_eq!(d.prior_evidence, TrustEvidence::Provenance);
694 assert_eq!(d.prior_version, "2.0.0");
695 assert_eq!(d.current_evidence, None);
696 }
697 _ => panic!("expected Downgrade"),
698 }
699 }
700
701 #[test]
702 fn downgrade_trusted_publisher_to_provenance_fails() {
703 let p = packument(
704 "foo",
705 vec![
706 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
707 (
708 "2.0.0",
709 "2025-02-01T00:00:00.000Z",
710 with_trusted_publisher(version("foo", "2.0.0")),
711 ),
712 (
713 "3.0.0",
714 "2025-03-01T00:00:00.000Z",
715 with_provenance(version("foo", "3.0.0")),
716 ),
717 ],
718 );
719 let picked = p.versions.get("3.0.0").unwrap();
720 let err = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None)
721 .expect_err("trustedPublisher → provenance is a downgrade");
722 match err {
723 TrustCheckError::Downgrade(d) => {
724 assert_eq!(d.prior_evidence, TrustEvidence::TrustedPublisher);
725 assert_eq!(d.current_evidence, Some(TrustEvidence::Provenance));
726 }
727 _ => panic!("expected Downgrade"),
728 }
729 }
730
731 #[test]
732 fn same_trust_level_passes() {
733 let p = packument(
734 "foo",
735 vec![
736 (
737 "2.0.0",
738 "2025-02-01T00:00:00.000Z",
739 with_trusted_publisher(version("foo", "2.0.0")),
740 ),
741 (
742 "3.0.0",
743 "2025-03-01T00:00:00.000Z",
744 with_trusted_publisher(version("foo", "3.0.0")),
745 ),
746 ],
747 );
748 let picked = p.versions.get("3.0.0").unwrap();
749 let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
750 assert!(result.is_ok());
751 }
752
753 #[test]
754 fn prior_prerelease_ignored_when_picking_stable() {
755 let p = packument(
756 "foo",
757 vec![
758 ("1.0.0", "2025-01-01T00:00:00.000Z", version("foo", "1.0.0")),
759 (
760 "2.0.0-0",
761 "2025-02-01T00:00:00.000Z",
762 with_provenance(version("foo", "2.0.0-0")),
763 ),
764 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
765 ],
766 );
767 let picked = p.versions.get("3.0.0").unwrap();
768 let result = check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), None);
769 assert!(
770 result.is_ok(),
771 "trusted prerelease shouldn't block a stable that omits attestation"
772 );
773 }
774
775 #[test]
776 fn prior_prerelease_counts_when_picking_prerelease() {
777 let p = packument(
778 "foo",
779 vec![
780 (
781 "2.0.0-0",
782 "2025-02-01T00:00:00.000Z",
783 with_provenance(version("foo", "2.0.0-0")),
784 ),
785 (
786 "3.0.0-0",
787 "2025-03-01T00:00:00.000Z",
788 version("foo", "3.0.0-0"),
789 ),
790 ],
791 );
792 let picked = p.versions.get("3.0.0-0").unwrap();
793 let result = check_no_downgrade(&p, "3.0.0-0", picked, &TrustExcludeRules::default(), None);
794 assert!(
795 result.is_err(),
796 "prerelease pick should compare against prior prereleases"
797 );
798 }
799
800 #[test]
805 fn empty_time_map_skips_check() {
806 let p = Packument {
807 name: "foo".to_string(),
808 modified: None,
809 versions: {
810 let mut m = BTreeMap::new();
811 m.insert(
812 "1.0.0".to_string(),
813 with_provenance(version("foo", "1.0.0")),
814 );
815 m.insert("2.0.0".to_string(), version("foo", "2.0.0"));
816 m
817 },
818 dist_tags: BTreeMap::new(),
819 time: BTreeMap::new(), };
821 let picked = p.versions.get("2.0.0").unwrap();
822 let result = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None);
825 assert!(result.is_ok(), "empty time map should skip the check");
826 }
827
828 #[test]
829 fn missing_time_for_picked_version_errors() {
830 let mut p = packument(
831 "foo",
832 vec![
833 (
834 "1.0.0",
835 "2025-01-01T00:00:00.000Z",
836 with_provenance(version("foo", "1.0.0")),
837 ),
838 ("2.0.0", "2025-02-01T00:00:00.000Z", version("foo", "2.0.0")),
839 ],
840 );
841 p.time.remove("2.0.0");
843 let picked = p.versions.get("2.0.0").unwrap();
844 let err = check_no_downgrade(&p, "2.0.0", picked, &TrustExcludeRules::default(), None)
845 .expect_err("missing time should error");
846 assert!(matches!(err, TrustCheckError::MissingTime(_)));
847 }
848
849 #[test]
850 fn exclude_name_at_version_bypasses_missing_time() {
851 let p = Packument {
853 name: "baz".to_string(),
854 modified: None,
855 versions: {
856 let mut m = BTreeMap::new();
857 m.insert("1.0.0".to_string(), version("baz", "1.0.0"));
858 m
859 },
860 dist_tags: BTreeMap::new(),
861 time: BTreeMap::new(),
862 };
863 let picked = p.versions.get("1.0.0").unwrap();
864 let exclude = TrustExcludeRules::parse(["baz@1.0.0"]).unwrap();
865 let result = check_no_downgrade(&p, "1.0.0", picked, &exclude, None);
866 assert!(result.is_ok(), "excluded version must skip the time lookup");
867 }
868
869 #[test]
870 fn exclude_name_only_bypasses_missing_time() {
871 let p = Packument {
872 name: "qux".to_string(),
873 modified: None,
874 versions: {
875 let mut m = BTreeMap::new();
876 m.insert("2.0.0".to_string(), version("qux", "2.0.0"));
877 m
878 },
879 dist_tags: BTreeMap::new(),
880 time: BTreeMap::new(),
881 };
882 let picked = p.versions.get("2.0.0").unwrap();
883 let exclude = TrustExcludeRules::parse(["qux"]).unwrap();
884 let result = check_no_downgrade(&p, "2.0.0", picked, &exclude, None);
885 assert!(result.is_ok());
886 }
887
888 #[test]
889 fn exclude_blocks_downgrade_failure() {
890 let p = packument(
891 "foo",
892 vec![
893 (
894 "2.0.0",
895 "2025-02-01T00:00:00.000Z",
896 with_provenance(version("foo", "2.0.0")),
897 ),
898 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
899 ],
900 );
901 let picked = p.versions.get("3.0.0").unwrap();
902 let exclude = TrustExcludeRules::parse(["foo@3.0.0"]).unwrap();
903 let result = check_no_downgrade(&p, "3.0.0", picked, &exclude, None);
904 assert!(result.is_ok(), "exclude should bypass the downgrade");
905 }
906
907 #[test]
908 fn ignore_after_skips_old_versions() {
909 let p = packument(
910 "foo",
911 vec![
912 (
913 "2.0.0",
914 "2025-02-01T00:00:00.000Z",
915 with_provenance(version("foo", "2.0.0")),
916 ),
917 ("3.0.0", "2025-03-01T00:00:00.000Z", version("foo", "3.0.0")),
918 ],
919 );
920 let picked = p.versions.get("3.0.0").unwrap();
921 let result =
923 check_no_downgrade(&p, "3.0.0", picked, &TrustExcludeRules::default(), Some(1));
924 assert!(result.is_ok());
925 }
926
927 #[test]
930 fn exclude_parses_name_only() {
931 let r = TrustExcludeRules::parse(["foo"]).unwrap();
932 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
933 assert!(r.matches("foo", &node_semver::Version::parse("99.0.0").unwrap()));
934 assert!(!r.matches("bar", &node_semver::Version::parse("1.0.0").unwrap()));
935 }
936
937 #[test]
938 fn default_excludes_known_provenance_churn_packages() {
939 let r = TrustExcludeRules::default();
940 for package in DEFAULT_TRUST_POLICY_EXCLUDES {
941 assert!(
942 r.matches(package, &node_semver::Version::parse("1.0.0").unwrap()),
943 "{package} should be globally excluded"
944 );
945 }
946 assert!(!r.matches("left-pad", &node_semver::Version::parse("1.0.0").unwrap()));
947 }
948
949 #[test]
950 fn exclude_parses_name_at_version() {
951 let r = TrustExcludeRules::parse(["foo@1.0.0"]).unwrap();
952 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
953 assert!(!r.matches("foo", &node_semver::Version::parse("1.0.1").unwrap()));
954 }
955
956 #[test]
957 fn exclude_parses_version_union() {
958 let r = TrustExcludeRules::parse(["foo@1.0.0 || 2.0.0 || 3.0.0"]).unwrap();
959 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
960 assert!(r.matches("foo", &node_semver::Version::parse("2.0.0").unwrap()));
961 assert!(r.matches("foo", &node_semver::Version::parse("3.0.0").unwrap()));
962 assert!(!r.matches("foo", &node_semver::Version::parse("4.0.0").unwrap()));
963 }
964
965 #[test]
966 fn exclude_parses_scoped_name() {
967 let r = TrustExcludeRules::parse(["@babel/core@7.20.0"]).unwrap();
968 assert!(r.matches(
969 "@babel/core",
970 &node_semver::Version::parse("7.20.0").unwrap()
971 ));
972 assert!(!r.matches(
973 "@babel/core",
974 &node_semver::Version::parse("7.20.1").unwrap()
975 ));
976 }
977
978 #[test]
979 fn exclude_parses_scoped_name_only() {
980 let r = TrustExcludeRules::parse(["@babel/core"]).unwrap();
981 assert!(r.matches(
982 "@babel/core",
983 &node_semver::Version::parse("9.9.9").unwrap()
984 ));
985 }
986
987 #[test]
988 fn exclude_parses_glob() {
989 let r = TrustExcludeRules::parse(["is-*"]).unwrap();
990 assert!(r.matches("is-odd", &node_semver::Version::parse("1.0.0").unwrap()));
991 assert!(r.matches("is-even", &node_semver::Version::parse("1.0.0").unwrap()));
992 assert!(!r.matches("lodash", &node_semver::Version::parse("1.0.0").unwrap()));
993 }
994
995 #[test]
996 fn exclude_parses_star_matches_all() {
997 let r = TrustExcludeRules::parse(["*"]).unwrap();
998 assert!(r.matches("anything", &node_semver::Version::parse("0.0.1").unwrap()));
999 }
1000
1001 #[test]
1002 fn exclude_rejects_range_operators() {
1003 for bad in ["foo@^1.0.0", "foo@~1.0.0", "foo@>=1.0.0"] {
1004 let err = TrustExcludeRules::parse([bad]).expect_err(bad);
1005 assert!(matches!(
1006 err,
1007 TrustExcludeParseError::InvalidVersionUnion { .. }
1008 ));
1009 }
1010 }
1011
1012 #[test]
1013 fn exclude_rejects_glob_with_version() {
1014 let err = TrustExcludeRules::parse(["is-*@1.0.0"]).expect_err("glob+version");
1015 assert!(matches!(
1016 err,
1017 TrustExcludeParseError::NameGlobWithVersions { .. }
1018 ));
1019 }
1020
1021 #[test]
1022 fn parse_lossy_keeps_valid_drops_invalid() {
1023 let (rules, errors) = TrustExcludeRules::parse_lossy([
1024 "good",
1025 "bad@^1.0.0",
1026 "@scope/also-good@1.0.0",
1027 "is-*@nope",
1028 ]);
1029 assert!(rules.matches("good", &node_semver::Version::parse("1.0.0").unwrap()));
1031 assert!(rules.matches(
1032 "@scope/also-good",
1033 &node_semver::Version::parse("1.0.0").unwrap()
1034 ));
1035 assert_eq!(errors.len(), 2, "two malformed entries reported");
1036 }
1037
1038 #[test]
1039 fn exclude_skips_empty_patterns() {
1040 let r = TrustExcludeRules::parse(["", "foo", ""]).unwrap();
1042 assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap()));
1043 }
1044}