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_wildcards: Vec<String>,
59 denied_wildcards: Vec<String>,
60}
61
62impl BuildPolicy {
63 pub fn deny_all() -> Self {
65 Self::default()
66 }
67
68 pub fn allow_all() -> Self {
71 Self {
72 allow_all: true,
73 ..Self::default()
74 }
75 }
76
77 pub fn from_config(
87 allow_builds: &BTreeMap<String, AllowBuildRaw>,
88 only_built: &[String],
89 never_built: &[String],
90 dangerously_allow_all: bool,
91 ) -> (Self, Vec<BuildPolicyError>) {
92 if dangerously_allow_all {
93 return (Self::allow_all(), Vec::new());
94 }
95 let mut allowed = HashSet::new();
96 let mut denied = HashSet::new();
97 let mut allowed_wildcards = Vec::new();
98 let mut denied_wildcards = Vec::new();
99 let mut warnings = Vec::new();
100
101 for (pattern, value) in allow_builds {
102 let bool_value = match value {
103 AllowBuildRaw::Bool(b) => *b,
104 AllowBuildRaw::Other(raw) => {
105 if raw == aube_manifest::workspace::ALLOW_BUILDS_REVIEW_PLACEHOLDER {
113 continue;
114 }
115 warnings.push(BuildPolicyError::UnsupportedValue {
116 pattern: pattern.clone(),
117 raw: raw.clone(),
118 });
119 continue;
120 }
121 };
122 match expand_spec(pattern) {
123 Ok(expanded) => {
124 let (exact, wild) = if bool_value {
125 (&mut allowed, &mut allowed_wildcards)
126 } else {
127 (&mut denied, &mut denied_wildcards)
128 };
129 sort_entries(expanded, exact, wild);
130 }
131 Err(e) => warnings.push(e),
132 }
133 }
134
135 for pattern in only_built {
141 match expand_spec(pattern) {
142 Ok(expanded) => sort_entries(expanded, &mut allowed, &mut allowed_wildcards),
143 Err(e) => warnings.push(e),
144 }
145 }
146 for pattern in never_built {
147 match expand_spec(pattern) {
148 Ok(expanded) => sort_entries(expanded, &mut denied, &mut denied_wildcards),
149 Err(e) => warnings.push(e),
150 }
151 }
152
153 (
154 Self {
155 allow_all: false,
156 allowed,
157 denied,
158 allowed_wildcards,
159 denied_wildcards,
160 },
161 warnings,
162 )
163 }
164
165 pub fn denylist(denied_patterns: &[String]) -> (Self, Vec<BuildPolicyError>) {
167 let mut denied = HashSet::new();
168 let mut denied_wildcards = Vec::new();
169 let mut warnings = Vec::new();
170 for pattern in denied_patterns {
171 match expand_spec(pattern) {
172 Ok(expanded) => sort_entries(expanded, &mut denied, &mut denied_wildcards),
173 Err(e) => warnings.push(e),
174 }
175 }
176 (
177 Self {
178 allow_all: true,
179 allowed: HashSet::new(),
180 denied,
181 allowed_wildcards: Vec::new(),
182 denied_wildcards,
183 },
184 warnings,
185 )
186 }
187
188 pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
191 thread_local! {
195 static KEY_BUF: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
196 }
197 if self.denied.contains(name) {
198 return AllowDecision::Deny;
199 }
200 if matches_any_wildcard(name, &self.denied_wildcards) {
201 return AllowDecision::Deny;
202 }
203 let (denied_versioned, allowed_versioned) = KEY_BUF.with(|buf| {
206 let mut b = buf.borrow_mut();
207 b.clear();
208 use std::fmt::Write as _;
209 let _ = write!(b, "{name}@{version}");
210 let key = b.as_str();
211 (self.denied.contains(key), self.allowed.contains(key))
212 });
213 if denied_versioned {
214 return AllowDecision::Deny;
215 }
216 if self.allow_all {
217 return AllowDecision::Allow;
218 }
219 if self.allowed.contains(name) || allowed_versioned {
220 return AllowDecision::Allow;
221 }
222 if matches_any_wildcard(name, &self.allowed_wildcards) {
223 return AllowDecision::Allow;
224 }
225 AllowDecision::Unspecified
226 }
227
228 pub fn has_any_allow_rule(&self) -> bool {
232 self.allow_all || !self.allowed.is_empty() || !self.allowed_wildcards.is_empty()
233 }
234
235 pub fn merge(&mut self, other: &Self) {
238 self.allow_all |= other.allow_all;
239 self.allowed.extend(other.allowed.iter().cloned());
240 self.denied.extend(other.denied.iter().cloned());
241 merge_unique(&mut self.allowed_wildcards, &other.allowed_wildcards);
242 merge_unique(&mut self.denied_wildcards, &other.denied_wildcards);
243 }
244}
245
246fn merge_unique(target: &mut Vec<String>, source: &[String]) {
247 for value in source {
248 if !target.iter().any(|existing| existing == value) {
249 target.push(value.clone());
250 }
251 }
252}
253
254pub fn pattern_matches(pattern: &str, name: &str, version: &str) -> Result<bool, BuildPolicyError> {
256 let with_version = format!("{name}@{version}");
257 for expanded in expand_spec(pattern)? {
258 if expanded.contains('*') {
259 if matches_wildcard(name, &expanded) {
260 return Ok(true);
261 }
262 } else if expanded == name || expanded == with_version {
263 return Ok(true);
264 }
265 }
266 Ok(false)
267}
268
269fn sort_entries(entries: Vec<String>, exact: &mut HashSet<String>, wildcards: &mut Vec<String>) {
274 for entry in entries {
275 if entry.contains('*') {
276 if !wildcards.iter().any(|p| p == &entry) {
277 wildcards.push(entry);
278 }
279 } else {
280 exact.insert(entry);
281 }
282 }
283}
284
285fn matches_any_wildcard(name: &str, patterns: &[String]) -> bool {
300 patterns.iter().any(|p| matches_wildcard(name, p))
301}
302
303fn matches_wildcard(name: &str, pattern: &str) -> bool {
304 let parts: Vec<&str> = pattern.split('*').collect();
305 let (first, rest) = match parts.split_first() {
308 Some(pair) => pair,
309 None => return false,
310 };
311 let Some(after_prefix) = name.strip_prefix(first) else {
312 return false;
313 };
314 let (last, middle) = match rest.split_last() {
315 Some(pair) => pair,
316 None => {
321 debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
322 return false;
323 }
324 };
325
326 let mut remaining = after_prefix;
327 for mid in middle {
328 match remaining.find(mid) {
329 Some(idx) => remaining = &remaining[idx + mid.len()..],
330 None => return false,
331 }
332 }
333 remaining.len() >= last.len() && remaining.ends_with(last)
334}
335
336#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
337pub enum BuildPolicyError {
338 #[error("build policy entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
339 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_UNSUPPORTED_VALUE))]
340 UnsupportedValue { pattern: String, raw: String },
341 #[error("build policy pattern {0:?} contains an invalid version union")]
342 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_INVALID_VERSION_UNION))]
343 InvalidVersionUnion(String),
344 #[error("build policy pattern {0:?} mixes a wildcard name with a version union")]
345 #[diagnostic(code(ERR_AUBE_BUILD_POLICY_WILDCARD_WITH_VERSION))]
346 WildcardWithVersion(String),
347}
348
349fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
353 let (name, versions_part) = split_name_and_versions(pattern);
354
355 if versions_part.is_empty() {
356 return Ok(vec![name.to_string()]);
357 }
358 if name.contains('*') {
359 return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
360 }
361
362 let mut out = Vec::new();
363 for raw in versions_part.split("||") {
364 let trimmed = raw.trim();
365 if trimmed.is_empty() || !is_exact_semver(trimmed) {
366 return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
367 }
368 out.push(format!("{name}@{trimmed}"));
369 }
370 Ok(out)
371}
372
373fn split_name_and_versions(pattern: &str) -> (&str, &str) {
376 let scoped = pattern.starts_with('@');
377 let search_from = if scoped { 1 } else { 0 };
378 match pattern[search_from..].find('@') {
379 Some(rel) => {
380 let at = search_from + rel;
381 (&pattern[..at], &pattern[at + 1..])
382 }
383 None => (pattern, ""),
384 }
385}
386
387fn is_exact_semver(s: &str) -> bool {
392 let core = s.split('+').next().unwrap_or(s);
394 let main = core.split('-').next().unwrap_or(core);
396 let parts: Vec<&str> = main.split('.').collect();
397 if parts.len() != 3 {
398 return false;
399 }
400 parts
401 .iter()
402 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
410 let map: BTreeMap<String, AllowBuildRaw> = pairs
411 .iter()
412 .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
413 .collect();
414 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
415 assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
416 p
417 }
418
419 #[test]
420 fn bare_name_allows_any_version() {
421 let p = policy(&[("esbuild", true)]);
422 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
423 assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
424 assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
425 }
426
427 #[test]
428 fn exact_version_is_strict() {
429 let p = policy(&[("esbuild@0.19.0", true)]);
430 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
431 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
432 }
433
434 #[test]
435 fn version_union_splits() {
436 let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
437 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
438 assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
439 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
440 }
441
442 #[test]
443 fn scoped_package_parses() {
444 let p = policy(&[("@swc/core@1.3.0", true)]);
445 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
446 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
447 }
448
449 #[test]
450 fn scoped_bare_name() {
451 let p = policy(&[("@swc/core", true)]);
452 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
453 }
454
455 #[test]
456 fn pattern_matches_scoped_names_and_versions() {
457 assert!(pattern_matches("@swc/core", "@swc/core", "1.3.0").unwrap());
458 assert!(pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.0").unwrap());
459 assert!(!pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.1").unwrap());
460 assert!(pattern_matches("@swc/*", "@swc/core", "1.3.0").unwrap());
461 assert!(pattern_matches("aube-test-*", "aube-test-native", "1.0.0").unwrap());
462 }
463
464 #[test]
465 fn dangerously_allow_all_bypasses_deny_list() {
466 let mut map = BTreeMap::new();
472 map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
473 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
474 assert!(errs.is_empty());
475 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
476 }
477
478 #[test]
479 fn deny_wins_over_allow_when_both_listed() {
480 let map: BTreeMap<String, AllowBuildRaw> = [
481 ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
482 ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
483 ]
484 .into_iter()
485 .collect();
486 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
487 assert!(errs.is_empty());
488 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
489 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
490 }
491
492 #[test]
493 fn deny_all_is_default() {
494 let p = BuildPolicy::deny_all();
495 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
496 assert!(!p.has_any_allow_rule());
497 }
498
499 #[test]
500 fn allow_all_flag() {
501 let p = BuildPolicy::allow_all();
502 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
503 assert!(p.has_any_allow_rule());
504 }
505
506 #[test]
507 fn invalid_version_union_reports_warning() {
508 let map: BTreeMap<String, AllowBuildRaw> = [(
509 "esbuild@not-a-version".to_string(),
510 AllowBuildRaw::Bool(true),
511 )]
512 .into_iter()
513 .collect();
514 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
515 assert_eq!(errs.len(), 1);
516 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
518 }
519
520 #[test]
521 fn non_bool_value_reports_warning() {
522 let map: BTreeMap<String, AllowBuildRaw> =
523 [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
524 .into_iter()
525 .collect();
526 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
527 assert_eq!(errs.len(), 1);
528 }
529
530 #[test]
531 fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
532 let map = BTreeMap::new();
536 let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
537 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
538 assert!(errs.is_empty());
539 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
540 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
541 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
542 assert!(p.has_any_allow_rule());
543 }
544
545 #[test]
546 fn never_built_dependencies_denies() {
547 let map = BTreeMap::new();
548 let only_built = vec!["esbuild".to_string()];
549 let never_built = vec!["esbuild@0.19.0".to_string()];
550 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
551 assert!(errs.is_empty());
552 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
553 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
554 }
555
556 #[test]
557 fn never_built_beats_allow_builds_map() {
558 let map: BTreeMap<String, AllowBuildRaw> =
562 [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
563 .into_iter()
564 .collect();
565 let never_built = vec!["esbuild".to_string()];
566 let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
567 assert!(errs.is_empty());
568 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
569 }
570
571 #[test]
572 fn merge_deduplicates_wildcards() {
573 let mut p = policy(&[("@babel/*", true), ("*-internal", false)]);
574 let other = policy(&[
575 ("@babel/*", true),
576 ("@types/*", true),
577 ("*-internal", false),
578 ]);
579 p.merge(&other);
580 p.merge(&other);
581
582 assert_eq!(p.allowed_wildcards, vec!["@babel/*", "@types/*"]);
583 assert_eq!(p.denied_wildcards, vec!["*-internal"]);
584 assert_eq!(p.decide("@types/node", "1.0.0"), AllowDecision::Allow);
585 assert_eq!(p.decide("pkg-internal", "1.0.0"), AllowDecision::Deny);
586 }
587
588 #[test]
589 fn splits_scoped_correctly() {
590 assert_eq!(
591 split_name_and_versions("@swc/core@1.3.0"),
592 ("@swc/core", "1.3.0")
593 );
594 assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
595 assert_eq!(
596 split_name_and_versions("esbuild@0.19.0"),
597 ("esbuild", "0.19.0")
598 );
599 assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
600 }
601
602 #[test]
603 fn wildcard_scope_allows_every_scope_member() {
604 let p = policy(&[("@babel/*", true)]);
605 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
606 assert_eq!(
607 p.decide("@babel/preset-env", "7.22.0"),
608 AllowDecision::Allow
609 );
610 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
611 assert_eq!(
612 p.decide("babel-loader", "9.0.0"),
613 AllowDecision::Unspecified
614 );
615 assert!(p.has_any_allow_rule());
616 }
617
618 #[test]
619 fn wildcard_suffix_matches_any_prefix() {
620 let p = policy(&[("*-loader", true)]);
621 assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
622 assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
623 assert_eq!(
624 p.decide("loader-utils", "3.0.0"),
625 AllowDecision::Unspecified
626 );
627 }
628
629 #[test]
630 fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
631 let map: BTreeMap<String, AllowBuildRaw> = [
635 ("*".to_string(), AllowBuildRaw::Bool(true)),
636 ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
637 ]
638 .into_iter()
639 .collect();
640 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
641 assert!(errs.is_empty());
642 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
643 assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
644 }
645
646 #[test]
647 fn denied_wildcard_blocks_allowed_exact() {
648 let map: BTreeMap<String, AllowBuildRaw> = [
649 ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
650 ("@babel/*".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("@babel/core", "7.0.0"), AllowDecision::Deny);
657 assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
658 }
659
660 #[test]
661 fn wildcard_with_version_is_rejected() {
662 let map: BTreeMap<String, AllowBuildRaw> =
663 [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
664 .into_iter()
665 .collect();
666 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
667 assert_eq!(errs.len(), 1);
668 assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
669 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
672 }
673
674 #[test]
675 fn wildcards_flow_through_flat_lists_too() {
676 let only_built = vec!["@types/*".to_string()];
677 let never_built = vec!["*-internal".to_string()];
678 let (p, errs) =
679 BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
680 assert!(errs.is_empty());
681 assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
682 assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
683 assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
684 }
685
686 #[test]
687 fn matches_wildcard_handles_all_positions() {
688 assert!(matches_wildcard("@babel/core", "@babel/*"));
689 assert!(matches_wildcard("@babel/", "@babel/*"));
690 assert!(!matches_wildcard("@babe/core", "@babel/*"));
691
692 assert!(matches_wildcard("css-loader", "*-loader"));
693 assert!(matches_wildcard("-loader", "*-loader"));
694 assert!(!matches_wildcard("loader-x", "*-loader"));
695
696 assert!(matches_wildcard("foobar", "foo*bar"));
697 assert!(matches_wildcard("foo-x-bar", "foo*bar"));
698 assert!(!matches_wildcard("foobaz", "foo*bar"));
699
700 assert!(matches_wildcard("@x/anything", "*"));
701 assert!(matches_wildcard("", "*"));
702
703 assert!(matches_wildcard("anything", "**"));
705 }
706
707 #[test]
708 fn matches_wildcard_multi_segment_greedy_is_correct() {
709 assert!(matches_wildcard("abca", "*a*bc*a"));
716 assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
717 assert!(matches_wildcard("abcaXa", "*a*bc*a"));
718 assert!(matches_wildcard("ababab", "*ab*ab*"));
719 assert!(matches_wildcard("abcd", "a*b*c*d"));
720 assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
721
722 assert!(!matches_wildcard("aab", "*ab*ab"));
726 assert!(!matches_wildcard("abab", "*abc*abc"));
727
728 assert!(matches_wildcard(
730 "@acme/core-loader-plugin",
731 "@acme/*-*-plugin"
732 ));
733 assert!(!matches_wildcard(
734 "@acme/core-plugin-extra",
735 "@acme/*-*-plugin"
736 ));
737 }
738
739 #[test]
740 fn semver_shape() {
741 assert!(is_exact_semver("1.2.3"));
742 assert!(is_exact_semver("0.19.0"));
743 assert!(is_exact_semver("1.0.0-alpha"));
744 assert!(is_exact_semver("1.0.0+build.42"));
745 assert!(!is_exact_semver("1.2"));
746 assert!(!is_exact_semver("^1.2.3"));
747 assert!(!is_exact_semver("1.x.0"));
748 }
749}