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