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 warnings.push(BuildPolicyError::UnsupportedValue {
106 pattern: pattern.clone(),
107 raw: raw.clone(),
108 });
109 continue;
110 }
111 };
112 match expand_spec(pattern) {
113 Ok(expanded) => {
114 let (exact, wild) = if bool_value {
115 (&mut allowed, &mut allowed_wildcards)
116 } else {
117 (&mut denied, &mut denied_wildcards)
118 };
119 sort_entries(expanded, exact, wild);
120 }
121 Err(e) => warnings.push(e),
122 }
123 }
124
125 for pattern in only_built {
131 match expand_spec(pattern) {
132 Ok(expanded) => sort_entries(expanded, &mut allowed, &mut allowed_wildcards),
133 Err(e) => warnings.push(e),
134 }
135 }
136 for pattern in never_built {
137 match expand_spec(pattern) {
138 Ok(expanded) => sort_entries(expanded, &mut denied, &mut denied_wildcards),
139 Err(e) => warnings.push(e),
140 }
141 }
142
143 (
144 Self {
145 allow_all: false,
146 allowed,
147 denied,
148 allowed_wildcards,
149 denied_wildcards,
150 },
151 warnings,
152 )
153 }
154
155 pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
158 let with_version = format!("{name}@{version}");
159 if self.denied.contains(name) || self.denied.contains(&with_version) {
160 return AllowDecision::Deny;
161 }
162 if matches_any_wildcard(name, &self.denied_wildcards) {
163 return AllowDecision::Deny;
164 }
165 if self.allow_all {
166 return AllowDecision::Allow;
167 }
168 if self.allowed.contains(name) || self.allowed.contains(&with_version) {
169 return AllowDecision::Allow;
170 }
171 if matches_any_wildcard(name, &self.allowed_wildcards) {
172 return AllowDecision::Allow;
173 }
174 AllowDecision::Unspecified
175 }
176
177 pub fn has_any_allow_rule(&self) -> bool {
181 self.allow_all || !self.allowed.is_empty() || !self.allowed_wildcards.is_empty()
182 }
183}
184
185fn sort_entries(entries: Vec<String>, exact: &mut HashSet<String>, wildcards: &mut Vec<String>) {
190 for entry in entries {
191 if entry.contains('*') {
192 if !wildcards.iter().any(|p| p == &entry) {
193 wildcards.push(entry);
194 }
195 } else {
196 exact.insert(entry);
197 }
198 }
199}
200
201fn matches_any_wildcard(name: &str, patterns: &[String]) -> bool {
216 patterns.iter().any(|p| matches_wildcard(name, p))
217}
218
219fn matches_wildcard(name: &str, pattern: &str) -> bool {
220 let parts: Vec<&str> = pattern.split('*').collect();
221 let (first, rest) = match parts.split_first() {
224 Some(pair) => pair,
225 None => return false,
226 };
227 let Some(after_prefix) = name.strip_prefix(first) else {
228 return false;
229 };
230 let (last, middle) = match rest.split_last() {
231 Some(pair) => pair,
232 None => {
237 debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
238 return false;
239 }
240 };
241
242 let mut remaining = after_prefix;
243 for mid in middle {
244 match remaining.find(mid) {
245 Some(idx) => remaining = &remaining[idx + mid.len()..],
246 None => return false,
247 }
248 }
249 remaining.len() >= last.len() && remaining.ends_with(last)
250}
251
252#[derive(Debug, Clone, thiserror::Error)]
253pub enum BuildPolicyError {
254 #[error("allowBuilds entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
255 UnsupportedValue { pattern: String, raw: String },
256 #[error("allowBuilds pattern {0:?} contains an invalid version union")]
257 InvalidVersionUnion(String),
258 #[error("allowBuilds pattern {0:?} mixes a wildcard name with a version union")]
259 WildcardWithVersion(String),
260}
261
262fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
266 let (name, versions_part) = split_name_and_versions(pattern);
267
268 if versions_part.is_empty() {
269 return Ok(vec![name.to_string()]);
270 }
271 if name.contains('*') {
272 return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
273 }
274
275 let mut out = Vec::new();
276 for raw in versions_part.split("||") {
277 let trimmed = raw.trim();
278 if trimmed.is_empty() || !is_exact_semver(trimmed) {
279 return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
280 }
281 out.push(format!("{name}@{trimmed}"));
282 }
283 Ok(out)
284}
285
286fn split_name_and_versions(pattern: &str) -> (&str, &str) {
289 let scoped = pattern.starts_with('@');
290 let search_from = if scoped { 1 } else { 0 };
291 match pattern[search_from..].find('@') {
292 Some(rel) => {
293 let at = search_from + rel;
294 (&pattern[..at], &pattern[at + 1..])
295 }
296 None => (pattern, ""),
297 }
298}
299
300fn is_exact_semver(s: &str) -> bool {
305 let core = s.split('+').next().unwrap_or(s);
307 let main = core.split('-').next().unwrap_or(core);
309 let parts: Vec<&str> = main.split('.').collect();
310 if parts.len() != 3 {
311 return false;
312 }
313 parts
314 .iter()
315 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
323 let map: BTreeMap<String, AllowBuildRaw> = pairs
324 .iter()
325 .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
326 .collect();
327 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
328 assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
329 p
330 }
331
332 #[test]
333 fn bare_name_allows_any_version() {
334 let p = policy(&[("esbuild", true)]);
335 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
336 assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
337 assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
338 }
339
340 #[test]
341 fn exact_version_is_strict() {
342 let p = policy(&[("esbuild@0.19.0", true)]);
343 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
344 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
345 }
346
347 #[test]
348 fn version_union_splits() {
349 let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
350 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
351 assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
352 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
353 }
354
355 #[test]
356 fn scoped_package_parses() {
357 let p = policy(&[("@swc/core@1.3.0", true)]);
358 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
359 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
360 }
361
362 #[test]
363 fn scoped_bare_name() {
364 let p = policy(&[("@swc/core", true)]);
365 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
366 }
367
368 #[test]
369 fn dangerously_allow_all_bypasses_deny_list() {
370 let mut map = BTreeMap::new();
376 map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
377 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
378 assert!(errs.is_empty());
379 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
380 }
381
382 #[test]
383 fn deny_wins_over_allow_when_both_listed() {
384 let map: BTreeMap<String, AllowBuildRaw> = [
385 ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
386 ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
387 ]
388 .into_iter()
389 .collect();
390 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
391 assert!(errs.is_empty());
392 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
393 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
394 }
395
396 #[test]
397 fn deny_all_is_default() {
398 let p = BuildPolicy::deny_all();
399 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
400 assert!(!p.has_any_allow_rule());
401 }
402
403 #[test]
404 fn allow_all_flag() {
405 let p = BuildPolicy::allow_all();
406 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
407 assert!(p.has_any_allow_rule());
408 }
409
410 #[test]
411 fn invalid_version_union_reports_warning() {
412 let map: BTreeMap<String, AllowBuildRaw> = [(
413 "esbuild@not-a-version".to_string(),
414 AllowBuildRaw::Bool(true),
415 )]
416 .into_iter()
417 .collect();
418 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
419 assert_eq!(errs.len(), 1);
420 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
422 }
423
424 #[test]
425 fn non_bool_value_reports_warning() {
426 let map: BTreeMap<String, AllowBuildRaw> =
427 [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
428 .into_iter()
429 .collect();
430 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
431 assert_eq!(errs.len(), 1);
432 }
433
434 #[test]
435 fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
436 let map = BTreeMap::new();
440 let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
441 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
442 assert!(errs.is_empty());
443 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
444 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
445 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
446 assert!(p.has_any_allow_rule());
447 }
448
449 #[test]
450 fn never_built_dependencies_denies() {
451 let map = BTreeMap::new();
452 let only_built = vec!["esbuild".to_string()];
453 let never_built = vec!["esbuild@0.19.0".to_string()];
454 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
455 assert!(errs.is_empty());
456 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
457 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
458 }
459
460 #[test]
461 fn never_built_beats_allow_builds_map() {
462 let map: BTreeMap<String, AllowBuildRaw> =
466 [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
467 .into_iter()
468 .collect();
469 let never_built = vec!["esbuild".to_string()];
470 let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
471 assert!(errs.is_empty());
472 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
473 }
474
475 #[test]
476 fn splits_scoped_correctly() {
477 assert_eq!(
478 split_name_and_versions("@swc/core@1.3.0"),
479 ("@swc/core", "1.3.0")
480 );
481 assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
482 assert_eq!(
483 split_name_and_versions("esbuild@0.19.0"),
484 ("esbuild", "0.19.0")
485 );
486 assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
487 }
488
489 #[test]
490 fn wildcard_scope_allows_every_scope_member() {
491 let p = policy(&[("@babel/*", true)]);
492 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
493 assert_eq!(
494 p.decide("@babel/preset-env", "7.22.0"),
495 AllowDecision::Allow
496 );
497 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
498 assert_eq!(
499 p.decide("babel-loader", "9.0.0"),
500 AllowDecision::Unspecified
501 );
502 assert!(p.has_any_allow_rule());
503 }
504
505 #[test]
506 fn wildcard_suffix_matches_any_prefix() {
507 let p = policy(&[("*-loader", true)]);
508 assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
509 assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
510 assert_eq!(
511 p.decide("loader-utils", "3.0.0"),
512 AllowDecision::Unspecified
513 );
514 }
515
516 #[test]
517 fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
518 let map: BTreeMap<String, AllowBuildRaw> = [
522 ("*".to_string(), AllowBuildRaw::Bool(true)),
523 ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
524 ]
525 .into_iter()
526 .collect();
527 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
528 assert!(errs.is_empty());
529 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
530 assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
531 }
532
533 #[test]
534 fn denied_wildcard_blocks_allowed_exact() {
535 let map: BTreeMap<String, AllowBuildRaw> = [
536 ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
537 ("@babel/*".to_string(), AllowBuildRaw::Bool(false)),
538 ]
539 .into_iter()
540 .collect();
541 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
542 assert!(errs.is_empty());
543 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Deny);
544 assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
545 }
546
547 #[test]
548 fn wildcard_with_version_is_rejected() {
549 let map: BTreeMap<String, AllowBuildRaw> =
550 [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
551 .into_iter()
552 .collect();
553 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
554 assert_eq!(errs.len(), 1);
555 assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
556 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
559 }
560
561 #[test]
562 fn wildcards_flow_through_flat_lists_too() {
563 let only_built = vec!["@types/*".to_string()];
564 let never_built = vec!["*-internal".to_string()];
565 let (p, errs) =
566 BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
567 assert!(errs.is_empty());
568 assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
569 assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
570 assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
571 }
572
573 #[test]
574 fn matches_wildcard_handles_all_positions() {
575 assert!(matches_wildcard("@babel/core", "@babel/*"));
576 assert!(matches_wildcard("@babel/", "@babel/*"));
577 assert!(!matches_wildcard("@babe/core", "@babel/*"));
578
579 assert!(matches_wildcard("css-loader", "*-loader"));
580 assert!(matches_wildcard("-loader", "*-loader"));
581 assert!(!matches_wildcard("loader-x", "*-loader"));
582
583 assert!(matches_wildcard("foobar", "foo*bar"));
584 assert!(matches_wildcard("foo-x-bar", "foo*bar"));
585 assert!(!matches_wildcard("foobaz", "foo*bar"));
586
587 assert!(matches_wildcard("@x/anything", "*"));
588 assert!(matches_wildcard("", "*"));
589
590 assert!(matches_wildcard("anything", "**"));
592 }
593
594 #[test]
595 fn matches_wildcard_multi_segment_greedy_is_correct() {
596 assert!(matches_wildcard("abca", "*a*bc*a"));
603 assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
604 assert!(matches_wildcard("abcaXa", "*a*bc*a"));
605 assert!(matches_wildcard("ababab", "*ab*ab*"));
606 assert!(matches_wildcard("abcd", "a*b*c*d"));
607 assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
608
609 assert!(!matches_wildcard("aab", "*ab*ab"));
613 assert!(!matches_wildcard("abab", "*abc*abc"));
614
615 assert!(matches_wildcard(
617 "@acme/core-loader-plugin",
618 "@acme/*-*-plugin"
619 ));
620 assert!(!matches_wildcard(
621 "@acme/core-plugin-extra",
622 "@acme/*-*-plugin"
623 ));
624 }
625
626 #[test]
627 fn semver_shape() {
628 assert!(is_exact_semver("1.2.3"));
629 assert!(is_exact_semver("0.19.0"));
630 assert!(is_exact_semver("1.0.0-alpha"));
631 assert!(is_exact_semver("1.0.0+build.42"));
632 assert!(!is_exact_semver("1.2"));
633 assert!(!is_exact_semver("^1.2.3"));
634 assert!(!is_exact_semver("1.x.0"));
635 }
636}