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