1use aube_manifest::AllowBuildRaw;
32use std::collections::{BTreeMap, HashSet};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AllowDecision {
37 Allow,
39 Deny,
41 Unspecified,
43}
44
45#[derive(Debug, Clone, Default)]
48pub struct BuildPolicy {
49 allow_all: bool,
50 allowed: HashSet<String>,
53 denied: HashSet<String>,
54 allowed_sources: HashSet<String>,
58 denied_sources: HashSet<String>,
59 allowed_wildcards: Vec<String>,
64 denied_wildcards: Vec<String>,
65}
66
67impl BuildPolicy {
68 pub fn deny_all() -> Self {
70 Self::default()
71 }
72
73 pub fn allow_all() -> Self {
76 Self {
77 allow_all: true,
78 ..Self::default()
79 }
80 }
81
82 pub fn from_config(
92 allow_builds: &BTreeMap<String, AllowBuildRaw>,
93 only_built: &[String],
94 never_built: &[String],
95 dangerously_allow_all: bool,
96 ) -> (Self, Vec<BuildPolicyError>) {
97 if dangerously_allow_all {
98 return (Self::allow_all(), Vec::new());
99 }
100 let mut allowed = HashSet::new();
101 let mut denied = HashSet::new();
102 let mut allowed_sources = HashSet::new();
103 let mut denied_sources = HashSet::new();
104 let mut allowed_wildcards = Vec::new();
105 let mut denied_wildcards = Vec::new();
106 let mut warnings = Vec::new();
107
108 for (pattern, value) in allow_builds {
109 let bool_value = match value {
110 AllowBuildRaw::Bool(b) => *b,
111 AllowBuildRaw::Other(raw) => {
112 if raw == aube_manifest::workspace::ALLOW_BUILDS_REVIEW_PLACEHOLDER {
121 continue;
122 }
123 warnings.push(BuildPolicyError::UnsupportedValue {
124 pattern: pattern.clone(),
125 raw: raw.clone(),
126 });
127 continue;
128 }
129 };
130 match expand_spec(pattern) {
131 Ok(expanded) => {
132 let (exact, wild) = if bool_value {
133 (&mut allowed, &mut allowed_wildcards)
134 } else {
135 (&mut denied, &mut denied_wildcards)
136 };
137 let source = if bool_value {
138 &mut allowed_sources
139 } else {
140 &mut denied_sources
141 };
142 sort_entries(expanded, exact, source, wild);
143 }
144 Err(e) => warnings.push(e),
145 }
146 }
147
148 for pattern in only_built {
154 match expand_spec(pattern) {
155 Ok(expanded) => sort_entries(
156 expanded,
157 &mut allowed,
158 &mut allowed_sources,
159 &mut allowed_wildcards,
160 ),
161 Err(e) => warnings.push(e),
162 }
163 }
164 for pattern in never_built {
165 match expand_spec(pattern) {
166 Ok(expanded) => sort_entries(
167 expanded,
168 &mut denied,
169 &mut denied_sources,
170 &mut denied_wildcards,
171 ),
172 Err(e) => warnings.push(e),
173 }
174 }
175
176 (
177 Self {
178 allow_all: false,
179 allowed,
180 denied,
181 allowed_sources,
182 denied_sources,
183 allowed_wildcards,
184 denied_wildcards,
185 },
186 warnings,
187 )
188 }
189
190 pub fn denylist(denied_patterns: &[String]) -> (Self, Vec<BuildPolicyError>) {
192 let mut denied = HashSet::new();
193 let mut denied_sources = HashSet::new();
194 let mut denied_wildcards = Vec::new();
195 let mut warnings = Vec::new();
196 for pattern in denied_patterns {
197 match expand_spec(pattern) {
198 Ok(expanded) => sort_entries(
199 expanded,
200 &mut denied,
201 &mut denied_sources,
202 &mut denied_wildcards,
203 ),
204 Err(e) => warnings.push(e),
205 }
206 }
207 (
208 Self {
209 allow_all: true,
210 allowed: HashSet::new(),
211 denied,
212 allowed_sources: HashSet::new(),
213 denied_sources,
214 allowed_wildcards: Vec::new(),
215 denied_wildcards,
216 },
217 warnings,
218 )
219 }
220
221 pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
224 self.decide_package(name, version, None)
225 }
226
227 pub fn decide_package(
235 &self,
236 name: &str,
237 version: &str,
238 source_key: Option<&str>,
239 ) -> AllowDecision {
240 thread_local! {
244 static KEY_BUF: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
245 }
246 if self.denied.contains(name) {
247 return AllowDecision::Deny;
248 }
249 if matches_any_wildcard(name, &self.denied_wildcards) {
250 return AllowDecision::Deny;
251 }
252 if let Some(source_key) = source_key
253 && self.denied_sources.contains(source_key)
254 {
255 return AllowDecision::Deny;
256 }
257 let (denied_versioned, allowed_versioned) = KEY_BUF.with(|buf| {
260 let mut b = buf.borrow_mut();
261 b.clear();
262 use std::fmt::Write as _;
263 let _ = write!(b, "{name}@{version}");
264 let key = b.as_str();
265 (self.denied.contains(key), self.allowed.contains(key))
266 });
267 if denied_versioned {
268 return AllowDecision::Deny;
269 }
270 if self.allow_all {
271 return AllowDecision::Allow;
272 }
273 if let Some(source_key) = source_key {
274 return if self.allowed_sources.contains(source_key) {
275 AllowDecision::Allow
276 } else {
277 AllowDecision::Unspecified
278 };
279 }
280 if self.allowed.contains(name) || allowed_versioned {
281 return AllowDecision::Allow;
282 }
283 if matches_any_wildcard(name, &self.allowed_wildcards) {
284 return AllowDecision::Allow;
285 }
286 AllowDecision::Unspecified
287 }
288
289 pub fn has_any_allow_rule(&self) -> bool {
293 self.allow_all
294 || !self.allowed.is_empty()
295 || !self.allowed_sources.is_empty()
296 || !self.allowed_wildcards.is_empty()
297 }
298
299 pub fn merge(&mut self, other: &Self) {
302 self.allow_all |= other.allow_all;
303 self.allowed.extend(other.allowed.iter().cloned());
304 self.denied.extend(other.denied.iter().cloned());
305 self.allowed_sources
306 .extend(other.allowed_sources.iter().cloned());
307 self.denied_sources
308 .extend(other.denied_sources.iter().cloned());
309 merge_unique(&mut self.allowed_wildcards, &other.allowed_wildcards);
310 merge_unique(&mut self.denied_wildcards, &other.denied_wildcards);
311 }
312}
313
314fn merge_unique(target: &mut Vec<String>, source: &[String]) {
315 for value in source {
316 if !target.iter().any(|existing| existing == value) {
317 target.push(value.clone());
318 }
319 }
320}
321
322pub fn pattern_matches(pattern: &str, name: &str, version: &str) -> Result<bool, BuildPolicyError> {
324 let with_version = format!("{name}@{version}");
325 for expanded in expand_spec(pattern)? {
326 if expanded.contains('*') {
327 if matches_wildcard(name, &expanded) {
328 return Ok(true);
329 }
330 } else if expanded == name || expanded == with_version {
331 return Ok(true);
332 }
333 }
334 Ok(false)
335}
336
337fn sort_entries(
342 entries: Vec<String>,
343 exact: &mut HashSet<String>,
344 sources: &mut HashSet<String>,
345 wildcards: &mut Vec<String>,
346) {
347 for entry in entries {
348 if entry.contains('*') {
349 if !wildcards.iter().any(|p| p == &entry) {
350 wildcards.push(entry);
351 }
352 } else if is_source_key(&entry) {
353 sources.insert(entry);
354 } else {
355 exact.insert(entry);
356 }
357 }
358}
359
360fn matches_any_wildcard(name: &str, patterns: &[String]) -> bool {
375 patterns.iter().any(|p| matches_wildcard(name, p))
376}
377
378fn matches_wildcard(name: &str, pattern: &str) -> bool {
379 let parts: Vec<&str> = pattern.split('*').collect();
380 let (first, rest) = match parts.split_first() {
383 Some(pair) => pair,
384 None => return false,
385 };
386 let Some(after_prefix) = name.strip_prefix(first) else {
387 return false;
388 };
389 let (last, middle) = match rest.split_last() {
390 Some(pair) => pair,
391 None => {
396 debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
397 return false;
398 }
399 };
400
401 let mut remaining = after_prefix;
402 for mid in middle {
403 match remaining.find(mid) {
404 Some(idx) => remaining = &remaining[idx + mid.len()..],
405 None => return false,
406 }
407 }
408 remaining.len() >= last.len() && remaining.ends_with(last)
409}
410
411#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
412pub enum BuildPolicyError {
413 #[error("build policy entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
414 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_UNSUPPORTED_VALUE))]
415 UnsupportedValue { pattern: String, raw: String },
416 #[error("build policy pattern {0:?} contains an invalid version union")]
417 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_INVALID_VERSION_UNION))]
418 InvalidVersionUnion(String),
419 #[error("build policy pattern {0:?} mixes a wildcard name with a version union")]
420 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_WILDCARD_WITH_VERSION))]
421 WildcardWithVersion(String),
422}
423
424fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
428 let (name, versions_part) = split_name_and_versions(pattern);
429
430 if versions_part.is_empty() {
431 return Ok(vec![name.to_string()]);
432 }
433 if name.contains('*') {
434 return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
435 }
436
437 let mut out = Vec::new();
438 for raw in versions_part.split("||") {
439 let trimmed = raw.trim();
440 if is_source_version(trimmed) && !versions_part.contains("||") {
441 out.push(format!("{name}@{trimmed}"));
442 return Ok(out);
443 }
444 if trimmed.is_empty() || !is_exact_semver(trimmed) {
445 return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
446 }
447 out.push(format!("{name}@{trimmed}"));
448 }
449 Ok(out)
450}
451
452fn is_source_key(key: &str) -> bool {
453 let (_, version) = split_name_and_versions(key);
454 is_source_version(version)
455}
456
457fn is_source_version(version: &str) -> bool {
458 [
459 "file+",
460 "link+",
461 "portal+",
462 "exec+",
463 "git+",
464 "url+",
465 "file:",
466 "link:",
467 "portal:",
468 "exec:",
469 "git:",
470 "http://",
471 "https://",
472 "github:",
473 "workspace:",
474 ]
475 .iter()
476 .any(|prefix| version.starts_with(prefix))
477}
478
479fn split_name_and_versions(pattern: &str) -> (&str, &str) {
482 let scoped = pattern.starts_with('@');
483 let search_from = if scoped { 1 } else { 0 };
484 match pattern[search_from..].find('@') {
485 Some(rel) => {
486 let at = search_from + rel;
487 (&pattern[..at], &pattern[at + 1..])
488 }
489 None => (pattern, ""),
490 }
491}
492
493fn is_exact_semver(s: &str) -> bool {
498 let core = s.split('+').next().unwrap_or(s);
500 let main = core.split('-').next().unwrap_or(core);
502 let parts: Vec<&str> = main.split('.').collect();
503 if parts.len() != 3 {
504 return false;
505 }
506 parts
507 .iter()
508 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
516 let map: BTreeMap<String, AllowBuildRaw> = pairs
517 .iter()
518 .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
519 .collect();
520 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
521 assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
522 p
523 }
524
525 #[test]
526 fn bare_name_allows_any_version() {
527 let p = policy(&[("esbuild", true)]);
528 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
529 assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
530 assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
531 }
532
533 #[test]
534 fn bare_name_does_not_allow_source_backed_package() {
535 let p = policy(&[("esbuild", true)]);
536 assert_eq!(
537 p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
538 AllowDecision::Unspecified
539 );
540 }
541
542 #[test]
543 fn exact_source_key_allows_source_backed_package() {
544 let p = policy(&[("esbuild@file+abc123", true)]);
545 assert_eq!(
546 p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
547 AllowDecision::Allow
548 );
549 assert_eq!(
550 p.decide_package("esbuild", "0.25.0", Some("esbuild@file+def456")),
551 AllowDecision::Unspecified
552 );
553 assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Unspecified);
554 }
555
556 #[test]
557 fn source_keys_accept_url_and_git_tails() {
558 let p = policy(&[
559 ("native@url+abc123", true),
560 ("gitdep@git+def456", true),
561 ("raw-url@https://example.com/pkg.tgz", true),
562 ("raw-git@github:owner/repo", true),
563 ]);
564 assert_eq!(
565 p.decide_package("native", "1.0.0", Some("native@url+abc123")),
566 AllowDecision::Allow
567 );
568 assert_eq!(
569 p.decide_package("gitdep", "1.0.0", Some("gitdep@git+def456")),
570 AllowDecision::Allow
571 );
572 assert_eq!(
573 p.decide_package(
574 "raw-url",
575 "1.0.0",
576 Some("raw-url@https://example.com/pkg.tgz")
577 ),
578 AllowDecision::Allow
579 );
580 assert_eq!(
581 p.decide_package("raw-git", "1.0.0", Some("raw-git@github:owner/repo")),
582 AllowDecision::Allow
583 );
584 }
585
586 #[test]
587 fn source_backed_package_name_deny_still_wins() {
588 let p = policy(&[("esbuild", false), ("esbuild@file+abc123", true)]);
589 assert_eq!(
590 p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
591 AllowDecision::Deny
592 );
593 }
594
595 #[test]
596 fn exact_version_is_strict() {
597 let p = policy(&[("esbuild@0.19.0", true)]);
598 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
599 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
600 }
601
602 #[test]
603 fn version_union_splits() {
604 let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
605 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
606 assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
607 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
608 }
609
610 #[test]
611 fn scoped_package_parses() {
612 let p = policy(&[("@swc/core@1.3.0", true)]);
613 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
614 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
615 }
616
617 #[test]
618 fn scoped_bare_name() {
619 let p = policy(&[("@swc/core", true)]);
620 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
621 }
622
623 #[test]
624 fn pattern_matches_scoped_names_and_versions() {
625 assert!(pattern_matches("@swc/core", "@swc/core", "1.3.0").unwrap());
626 assert!(pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.0").unwrap());
627 assert!(!pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.1").unwrap());
628 assert!(pattern_matches("@swc/*", "@swc/core", "1.3.0").unwrap());
629 assert!(pattern_matches("aube-test-*", "aube-test-native", "1.0.0").unwrap());
630 }
631
632 #[test]
633 fn dangerously_allow_all_bypasses_deny_list() {
634 let mut map = BTreeMap::new();
640 map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
641 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
642 assert!(errs.is_empty());
643 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
644 }
645
646 #[test]
647 fn deny_wins_over_allow_when_both_listed() {
648 let map: BTreeMap<String, AllowBuildRaw> = [
649 ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
650 ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
651 ]
652 .into_iter()
653 .collect();
654 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
655 assert!(errs.is_empty());
656 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
657 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
658 }
659
660 #[test]
661 fn deny_all_is_default() {
662 let p = BuildPolicy::deny_all();
663 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
664 assert!(!p.has_any_allow_rule());
665 }
666
667 #[test]
668 fn allow_all_flag() {
669 let p = BuildPolicy::allow_all();
670 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
671 assert!(p.has_any_allow_rule());
672 }
673
674 #[test]
675 fn invalid_version_union_reports_warning() {
676 let map: BTreeMap<String, AllowBuildRaw> = [(
677 "esbuild@not-a-version".to_string(),
678 AllowBuildRaw::Bool(true),
679 )]
680 .into_iter()
681 .collect();
682 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
683 assert_eq!(errs.len(), 1);
684 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
686 }
687
688 #[test]
689 fn source_specs_cannot_be_union_members() {
690 let map: BTreeMap<String, AllowBuildRaw> = [(
691 "dependency@https://example.com/dep.tgz || 1.0.0".to_string(),
692 AllowBuildRaw::Bool(true),
693 )]
694 .into_iter()
695 .collect();
696 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
697 assert_eq!(errs.len(), 1);
698 assert!(matches!(errs[0], BuildPolicyError::InvalidVersionUnion(_)));
699 }
700
701 #[test]
702 fn semver_then_source_spec_union_is_also_rejected() {
703 let map: BTreeMap<String, AllowBuildRaw> = [(
704 "dependency@1.0.0 || https://example.com/dep.tgz".to_string(),
705 AllowBuildRaw::Bool(true),
706 )]
707 .into_iter()
708 .collect();
709 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
710 assert_eq!(errs.len(), 1);
711 assert!(matches!(errs[0], BuildPolicyError::InvalidVersionUnion(_)));
712 }
713
714 #[test]
715 fn non_bool_value_reports_warning() {
716 let map: BTreeMap<String, AllowBuildRaw> =
717 [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
718 .into_iter()
719 .collect();
720 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
721 assert_eq!(errs.len(), 1);
722 }
723
724 #[test]
725 fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
726 let map = BTreeMap::new();
730 let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
731 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
732 assert!(errs.is_empty());
733 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
734 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
735 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
736 assert!(p.has_any_allow_rule());
737 }
738
739 #[test]
740 fn never_built_dependencies_denies() {
741 let map = BTreeMap::new();
742 let only_built = vec!["esbuild".to_string()];
743 let never_built = vec!["esbuild@0.19.0".to_string()];
744 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
745 assert!(errs.is_empty());
746 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
747 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
748 }
749
750 #[test]
751 fn never_built_beats_allow_builds_map() {
752 let map: BTreeMap<String, AllowBuildRaw> =
756 [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
757 .into_iter()
758 .collect();
759 let never_built = vec!["esbuild".to_string()];
760 let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
761 assert!(errs.is_empty());
762 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
763 }
764
765 #[test]
766 fn merge_deduplicates_wildcards() {
767 let mut p = policy(&[("@babel/*", true), ("*-internal", false)]);
768 let other = policy(&[
769 ("@babel/*", true),
770 ("@types/*", true),
771 ("*-internal", false),
772 ]);
773 p.merge(&other);
774 p.merge(&other);
775
776 assert_eq!(p.allowed_wildcards, vec!["@babel/*", "@types/*"]);
777 assert_eq!(p.denied_wildcards, vec!["*-internal"]);
778 assert_eq!(p.decide("@types/node", "1.0.0"), AllowDecision::Allow);
779 assert_eq!(p.decide("pkg-internal", "1.0.0"), AllowDecision::Deny);
780 }
781
782 #[test]
783 fn splits_scoped_correctly() {
784 assert_eq!(
785 split_name_and_versions("@swc/core@1.3.0"),
786 ("@swc/core", "1.3.0")
787 );
788 assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
789 assert_eq!(
790 split_name_and_versions("esbuild@0.19.0"),
791 ("esbuild", "0.19.0")
792 );
793 assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
794 }
795
796 #[test]
797 fn wildcard_scope_allows_every_scope_member() {
798 let p = policy(&[("@babel/*", true)]);
799 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
800 assert_eq!(
801 p.decide("@babel/preset-env", "7.22.0"),
802 AllowDecision::Allow
803 );
804 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
805 assert_eq!(
806 p.decide("babel-loader", "9.0.0"),
807 AllowDecision::Unspecified
808 );
809 assert!(p.has_any_allow_rule());
810 }
811
812 #[test]
813 fn wildcard_suffix_matches_any_prefix() {
814 let p = policy(&[("*-loader", true)]);
815 assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
816 assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
817 assert_eq!(
818 p.decide("loader-utils", "3.0.0"),
819 AllowDecision::Unspecified
820 );
821 }
822
823 #[test]
824 fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
825 let map: BTreeMap<String, AllowBuildRaw> = [
829 ("*".to_string(), AllowBuildRaw::Bool(true)),
830 ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
831 ]
832 .into_iter()
833 .collect();
834 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
835 assert!(errs.is_empty());
836 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
837 assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
838 }
839
840 #[test]
841 fn denied_wildcard_blocks_allowed_exact() {
842 let map: BTreeMap<String, AllowBuildRaw> = [
843 ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
844 ("@babel/*".to_string(), AllowBuildRaw::Bool(false)),
845 ]
846 .into_iter()
847 .collect();
848 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
849 assert!(errs.is_empty());
850 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Deny);
851 assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
852 }
853
854 #[test]
855 fn wildcard_with_version_is_rejected() {
856 let map: BTreeMap<String, AllowBuildRaw> =
857 [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
858 .into_iter()
859 .collect();
860 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
861 assert_eq!(errs.len(), 1);
862 assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
863 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
866 }
867
868 #[test]
869 fn wildcards_flow_through_flat_lists_too() {
870 let only_built = vec!["@types/*".to_string()];
871 let never_built = vec!["*-internal".to_string()];
872 let (p, errs) =
873 BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
874 assert!(errs.is_empty());
875 assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
876 assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
877 assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
878 }
879
880 #[test]
881 fn matches_wildcard_handles_all_positions() {
882 assert!(matches_wildcard("@babel/core", "@babel/*"));
883 assert!(matches_wildcard("@babel/", "@babel/*"));
884 assert!(!matches_wildcard("@babe/core", "@babel/*"));
885
886 assert!(matches_wildcard("css-loader", "*-loader"));
887 assert!(matches_wildcard("-loader", "*-loader"));
888 assert!(!matches_wildcard("loader-x", "*-loader"));
889
890 assert!(matches_wildcard("foobar", "foo*bar"));
891 assert!(matches_wildcard("foo-x-bar", "foo*bar"));
892 assert!(!matches_wildcard("foobaz", "foo*bar"));
893
894 assert!(matches_wildcard("@x/anything", "*"));
895 assert!(matches_wildcard("", "*"));
896
897 assert!(matches_wildcard("anything", "**"));
899 }
900
901 #[test]
902 fn matches_wildcard_multi_segment_greedy_is_correct() {
903 assert!(matches_wildcard("abca", "*a*bc*a"));
910 assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
911 assert!(matches_wildcard("abcaXa", "*a*bc*a"));
912 assert!(matches_wildcard("ababab", "*ab*ab*"));
913 assert!(matches_wildcard("abcd", "a*b*c*d"));
914 assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
915
916 assert!(!matches_wildcard("aab", "*ab*ab"));
920 assert!(!matches_wildcard("abab", "*abc*abc"));
921
922 assert!(matches_wildcard(
924 "@acme/core-loader-plugin",
925 "@acme/*-*-plugin"
926 ));
927 assert!(!matches_wildcard(
928 "@acme/core-plugin-extra",
929 "@acme/*-*-plugin"
930 ));
931 }
932
933 #[test]
934 fn semver_shape() {
935 assert!(is_exact_semver("1.2.3"));
936 assert!(is_exact_semver("0.19.0"));
937 assert!(is_exact_semver("1.0.0-alpha"));
938 assert!(is_exact_semver("1.0.0+build.42"));
939 assert!(!is_exact_semver("1.2"));
940 assert!(!is_exact_semver("^1.2.3"));
941 assert!(!is_exact_semver("1.x.0"));
942 }
943}