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