1use aube_manifest::AllowBuildRaw;
34use std::collections::{BTreeMap, HashSet};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum AllowDecision {
39 Allow,
41 Deny,
43 Unspecified,
45}
46
47#[derive(Debug, Clone, Default)]
50pub struct BuildPolicy {
51 allow_all: bool,
52 allowed: HashSet<String>,
55 denied: HashSet<String>,
56 allowed_sources: HashSet<String>,
60 denied_sources: HashSet<String>,
61 allowed_git_repositories: HashSet<String>,
66 denied_git_repositories: HashSet<String>,
67 allowed_wildcards: Vec<String>,
72 denied_wildcards: Vec<String>,
73}
74
75impl BuildPolicy {
76 pub fn deny_all() -> Self {
78 Self::default()
79 }
80
81 pub fn allow_all() -> Self {
84 Self {
85 allow_all: true,
86 ..Self::default()
87 }
88 }
89
90 pub fn from_config(
100 allow_builds: &BTreeMap<String, AllowBuildRaw>,
101 only_built: &[String],
102 never_built: &[String],
103 dangerously_allow_all: bool,
104 ) -> (Self, Vec<BuildPolicyError>) {
105 if dangerously_allow_all {
106 return (Self::allow_all(), Vec::new());
107 }
108 let mut allowed = HashSet::new();
109 let mut denied = HashSet::new();
110 let mut allowed_sources = HashSet::new();
111 let mut denied_sources = HashSet::new();
112 let mut allowed_git_repositories = HashSet::new();
113 let mut denied_git_repositories = HashSet::new();
114 let mut allowed_wildcards = Vec::new();
115 let mut denied_wildcards = Vec::new();
116 let mut warnings = Vec::new();
117
118 for (pattern, value) in allow_builds {
119 let bool_value = match value {
120 AllowBuildRaw::Bool(b) => *b,
121 AllowBuildRaw::Other(raw) => {
122 if raw == aube_manifest::workspace::ALLOW_BUILDS_REVIEW_PLACEHOLDER {
131 continue;
132 }
133 warnings.push(BuildPolicyError::UnsupportedValue {
134 pattern: pattern.clone(),
135 raw: raw.clone(),
136 });
137 continue;
138 }
139 };
140 match expand_spec(pattern) {
141 Ok(expanded) => {
142 let (exact, wild) = if bool_value {
143 (&mut allowed, &mut allowed_wildcards)
144 } else {
145 (&mut denied, &mut denied_wildcards)
146 };
147 let source = if bool_value {
148 &mut allowed_sources
149 } else {
150 &mut denied_sources
151 };
152 let git_repositories = if bool_value {
153 &mut allowed_git_repositories
154 } else {
155 &mut denied_git_repositories
156 };
157 sort_entries(expanded, exact, source, git_repositories, wild);
158 }
159 Err(e) => warnings.push(e),
160 }
161 }
162
163 for pattern in only_built {
169 match expand_spec(pattern) {
170 Ok(expanded) => sort_entries(
171 expanded,
172 &mut allowed,
173 &mut allowed_sources,
174 &mut allowed_git_repositories,
175 &mut allowed_wildcards,
176 ),
177 Err(e) => warnings.push(e),
178 }
179 }
180 for pattern in never_built {
181 match expand_spec(pattern) {
182 Ok(expanded) => sort_entries(
183 expanded,
184 &mut denied,
185 &mut denied_sources,
186 &mut denied_git_repositories,
187 &mut denied_wildcards,
188 ),
189 Err(e) => warnings.push(e),
190 }
191 }
192
193 (
194 Self {
195 allow_all: false,
196 allowed,
197 denied,
198 allowed_sources,
199 denied_sources,
200 allowed_git_repositories,
201 denied_git_repositories,
202 allowed_wildcards,
203 denied_wildcards,
204 },
205 warnings,
206 )
207 }
208
209 pub fn denylist(denied_patterns: &[String]) -> (Self, Vec<BuildPolicyError>) {
211 let mut denied = HashSet::new();
212 let mut denied_sources = HashSet::new();
213 let mut denied_git_repositories = HashSet::new();
214 let mut denied_wildcards = Vec::new();
215 let mut warnings = Vec::new();
216 for pattern in denied_patterns {
217 match expand_spec(pattern) {
218 Ok(expanded) => sort_entries(
219 expanded,
220 &mut denied,
221 &mut denied_sources,
222 &mut denied_git_repositories,
223 &mut denied_wildcards,
224 ),
225 Err(e) => warnings.push(e),
226 }
227 }
228 (
229 Self {
230 allow_all: true,
231 allowed: HashSet::new(),
232 denied,
233 allowed_sources: HashSet::new(),
234 denied_sources,
235 allowed_git_repositories: HashSet::new(),
236 denied_git_repositories,
237 allowed_wildcards: Vec::new(),
238 denied_wildcards,
239 },
240 warnings,
241 )
242 }
243
244 pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
247 self.decide_package(name, version, None)
248 }
249
250 pub fn decide_package(
258 &self,
259 name: &str,
260 version: &str,
261 source_key: Option<&str>,
262 ) -> AllowDecision {
263 self.decide_package_with_git_repository(name, version, source_key, None)
264 }
265
266 pub fn decide_package_with_git_repository(
269 &self,
270 name: &str,
271 version: &str,
272 source_key: Option<&str>,
273 git_repository_key: Option<&str>,
274 ) -> AllowDecision {
275 thread_local! {
279 static KEY_BUF: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
280 }
281 if self.denied.contains(name) {
282 return AllowDecision::Deny;
283 }
284 if matches_any_wildcard(name, &self.denied_wildcards) {
285 return AllowDecision::Deny;
286 }
287 if let Some(source_key) = source_key
288 && self.denied_sources.contains(source_key)
289 {
290 return AllowDecision::Deny;
291 }
292 if let Some(git_repository_key) = git_repository_key
293 && self.denied_git_repositories.contains(git_repository_key)
294 {
295 return AllowDecision::Deny;
296 }
297 let (denied_versioned, allowed_versioned) = KEY_BUF.with(|buf| {
300 let mut b = buf.borrow_mut();
301 b.clear();
302 use std::fmt::Write as _;
303 let _ = write!(b, "{name}@{version}");
304 let key = b.as_str();
305 (self.denied.contains(key), self.allowed.contains(key))
306 });
307 if denied_versioned {
308 return AllowDecision::Deny;
309 }
310 if self.allow_all {
311 return AllowDecision::Allow;
312 }
313 if let Some(git_repository_key) = git_repository_key
314 && self.allowed_git_repositories.contains(git_repository_key)
315 {
316 return AllowDecision::Allow;
317 }
318 if let Some(source_key) = source_key {
319 return if self.allowed_sources.contains(source_key) {
320 AllowDecision::Allow
321 } else {
322 AllowDecision::Unspecified
323 };
324 }
325 if self.allowed.contains(name) || allowed_versioned {
326 return AllowDecision::Allow;
327 }
328 if matches_any_wildcard(name, &self.allowed_wildcards) {
329 return AllowDecision::Allow;
330 }
331 AllowDecision::Unspecified
332 }
333
334 pub fn has_any_allow_rule(&self) -> bool {
338 self.allow_all
339 || !self.allowed.is_empty()
340 || !self.allowed_sources.is_empty()
341 || !self.allowed_git_repositories.is_empty()
342 || !self.allowed_wildcards.is_empty()
343 }
344
345 pub fn merge(&mut self, other: &Self) {
348 self.allow_all |= other.allow_all;
349 self.allowed.extend(other.allowed.iter().cloned());
350 self.denied.extend(other.denied.iter().cloned());
351 self.allowed_sources
352 .extend(other.allowed_sources.iter().cloned());
353 self.denied_sources
354 .extend(other.denied_sources.iter().cloned());
355 self.allowed_git_repositories
356 .extend(other.allowed_git_repositories.iter().cloned());
357 self.denied_git_repositories
358 .extend(other.denied_git_repositories.iter().cloned());
359 merge_unique(&mut self.allowed_wildcards, &other.allowed_wildcards);
360 merge_unique(&mut self.denied_wildcards, &other.denied_wildcards);
361 }
362}
363
364fn merge_unique(target: &mut Vec<String>, source: &[String]) {
365 for value in source {
366 if !target.iter().any(|existing| existing == value) {
367 target.push(value.clone());
368 }
369 }
370}
371
372pub fn pattern_matches(pattern: &str, name: &str, version: &str) -> Result<bool, BuildPolicyError> {
374 let with_version = format!("{name}@{version}");
375 for expanded in expand_spec(pattern)? {
376 if expanded.contains('*') {
377 if matches_wildcard(name, &expanded) {
378 return Ok(true);
379 }
380 } else if expanded == name || expanded == with_version {
381 return Ok(true);
382 }
383 }
384 Ok(false)
385}
386
387fn sort_entries(
392 entries: Vec<String>,
393 exact: &mut HashSet<String>,
394 sources: &mut HashSet<String>,
395 git_repositories: &mut HashSet<String>,
396 wildcards: &mut Vec<String>,
397) {
398 for entry in entries {
399 if entry.contains('*') {
400 if !wildcards.iter().any(|p| p == &entry) {
401 wildcards.push(entry);
402 }
403 } else if is_git_repository_key(&entry) {
404 git_repositories.insert(entry);
405 } else if is_source_key(&entry) {
406 sources.insert(entry);
407 } else {
408 exact.insert(entry);
409 }
410 }
411}
412
413fn matches_any_wildcard(name: &str, patterns: &[String]) -> bool {
428 patterns.iter().any(|p| matches_wildcard(name, p))
429}
430
431fn matches_wildcard(name: &str, pattern: &str) -> bool {
432 let parts: Vec<&str> = pattern.split('*').collect();
433 let (first, rest) = match parts.split_first() {
436 Some(pair) => pair,
437 None => return false,
438 };
439 let Some(after_prefix) = name.strip_prefix(first) else {
440 return false;
441 };
442 let (last, middle) = match rest.split_last() {
443 Some(pair) => pair,
444 None => {
449 debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
450 return false;
451 }
452 };
453
454 let mut remaining = after_prefix;
455 for mid in middle {
456 match remaining.find(mid) {
457 Some(idx) => remaining = &remaining[idx + mid.len()..],
458 None => return false,
459 }
460 }
461 remaining.len() >= last.len() && remaining.ends_with(last)
462}
463
464#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
465pub enum BuildPolicyError {
466 #[error("build policy entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
467 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_UNSUPPORTED_VALUE))]
468 UnsupportedValue { pattern: String, raw: String },
469 #[error("build policy pattern {0:?} contains an invalid version union")]
470 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_INVALID_VERSION_UNION))]
471 InvalidVersionUnion(String),
472 #[error("build policy pattern {0:?} mixes a wildcard name with a version union")]
473 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_WILDCARD_WITH_VERSION))]
474 WildcardWithVersion(String),
475}
476
477fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
481 let (name, versions_part) = split_name_and_versions(pattern);
482
483 if versions_part.is_empty() {
484 return Ok(vec![name.to_string()]);
485 }
486 if name.contains('*') {
487 return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
488 }
489
490 let mut out = Vec::new();
491 for raw in versions_part.split("||") {
492 let trimmed = raw.trim();
493 if is_source_version(trimmed) && !versions_part.contains("||") {
494 out.push(format!("{name}@{trimmed}"));
495 return Ok(out);
496 }
497 if trimmed.is_empty() || !is_exact_semver(trimmed) {
498 return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
499 }
500 out.push(format!("{name}@{trimmed}"));
501 }
502 Ok(out)
503}
504
505fn is_source_key(key: &str) -> bool {
506 let (_, version) = split_name_and_versions(key);
507 is_source_version(version)
508}
509
510fn is_git_repository_key(key: &str) -> bool {
511 let (_, source) = split_name_and_versions(key);
512 !source.contains('#')
513 && [
514 "git+https://",
515 "git+http://",
516 "git+ssh://",
517 "git+file://",
518 "git+git://",
519 ]
520 .iter()
521 .any(|prefix| source.starts_with(prefix))
522}
523
524fn is_source_version(version: &str) -> bool {
525 [
526 "file+",
527 "link+",
528 "portal+",
529 "exec+",
530 "git+",
531 "url+",
532 "file:",
533 "link:",
534 "portal:",
535 "exec:",
536 "git:",
537 "http://",
538 "https://",
539 "github:",
540 "workspace:",
541 ]
542 .iter()
543 .any(|prefix| version.starts_with(prefix))
544}
545
546fn split_name_and_versions(pattern: &str) -> (&str, &str) {
549 let scoped = pattern.starts_with('@');
550 let search_from = if scoped { 1 } else { 0 };
551 match pattern[search_from..].find('@') {
552 Some(rel) => {
553 let at = search_from + rel;
554 (&pattern[..at], &pattern[at + 1..])
555 }
556 None => (pattern, ""),
557 }
558}
559
560fn is_exact_semver(s: &str) -> bool {
565 let core = s.split('+').next().unwrap_or(s);
567 let main = core.split('-').next().unwrap_or(core);
569 let parts: Vec<&str> = main.split('.').collect();
570 if parts.len() != 3 {
571 return false;
572 }
573 parts
574 .iter()
575 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
583 let map: BTreeMap<String, AllowBuildRaw> = pairs
584 .iter()
585 .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
586 .collect();
587 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
588 assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
589 p
590 }
591
592 #[test]
593 fn bare_name_allows_any_version() {
594 let p = policy(&[("esbuild", true)]);
595 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
596 assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
597 assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
598 }
599
600 #[test]
601 fn bare_name_does_not_allow_source_backed_package() {
602 let p = policy(&[("esbuild", true)]);
603 assert_eq!(
604 p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
605 AllowDecision::Unspecified
606 );
607 }
608
609 #[test]
610 fn exact_source_key_allows_source_backed_package() {
611 let p = policy(&[("esbuild@file+abc123", true)]);
612 assert_eq!(
613 p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
614 AllowDecision::Allow
615 );
616 assert_eq!(
617 p.decide_package("esbuild", "0.25.0", Some("esbuild@file+def456")),
618 AllowDecision::Unspecified
619 );
620 assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Unspecified);
621 }
622
623 #[test]
624 fn source_keys_accept_url_and_git_tails() {
625 let p = policy(&[
626 ("native@url+abc123", true),
627 ("gitdep@git+def456", true),
628 ("raw-url@https://example.com/pkg.tgz", true),
629 ("raw-git@github:owner/repo", true),
630 ]);
631 assert_eq!(
632 p.decide_package("native", "1.0.0", Some("native@url+abc123")),
633 AllowDecision::Allow
634 );
635 assert_eq!(
636 p.decide_package("gitdep", "1.0.0", Some("gitdep@git+def456")),
637 AllowDecision::Allow
638 );
639 assert_eq!(
640 p.decide_package(
641 "raw-url",
642 "1.0.0",
643 Some("raw-url@https://example.com/pkg.tgz")
644 ),
645 AllowDecision::Allow
646 );
647 assert_eq!(
648 p.decide_package("raw-git", "1.0.0", Some("raw-git@github:owner/repo")),
649 AllowDecision::Allow
650 );
651 }
652
653 #[test]
654 fn git_repository_rule_allows_every_resolved_commit() {
655 let p = policy(&[("gitdep@git+https://github.com/acme/gitdep.git", true)]);
656
657 for source_key in [
658 "gitdep@https://github.com/acme/gitdep.git#0123456789012345678901234567890123456789",
659 "gitdep@https://github.com/acme/gitdep.git#abcdefabcdefabcdefabcdefabcdefabcdefabcd",
660 ] {
661 assert_eq!(
662 p.decide_package_with_git_repository(
663 "gitdep",
664 "1.0.0",
665 Some(source_key),
666 Some("gitdep@git+https://github.com/acme/gitdep.git"),
667 ),
668 AllowDecision::Allow
669 );
670 }
671
672 assert_eq!(
673 p.decide_package_with_git_repository(
674 "gitdep",
675 "1.0.0",
676 Some("gitdep@https://github.com/acme/other.git#0123456789012345678901234567890123456789"),
677 Some("gitdep@git+https://github.com/acme/other.git"),
678 ),
679 AllowDecision::Unspecified
680 );
681 }
682
683 #[test]
684 fn git_repository_rule_accepts_native_git_transport() {
685 let p = policy(&[("gitdep@git+git://github.com/acme/gitdep.git", true)]);
686
687 assert_eq!(
688 p.decide_package_with_git_repository(
689 "gitdep",
690 "1.0.0",
691 Some("gitdep@git://github.com/acme/gitdep.git#0123456789012345678901234567890123456789"),
692 Some("gitdep@git+git://github.com/acme/gitdep.git"),
693 ),
694 AllowDecision::Allow
695 );
696 }
697
698 #[test]
699 fn git_repository_deny_and_package_deny_override_repository_allow() {
700 let p = policy(&[
701 ("gitdep@git+https://github.com/acme/gitdep.git", true),
702 ("other@git+https://github.com/acme/other.git", false),
703 ("blocked", false),
704 ("blocked@git+https://github.com/acme/blocked.git", true),
705 ]);
706
707 assert_eq!(
708 p.decide_package_with_git_repository(
709 "other",
710 "1.0.0",
711 Some("other@https://github.com/acme/other.git#0123456789012345678901234567890123456789"),
712 Some("other@git+https://github.com/acme/other.git"),
713 ),
714 AllowDecision::Deny
715 );
716 assert_eq!(
717 p.decide_package_with_git_repository(
718 "blocked",
719 "1.0.0",
720 Some("blocked@https://github.com/acme/blocked.git#0123456789012345678901234567890123456789"),
721 Some("blocked@git+https://github.com/acme/blocked.git"),
722 ),
723 AllowDecision::Deny
724 );
725 }
726
727 #[test]
728 fn source_backed_package_name_deny_still_wins() {
729 let p = policy(&[("esbuild", false), ("esbuild@file+abc123", true)]);
730 assert_eq!(
731 p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
732 AllowDecision::Deny
733 );
734 }
735
736 #[test]
737 fn exact_version_is_strict() {
738 let p = policy(&[("esbuild@0.19.0", true)]);
739 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
740 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
741 }
742
743 #[test]
744 fn version_union_splits() {
745 let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
746 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
747 assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
748 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
749 }
750
751 #[test]
752 fn scoped_package_parses() {
753 let p = policy(&[("@swc/core@1.3.0", true)]);
754 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
755 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
756 }
757
758 #[test]
759 fn scoped_bare_name() {
760 let p = policy(&[("@swc/core", true)]);
761 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
762 }
763
764 #[test]
765 fn pattern_matches_scoped_names_and_versions() {
766 assert!(pattern_matches("@swc/core", "@swc/core", "1.3.0").unwrap());
767 assert!(pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.0").unwrap());
768 assert!(!pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.1").unwrap());
769 assert!(pattern_matches("@swc/*", "@swc/core", "1.3.0").unwrap());
770 assert!(pattern_matches("aube-test-*", "aube-test-native", "1.0.0").unwrap());
771 }
772
773 #[test]
774 fn dangerously_allow_all_bypasses_deny_list() {
775 let mut map = BTreeMap::new();
781 map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
782 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
783 assert!(errs.is_empty());
784 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
785 }
786
787 #[test]
788 fn deny_wins_over_allow_when_both_listed() {
789 let map: BTreeMap<String, AllowBuildRaw> = [
790 ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
791 ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
792 ]
793 .into_iter()
794 .collect();
795 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
796 assert!(errs.is_empty());
797 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
798 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
799 }
800
801 #[test]
802 fn deny_all_is_default() {
803 let p = BuildPolicy::deny_all();
804 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
805 assert!(!p.has_any_allow_rule());
806 }
807
808 #[test]
809 fn allow_all_flag() {
810 let p = BuildPolicy::allow_all();
811 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
812 assert!(p.has_any_allow_rule());
813 }
814
815 #[test]
816 fn invalid_version_union_reports_warning() {
817 let map: BTreeMap<String, AllowBuildRaw> = [(
818 "esbuild@not-a-version".to_string(),
819 AllowBuildRaw::Bool(true),
820 )]
821 .into_iter()
822 .collect();
823 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
824 assert_eq!(errs.len(), 1);
825 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
827 }
828
829 #[test]
830 fn source_specs_cannot_be_union_members() {
831 let map: BTreeMap<String, AllowBuildRaw> = [(
832 "dependency@https://example.com/dep.tgz || 1.0.0".to_string(),
833 AllowBuildRaw::Bool(true),
834 )]
835 .into_iter()
836 .collect();
837 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
838 assert_eq!(errs.len(), 1);
839 assert!(matches!(errs[0], BuildPolicyError::InvalidVersionUnion(_)));
840 }
841
842 #[test]
843 fn semver_then_source_spec_union_is_also_rejected() {
844 let map: BTreeMap<String, AllowBuildRaw> = [(
845 "dependency@1.0.0 || https://example.com/dep.tgz".to_string(),
846 AllowBuildRaw::Bool(true),
847 )]
848 .into_iter()
849 .collect();
850 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
851 assert_eq!(errs.len(), 1);
852 assert!(matches!(errs[0], BuildPolicyError::InvalidVersionUnion(_)));
853 }
854
855 #[test]
856 fn non_bool_value_reports_warning() {
857 let map: BTreeMap<String, AllowBuildRaw> =
858 [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
859 .into_iter()
860 .collect();
861 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
862 assert_eq!(errs.len(), 1);
863 }
864
865 #[test]
866 fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
867 let map = BTreeMap::new();
871 let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
872 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
873 assert!(errs.is_empty());
874 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
875 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
876 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
877 assert!(p.has_any_allow_rule());
878 }
879
880 #[test]
881 fn never_built_dependencies_denies() {
882 let map = BTreeMap::new();
883 let only_built = vec!["esbuild".to_string()];
884 let never_built = vec!["esbuild@0.19.0".to_string()];
885 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
886 assert!(errs.is_empty());
887 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
888 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
889 }
890
891 #[test]
892 fn never_built_beats_allow_builds_map() {
893 let map: BTreeMap<String, AllowBuildRaw> =
897 [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
898 .into_iter()
899 .collect();
900 let never_built = vec!["esbuild".to_string()];
901 let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
902 assert!(errs.is_empty());
903 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
904 }
905
906 #[test]
907 fn merge_deduplicates_wildcards() {
908 let mut p = policy(&[("@babel/*", true), ("*-internal", false)]);
909 let other = policy(&[
910 ("@babel/*", true),
911 ("@types/*", true),
912 ("*-internal", false),
913 ]);
914 p.merge(&other);
915 p.merge(&other);
916
917 assert_eq!(p.allowed_wildcards, vec!["@babel/*", "@types/*"]);
918 assert_eq!(p.denied_wildcards, vec!["*-internal"]);
919 assert_eq!(p.decide("@types/node", "1.0.0"), AllowDecision::Allow);
920 assert_eq!(p.decide("pkg-internal", "1.0.0"), AllowDecision::Deny);
921 }
922
923 #[test]
924 fn splits_scoped_correctly() {
925 assert_eq!(
926 split_name_and_versions("@swc/core@1.3.0"),
927 ("@swc/core", "1.3.0")
928 );
929 assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
930 assert_eq!(
931 split_name_and_versions("esbuild@0.19.0"),
932 ("esbuild", "0.19.0")
933 );
934 assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
935 }
936
937 #[test]
938 fn wildcard_scope_allows_every_scope_member() {
939 let p = policy(&[("@babel/*", true)]);
940 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
941 assert_eq!(
942 p.decide("@babel/preset-env", "7.22.0"),
943 AllowDecision::Allow
944 );
945 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
946 assert_eq!(
947 p.decide("babel-loader", "9.0.0"),
948 AllowDecision::Unspecified
949 );
950 assert!(p.has_any_allow_rule());
951 }
952
953 #[test]
954 fn wildcard_suffix_matches_any_prefix() {
955 let p = policy(&[("*-loader", true)]);
956 assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
957 assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
958 assert_eq!(
959 p.decide("loader-utils", "3.0.0"),
960 AllowDecision::Unspecified
961 );
962 }
963
964 #[test]
965 fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
966 let map: BTreeMap<String, AllowBuildRaw> = [
970 ("*".to_string(), AllowBuildRaw::Bool(true)),
971 ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
972 ]
973 .into_iter()
974 .collect();
975 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
976 assert!(errs.is_empty());
977 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
978 assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
979 }
980
981 #[test]
982 fn denied_wildcard_blocks_allowed_exact() {
983 let map: BTreeMap<String, AllowBuildRaw> = [
984 ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
985 ("@babel/*".to_string(), AllowBuildRaw::Bool(false)),
986 ]
987 .into_iter()
988 .collect();
989 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
990 assert!(errs.is_empty());
991 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Deny);
992 assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
993 }
994
995 #[test]
996 fn wildcard_with_version_is_rejected() {
997 let map: BTreeMap<String, AllowBuildRaw> =
998 [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
999 .into_iter()
1000 .collect();
1001 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
1002 assert_eq!(errs.len(), 1);
1003 assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
1004 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
1007 }
1008
1009 #[test]
1010 fn wildcards_flow_through_flat_lists_too() {
1011 let only_built = vec!["@types/*".to_string()];
1012 let never_built = vec!["*-internal".to_string()];
1013 let (p, errs) =
1014 BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
1015 assert!(errs.is_empty());
1016 assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
1017 assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
1018 assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
1019 }
1020
1021 #[test]
1022 fn matches_wildcard_handles_all_positions() {
1023 assert!(matches_wildcard("@babel/core", "@babel/*"));
1024 assert!(matches_wildcard("@babel/", "@babel/*"));
1025 assert!(!matches_wildcard("@babe/core", "@babel/*"));
1026
1027 assert!(matches_wildcard("css-loader", "*-loader"));
1028 assert!(matches_wildcard("-loader", "*-loader"));
1029 assert!(!matches_wildcard("loader-x", "*-loader"));
1030
1031 assert!(matches_wildcard("foobar", "foo*bar"));
1032 assert!(matches_wildcard("foo-x-bar", "foo*bar"));
1033 assert!(!matches_wildcard("foobaz", "foo*bar"));
1034
1035 assert!(matches_wildcard("@x/anything", "*"));
1036 assert!(matches_wildcard("", "*"));
1037
1038 assert!(matches_wildcard("anything", "**"));
1040 }
1041
1042 #[test]
1043 fn matches_wildcard_multi_segment_greedy_is_correct() {
1044 assert!(matches_wildcard("abca", "*a*bc*a"));
1051 assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
1052 assert!(matches_wildcard("abcaXa", "*a*bc*a"));
1053 assert!(matches_wildcard("ababab", "*ab*ab*"));
1054 assert!(matches_wildcard("abcd", "a*b*c*d"));
1055 assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
1056
1057 assert!(!matches_wildcard("aab", "*ab*ab"));
1061 assert!(!matches_wildcard("abab", "*abc*abc"));
1062
1063 assert!(matches_wildcard(
1065 "@acme/core-loader-plugin",
1066 "@acme/*-*-plugin"
1067 ));
1068 assert!(!matches_wildcard(
1069 "@acme/core-plugin-extra",
1070 "@acme/*-*-plugin"
1071 ));
1072 }
1073
1074 #[test]
1075 fn semver_shape() {
1076 assert!(is_exact_semver("1.2.3"));
1077 assert!(is_exact_semver("0.19.0"));
1078 assert!(is_exact_semver("1.0.0-alpha"));
1079 assert!(is_exact_semver("1.0.0+build.42"));
1080 assert!(!is_exact_semver("1.2"));
1081 assert!(!is_exact_semver("^1.2.3"));
1082 assert!(!is_exact_semver("1.x.0"));
1083 }
1084}