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 self
163 .denied_wildcards
164 .iter()
165 .any(|p| matches_wildcard(name, p))
166 {
167 return AllowDecision::Deny;
168 }
169 if self.allow_all {
170 return AllowDecision::Allow;
171 }
172 if self.allowed.contains(name) || self.allowed.contains(&with_version) {
173 return AllowDecision::Allow;
174 }
175 if self
176 .allowed_wildcards
177 .iter()
178 .any(|p| matches_wildcard(name, p))
179 {
180 return AllowDecision::Allow;
181 }
182 AllowDecision::Unspecified
183 }
184
185 pub fn has_any_allow_rule(&self) -> bool {
189 self.allow_all || !self.allowed.is_empty() || !self.allowed_wildcards.is_empty()
190 }
191}
192
193fn sort_entries(entries: Vec<String>, exact: &mut HashSet<String>, wildcards: &mut Vec<String>) {
198 for entry in entries {
199 if entry.contains('*') {
200 if !wildcards.iter().any(|p| p == &entry) {
201 wildcards.push(entry);
202 }
203 } else {
204 exact.insert(entry);
205 }
206 }
207}
208
209fn matches_wildcard(name: &str, pattern: &str) -> bool {
224 let parts: Vec<&str> = pattern.split('*').collect();
225 let (first, rest) = match parts.split_first() {
228 Some(pair) => pair,
229 None => return false,
230 };
231 let Some(after_prefix) = name.strip_prefix(first) else {
232 return false;
233 };
234 let (last, middle) = match rest.split_last() {
235 Some(pair) => pair,
236 None => {
241 debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
242 return false;
243 }
244 };
245
246 let mut remaining = after_prefix;
247 for mid in middle {
248 match remaining.find(mid) {
249 Some(idx) => remaining = &remaining[idx + mid.len()..],
250 None => return false,
251 }
252 }
253 remaining.len() >= last.len() && remaining.ends_with(last)
254}
255
256#[derive(Debug, Clone, thiserror::Error)]
257pub enum BuildPolicyError {
258 #[error("allowBuilds entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
259 UnsupportedValue { pattern: String, raw: String },
260 #[error("allowBuilds pattern {0:?} contains an invalid version union")]
261 InvalidVersionUnion(String),
262 #[error("allowBuilds pattern {0:?} mixes a wildcard name with a version union")]
263 WildcardWithVersion(String),
264}
265
266fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
270 let (name, versions_part) = split_name_and_versions(pattern);
271
272 if versions_part.is_empty() {
273 return Ok(vec![name.to_string()]);
274 }
275 if name.contains('*') {
276 return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
277 }
278
279 let mut out = Vec::new();
280 for raw in versions_part.split("||") {
281 let trimmed = raw.trim();
282 if trimmed.is_empty() || !is_exact_semver(trimmed) {
283 return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
284 }
285 out.push(format!("{name}@{trimmed}"));
286 }
287 Ok(out)
288}
289
290fn split_name_and_versions(pattern: &str) -> (&str, &str) {
293 let scoped = pattern.starts_with('@');
294 let search_from = if scoped { 1 } else { 0 };
295 match pattern[search_from..].find('@') {
296 Some(rel) => {
297 let at = search_from + rel;
298 (&pattern[..at], &pattern[at + 1..])
299 }
300 None => (pattern, ""),
301 }
302}
303
304fn is_exact_semver(s: &str) -> bool {
309 let core = s.split('+').next().unwrap_or(s);
311 let main = core.split('-').next().unwrap_or(core);
313 let parts: Vec<&str> = main.split('.').collect();
314 if parts.len() != 3 {
315 return false;
316 }
317 parts
318 .iter()
319 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
327 let map: BTreeMap<String, AllowBuildRaw> = pairs
328 .iter()
329 .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
330 .collect();
331 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
332 assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
333 p
334 }
335
336 #[test]
337 fn bare_name_allows_any_version() {
338 let p = policy(&[("esbuild", true)]);
339 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
340 assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
341 assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
342 }
343
344 #[test]
345 fn exact_version_is_strict() {
346 let p = policy(&[("esbuild@0.19.0", true)]);
347 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
348 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
349 }
350
351 #[test]
352 fn version_union_splits() {
353 let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
354 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
355 assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
356 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
357 }
358
359 #[test]
360 fn scoped_package_parses() {
361 let p = policy(&[("@swc/core@1.3.0", true)]);
362 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
363 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
364 }
365
366 #[test]
367 fn scoped_bare_name() {
368 let p = policy(&[("@swc/core", true)]);
369 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
370 }
371
372 #[test]
373 fn dangerously_allow_all_bypasses_deny_list() {
374 let mut map = BTreeMap::new();
380 map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
381 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
382 assert!(errs.is_empty());
383 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
384 }
385
386 #[test]
387 fn deny_wins_over_allow_when_both_listed() {
388 let map: BTreeMap<String, AllowBuildRaw> = [
389 ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
390 ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
391 ]
392 .into_iter()
393 .collect();
394 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
395 assert!(errs.is_empty());
396 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
397 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
398 }
399
400 #[test]
401 fn deny_all_is_default() {
402 let p = BuildPolicy::deny_all();
403 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
404 assert!(!p.has_any_allow_rule());
405 }
406
407 #[test]
408 fn allow_all_flag() {
409 let p = BuildPolicy::allow_all();
410 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
411 assert!(p.has_any_allow_rule());
412 }
413
414 #[test]
415 fn invalid_version_union_reports_warning() {
416 let map: BTreeMap<String, AllowBuildRaw> = [(
417 "esbuild@not-a-version".to_string(),
418 AllowBuildRaw::Bool(true),
419 )]
420 .into_iter()
421 .collect();
422 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
423 assert_eq!(errs.len(), 1);
424 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
426 }
427
428 #[test]
429 fn non_bool_value_reports_warning() {
430 let map: BTreeMap<String, AllowBuildRaw> =
431 [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
432 .into_iter()
433 .collect();
434 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
435 assert_eq!(errs.len(), 1);
436 }
437
438 #[test]
439 fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
440 let map = BTreeMap::new();
444 let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
445 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
446 assert!(errs.is_empty());
447 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
448 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
449 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
450 assert!(p.has_any_allow_rule());
451 }
452
453 #[test]
454 fn never_built_dependencies_denies() {
455 let map = BTreeMap::new();
456 let only_built = vec!["esbuild".to_string()];
457 let never_built = vec!["esbuild@0.19.0".to_string()];
458 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
459 assert!(errs.is_empty());
460 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
461 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
462 }
463
464 #[test]
465 fn never_built_beats_allow_builds_map() {
466 let map: BTreeMap<String, AllowBuildRaw> =
470 [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
471 .into_iter()
472 .collect();
473 let never_built = vec!["esbuild".to_string()];
474 let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
475 assert!(errs.is_empty());
476 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
477 }
478
479 #[test]
480 fn splits_scoped_correctly() {
481 assert_eq!(
482 split_name_and_versions("@swc/core@1.3.0"),
483 ("@swc/core", "1.3.0")
484 );
485 assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
486 assert_eq!(
487 split_name_and_versions("esbuild@0.19.0"),
488 ("esbuild", "0.19.0")
489 );
490 assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
491 }
492
493 #[test]
494 fn wildcard_scope_allows_every_scope_member() {
495 let p = policy(&[("@babel/*", true)]);
496 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
497 assert_eq!(
498 p.decide("@babel/preset-env", "7.22.0"),
499 AllowDecision::Allow
500 );
501 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
502 assert_eq!(
503 p.decide("babel-loader", "9.0.0"),
504 AllowDecision::Unspecified
505 );
506 assert!(p.has_any_allow_rule());
507 }
508
509 #[test]
510 fn wildcard_suffix_matches_any_prefix() {
511 let p = policy(&[("*-loader", true)]);
512 assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
513 assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
514 assert_eq!(
515 p.decide("loader-utils", "3.0.0"),
516 AllowDecision::Unspecified
517 );
518 }
519
520 #[test]
521 fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
522 let map: BTreeMap<String, AllowBuildRaw> = [
526 ("*".to_string(), AllowBuildRaw::Bool(true)),
527 ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
528 ]
529 .into_iter()
530 .collect();
531 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
532 assert!(errs.is_empty());
533 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
534 assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
535 }
536
537 #[test]
538 fn denied_wildcard_blocks_allowed_exact() {
539 let map: BTreeMap<String, AllowBuildRaw> = [
540 ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
541 ("@babel/*".to_string(), AllowBuildRaw::Bool(false)),
542 ]
543 .into_iter()
544 .collect();
545 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
546 assert!(errs.is_empty());
547 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Deny);
548 assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
549 }
550
551 #[test]
552 fn wildcard_with_version_is_rejected() {
553 let map: BTreeMap<String, AllowBuildRaw> =
554 [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
555 .into_iter()
556 .collect();
557 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
558 assert_eq!(errs.len(), 1);
559 assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
560 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
563 }
564
565 #[test]
566 fn wildcards_flow_through_flat_lists_too() {
567 let only_built = vec!["@types/*".to_string()];
568 let never_built = vec!["*-internal".to_string()];
569 let (p, errs) =
570 BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
571 assert!(errs.is_empty());
572 assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
573 assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
574 assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
575 }
576
577 #[test]
578 fn matches_wildcard_handles_all_positions() {
579 assert!(matches_wildcard("@babel/core", "@babel/*"));
580 assert!(matches_wildcard("@babel/", "@babel/*"));
581 assert!(!matches_wildcard("@babe/core", "@babel/*"));
582
583 assert!(matches_wildcard("css-loader", "*-loader"));
584 assert!(matches_wildcard("-loader", "*-loader"));
585 assert!(!matches_wildcard("loader-x", "*-loader"));
586
587 assert!(matches_wildcard("foobar", "foo*bar"));
588 assert!(matches_wildcard("foo-x-bar", "foo*bar"));
589 assert!(!matches_wildcard("foobaz", "foo*bar"));
590
591 assert!(matches_wildcard("@x/anything", "*"));
592 assert!(matches_wildcard("", "*"));
593
594 assert!(matches_wildcard("anything", "**"));
596 }
597
598 #[test]
599 fn matches_wildcard_multi_segment_greedy_is_correct() {
600 assert!(matches_wildcard("abca", "*a*bc*a"));
607 assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
608 assert!(matches_wildcard("abcaXa", "*a*bc*a"));
609 assert!(matches_wildcard("ababab", "*ab*ab*"));
610 assert!(matches_wildcard("abcd", "a*b*c*d"));
611 assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
612
613 assert!(!matches_wildcard("aab", "*ab*ab"));
617 assert!(!matches_wildcard("abab", "*abc*abc"));
618
619 assert!(matches_wildcard(
621 "@acme/core-loader-plugin",
622 "@acme/*-*-plugin"
623 ));
624 assert!(!matches_wildcard(
625 "@acme/core-plugin-extra",
626 "@acme/*-*-plugin"
627 ));
628 }
629
630 #[test]
631 fn semver_shape() {
632 assert!(is_exact_semver("1.2.3"));
633 assert!(is_exact_semver("0.19.0"));
634 assert!(is_exact_semver("1.0.0-alpha"));
635 assert!(is_exact_semver("1.0.0+build.42"));
636 assert!(!is_exact_semver("1.2"));
637 assert!(!is_exact_semver("^1.2.3"));
638 assert!(!is_exact_semver("1.x.0"));
639 }
640}