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 denylist(denied_patterns: &[String]) -> (Self, Vec<BuildPolicyError>) {
157 let mut denied = HashSet::new();
158 let mut denied_wildcards = Vec::new();
159 let mut warnings = Vec::new();
160 for pattern in denied_patterns {
161 match expand_spec(pattern) {
162 Ok(expanded) => sort_entries(expanded, &mut denied, &mut denied_wildcards),
163 Err(e) => warnings.push(e),
164 }
165 }
166 (
167 Self {
168 allow_all: true,
169 allowed: HashSet::new(),
170 denied,
171 allowed_wildcards: Vec::new(),
172 denied_wildcards,
173 },
174 warnings,
175 )
176 }
177
178 pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
181 let with_version = format!("{name}@{version}");
182 if self.denied.contains(name) || self.denied.contains(&with_version) {
183 return AllowDecision::Deny;
184 }
185 if matches_any_wildcard(name, &self.denied_wildcards) {
186 return AllowDecision::Deny;
187 }
188 if self.allow_all {
189 return AllowDecision::Allow;
190 }
191 if self.allowed.contains(name) || self.allowed.contains(&with_version) {
192 return AllowDecision::Allow;
193 }
194 if matches_any_wildcard(name, &self.allowed_wildcards) {
195 return AllowDecision::Allow;
196 }
197 AllowDecision::Unspecified
198 }
199
200 pub fn has_any_allow_rule(&self) -> bool {
204 self.allow_all || !self.allowed.is_empty() || !self.allowed_wildcards.is_empty()
205 }
206}
207
208pub fn pattern_matches(pattern: &str, name: &str, version: &str) -> Result<bool, BuildPolicyError> {
210 let with_version = format!("{name}@{version}");
211 for expanded in expand_spec(pattern)? {
212 if expanded.contains('*') {
213 if matches_wildcard(name, &expanded) {
214 return Ok(true);
215 }
216 } else if expanded == name || expanded == with_version {
217 return Ok(true);
218 }
219 }
220 Ok(false)
221}
222
223fn sort_entries(entries: Vec<String>, exact: &mut HashSet<String>, wildcards: &mut Vec<String>) {
228 for entry in entries {
229 if entry.contains('*') {
230 if !wildcards.iter().any(|p| p == &entry) {
231 wildcards.push(entry);
232 }
233 } else {
234 exact.insert(entry);
235 }
236 }
237}
238
239fn matches_any_wildcard(name: &str, patterns: &[String]) -> bool {
254 patterns.iter().any(|p| matches_wildcard(name, p))
255}
256
257fn matches_wildcard(name: &str, pattern: &str) -> bool {
258 let parts: Vec<&str> = pattern.split('*').collect();
259 let (first, rest) = match parts.split_first() {
262 Some(pair) => pair,
263 None => return false,
264 };
265 let Some(after_prefix) = name.strip_prefix(first) else {
266 return false;
267 };
268 let (last, middle) = match rest.split_last() {
269 Some(pair) => pair,
270 None => {
275 debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
276 return false;
277 }
278 };
279
280 let mut remaining = after_prefix;
281 for mid in middle {
282 match remaining.find(mid) {
283 Some(idx) => remaining = &remaining[idx + mid.len()..],
284 None => return false,
285 }
286 }
287 remaining.len() >= last.len() && remaining.ends_with(last)
288}
289
290#[derive(Debug, Clone, thiserror::Error)]
291pub enum BuildPolicyError {
292 #[error("build policy entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
293 UnsupportedValue { pattern: String, raw: String },
294 #[error("build policy pattern {0:?} contains an invalid version union")]
295 InvalidVersionUnion(String),
296 #[error("build policy pattern {0:?} mixes a wildcard name with a version union")]
297 WildcardWithVersion(String),
298}
299
300fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
304 let (name, versions_part) = split_name_and_versions(pattern);
305
306 if versions_part.is_empty() {
307 return Ok(vec![name.to_string()]);
308 }
309 if name.contains('*') {
310 return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
311 }
312
313 let mut out = Vec::new();
314 for raw in versions_part.split("||") {
315 let trimmed = raw.trim();
316 if trimmed.is_empty() || !is_exact_semver(trimmed) {
317 return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
318 }
319 out.push(format!("{name}@{trimmed}"));
320 }
321 Ok(out)
322}
323
324fn split_name_and_versions(pattern: &str) -> (&str, &str) {
327 let scoped = pattern.starts_with('@');
328 let search_from = if scoped { 1 } else { 0 };
329 match pattern[search_from..].find('@') {
330 Some(rel) => {
331 let at = search_from + rel;
332 (&pattern[..at], &pattern[at + 1..])
333 }
334 None => (pattern, ""),
335 }
336}
337
338fn is_exact_semver(s: &str) -> bool {
343 let core = s.split('+').next().unwrap_or(s);
345 let main = core.split('-').next().unwrap_or(core);
347 let parts: Vec<&str> = main.split('.').collect();
348 if parts.len() != 3 {
349 return false;
350 }
351 parts
352 .iter()
353 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
361 let map: BTreeMap<String, AllowBuildRaw> = pairs
362 .iter()
363 .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
364 .collect();
365 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
366 assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
367 p
368 }
369
370 #[test]
371 fn bare_name_allows_any_version() {
372 let p = policy(&[("esbuild", true)]);
373 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
374 assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
375 assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
376 }
377
378 #[test]
379 fn exact_version_is_strict() {
380 let p = policy(&[("esbuild@0.19.0", true)]);
381 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
382 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
383 }
384
385 #[test]
386 fn version_union_splits() {
387 let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
388 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
389 assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
390 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
391 }
392
393 #[test]
394 fn scoped_package_parses() {
395 let p = policy(&[("@swc/core@1.3.0", true)]);
396 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
397 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
398 }
399
400 #[test]
401 fn scoped_bare_name() {
402 let p = policy(&[("@swc/core", true)]);
403 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
404 }
405
406 #[test]
407 fn pattern_matches_scoped_names_and_versions() {
408 assert!(pattern_matches("@swc/core", "@swc/core", "1.3.0").unwrap());
409 assert!(pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.0").unwrap());
410 assert!(!pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.1").unwrap());
411 assert!(pattern_matches("@swc/*", "@swc/core", "1.3.0").unwrap());
412 assert!(pattern_matches("aube-test-*", "aube-test-native", "1.0.0").unwrap());
413 }
414
415 #[test]
416 fn dangerously_allow_all_bypasses_deny_list() {
417 let mut map = BTreeMap::new();
423 map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
424 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
425 assert!(errs.is_empty());
426 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
427 }
428
429 #[test]
430 fn deny_wins_over_allow_when_both_listed() {
431 let map: BTreeMap<String, AllowBuildRaw> = [
432 ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
433 ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
434 ]
435 .into_iter()
436 .collect();
437 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
438 assert!(errs.is_empty());
439 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
440 assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
441 }
442
443 #[test]
444 fn deny_all_is_default() {
445 let p = BuildPolicy::deny_all();
446 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
447 assert!(!p.has_any_allow_rule());
448 }
449
450 #[test]
451 fn allow_all_flag() {
452 let p = BuildPolicy::allow_all();
453 assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
454 assert!(p.has_any_allow_rule());
455 }
456
457 #[test]
458 fn invalid_version_union_reports_warning() {
459 let map: BTreeMap<String, AllowBuildRaw> = [(
460 "esbuild@not-a-version".to_string(),
461 AllowBuildRaw::Bool(true),
462 )]
463 .into_iter()
464 .collect();
465 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
466 assert_eq!(errs.len(), 1);
467 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
469 }
470
471 #[test]
472 fn non_bool_value_reports_warning() {
473 let map: BTreeMap<String, AllowBuildRaw> =
474 [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
475 .into_iter()
476 .collect();
477 let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
478 assert_eq!(errs.len(), 1);
479 }
480
481 #[test]
482 fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
483 let map = BTreeMap::new();
487 let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
488 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
489 assert!(errs.is_empty());
490 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
491 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
492 assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
493 assert!(p.has_any_allow_rule());
494 }
495
496 #[test]
497 fn never_built_dependencies_denies() {
498 let map = BTreeMap::new();
499 let only_built = vec!["esbuild".to_string()];
500 let never_built = vec!["esbuild@0.19.0".to_string()];
501 let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
502 assert!(errs.is_empty());
503 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
504 assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
505 }
506
507 #[test]
508 fn never_built_beats_allow_builds_map() {
509 let map: BTreeMap<String, AllowBuildRaw> =
513 [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
514 .into_iter()
515 .collect();
516 let never_built = vec!["esbuild".to_string()];
517 let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
518 assert!(errs.is_empty());
519 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
520 }
521
522 #[test]
523 fn splits_scoped_correctly() {
524 assert_eq!(
525 split_name_and_versions("@swc/core@1.3.0"),
526 ("@swc/core", "1.3.0")
527 );
528 assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
529 assert_eq!(
530 split_name_and_versions("esbuild@0.19.0"),
531 ("esbuild", "0.19.0")
532 );
533 assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
534 }
535
536 #[test]
537 fn wildcard_scope_allows_every_scope_member() {
538 let p = policy(&[("@babel/*", true)]);
539 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
540 assert_eq!(
541 p.decide("@babel/preset-env", "7.22.0"),
542 AllowDecision::Allow
543 );
544 assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
545 assert_eq!(
546 p.decide("babel-loader", "9.0.0"),
547 AllowDecision::Unspecified
548 );
549 assert!(p.has_any_allow_rule());
550 }
551
552 #[test]
553 fn wildcard_suffix_matches_any_prefix() {
554 let p = policy(&[("*-loader", true)]);
555 assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
556 assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
557 assert_eq!(
558 p.decide("loader-utils", "3.0.0"),
559 AllowDecision::Unspecified
560 );
561 }
562
563 #[test]
564 fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
565 let map: BTreeMap<String, AllowBuildRaw> = [
569 ("*".to_string(), AllowBuildRaw::Bool(true)),
570 ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
571 ]
572 .into_iter()
573 .collect();
574 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
575 assert!(errs.is_empty());
576 assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
577 assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
578 }
579
580 #[test]
581 fn denied_wildcard_blocks_allowed_exact() {
582 let map: BTreeMap<String, AllowBuildRaw> = [
583 ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
584 ("@babel/*".to_string(), AllowBuildRaw::Bool(false)),
585 ]
586 .into_iter()
587 .collect();
588 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
589 assert!(errs.is_empty());
590 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Deny);
591 assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
592 }
593
594 #[test]
595 fn wildcard_with_version_is_rejected() {
596 let map: BTreeMap<String, AllowBuildRaw> =
597 [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
598 .into_iter()
599 .collect();
600 let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
601 assert_eq!(errs.len(), 1);
602 assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
603 assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
606 }
607
608 #[test]
609 fn wildcards_flow_through_flat_lists_too() {
610 let only_built = vec!["@types/*".to_string()];
611 let never_built = vec!["*-internal".to_string()];
612 let (p, errs) =
613 BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
614 assert!(errs.is_empty());
615 assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
616 assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
617 assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
618 }
619
620 #[test]
621 fn matches_wildcard_handles_all_positions() {
622 assert!(matches_wildcard("@babel/core", "@babel/*"));
623 assert!(matches_wildcard("@babel/", "@babel/*"));
624 assert!(!matches_wildcard("@babe/core", "@babel/*"));
625
626 assert!(matches_wildcard("css-loader", "*-loader"));
627 assert!(matches_wildcard("-loader", "*-loader"));
628 assert!(!matches_wildcard("loader-x", "*-loader"));
629
630 assert!(matches_wildcard("foobar", "foo*bar"));
631 assert!(matches_wildcard("foo-x-bar", "foo*bar"));
632 assert!(!matches_wildcard("foobaz", "foo*bar"));
633
634 assert!(matches_wildcard("@x/anything", "*"));
635 assert!(matches_wildcard("", "*"));
636
637 assert!(matches_wildcard("anything", "**"));
639 }
640
641 #[test]
642 fn matches_wildcard_multi_segment_greedy_is_correct() {
643 assert!(matches_wildcard("abca", "*a*bc*a"));
650 assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
651 assert!(matches_wildcard("abcaXa", "*a*bc*a"));
652 assert!(matches_wildcard("ababab", "*ab*ab*"));
653 assert!(matches_wildcard("abcd", "a*b*c*d"));
654 assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
655
656 assert!(!matches_wildcard("aab", "*ab*ab"));
660 assert!(!matches_wildcard("abab", "*abc*abc"));
661
662 assert!(matches_wildcard(
664 "@acme/core-loader-plugin",
665 "@acme/*-*-plugin"
666 ));
667 assert!(!matches_wildcard(
668 "@acme/core-plugin-extra",
669 "@acme/*-*-plugin"
670 ));
671 }
672
673 #[test]
674 fn semver_shape() {
675 assert!(is_exact_semver("1.2.3"));
676 assert!(is_exact_semver("0.19.0"));
677 assert!(is_exact_semver("1.0.0-alpha"));
678 assert!(is_exact_semver("1.0.0+build.42"));
679 assert!(!is_exact_semver("1.2"));
680 assert!(!is_exact_semver("^1.2.3"));
681 assert!(!is_exact_semver("1.x.0"));
682 }
683}