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