Skip to main content

aube_scripts/
policy.rs

1//! Allowlist/denylist policy for running dependency lifecycle scripts.
2//!
3//! Mirrors pnpm's `createAllowBuildFunction` — given an `allowBuilds`
4//! map (`Record<string, boolean>`) and a `dangerouslyAllowAllBuilds`
5//! flag, produce a function from `(pkgName, version)` to an allow /
6//! deny / unspecified decision. Unspecified means "fall through to the
7//! caller's default," which for aube is always "deny."
8//!
9//! ## Entry shapes
10//!
11//! Keys in the `allowBuilds` map support three forms:
12//!
13//! - `"esbuild"` — bare name, matches every version of the package
14//! - `"esbuild@0.19.0"` — exact version match
15//! - `"esbuild@0.19.0 || 0.20.0"` — exact version union
16//!
17//! Semver ranges are intentionally *not* supported, matching pnpm's
18//! `expandPackageVersionSpecs` behavior: if you pin a version in the
19//! allowlist you're asserting a specific build has been audited, so
20//! range matching would defeat the point.
21//!
22//! Name patterns may also contain `*` wildcards, mirroring pnpm's
23//! `@pnpm/config.matcher`. `@babel/*` matches every package under the
24//! `@babel` scope, `*-loader` matches any name ending in `-loader`,
25//! and a bare `*` matches every package. `*` is the only supported
26//! metacharacter and always matches a possibly-empty run of any
27//! characters. Wildcards must stand alone — combining them with a
28//! version spec (`@babel/*@1.0.0`) is rejected, since a wildcard
29//! name can't be used to assert "this exact build was audited."
30
31use aube_manifest::AllowBuildRaw;
32use std::collections::{BTreeMap, HashSet};
33
34/// The decision for a single `(name, version)` lookup.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AllowDecision {
37    /// Package is explicitly allowed — run its lifecycle scripts.
38    Allow,
39    /// Package is explicitly denied — skip even if a broader rule would allow.
40    Deny,
41    /// No rule matched; caller applies its default (aube denies).
42    Unspecified,
43}
44
45/// Resolved policy for deciding whether a package may run its
46/// lifecycle scripts.
47#[derive(Debug, Clone, Default)]
48pub struct BuildPolicy {
49    allow_all: bool,
50    /// Expanded allow-keys: bare names (match any version) and
51    /// `name@version` strings (match that specific version).
52    allowed: HashSet<String>,
53    denied: HashSet<String>,
54    /// Bare-name patterns containing `*` wildcards. Checked with a
55    /// linear scan after the exact-match sets; wildcard rules are rare
56    /// enough that the linear pass is cheaper than building an
57    /// automaton.
58    allowed_wildcards: Vec<String>,
59    denied_wildcards: Vec<String>,
60}
61
62impl BuildPolicy {
63    /// A policy that denies every package (the aube default).
64    pub fn deny_all() -> Self {
65        Self::default()
66    }
67
68    /// A policy that allows every package, regardless of the map.
69    /// Corresponds to `--dangerously-allow-all-builds`.
70    pub fn allow_all() -> Self {
71        Self {
72            allow_all: true,
73            ..Self::default()
74        }
75    }
76
77    /// Build from a raw `allowBuilds` map plus pnpm's canonical
78    /// `onlyBuiltDependencies` / `neverBuiltDependencies` flat lists,
79    /// plus the `dangerouslyAllowAllBuilds` flag.
80    ///
81    /// All three sources merge into one allow/deny set — pnpm uses the
82    /// flat lists in most real-world projects, and aube's `allowBuilds`
83    /// map is the superset format. Unrecognized `allowBuilds` value
84    /// shapes are collected in the returned `warnings` vec so the
85    /// caller can surface them through the progress UI.
86    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                    // The canonical "set this to true or false" placeholder
106                    // is what aube itself writes when it auto-seeds an
107                    // unreviewed build into the user's allowBuilds. Treat
108                    // it as Unspecified (skip silently) — strict-dep-builds
109                    // will surface it via `unreviewed_dep_builds` instead.
110                    // Any other string is a user-authored value we don't
111                    // understand; warn so it isn't silently misread.
112                    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        // `onlyBuiltDependencies` / `neverBuiltDependencies` support the
136        // same pattern forms as `allowBuilds` map keys (bare name, exact
137        // version, exact version union), so route them through the same
138        // `expand_spec` — a single `esbuild@0.20.0` pin works in either
139        // format.
140        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    /// Build an allow-all policy with explicit package-pattern denies.
166    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    /// Decide whether `(name, version)` may run lifecycle scripts.
189    /// Explicit denies always win over allows (mirrors pnpm).
190    pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
191        // Reusable thread-local buffer for the `name@version` probe key.
192        // Avoids a `format!` allocation on every call — ~2k throwaway
193        // Strings on a typical install otherwise.
194        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        // Build the `name@version` probe key once and answer both the
204        // deny and the allow lookups from a single buffer borrow.
205        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    /// True when the policy would allow something — any rule at all, or
229    /// allow-all mode. Lets callers cheaply skip the whole dep-script
230    /// phase when nothing could possibly run.
231    pub fn has_any_allow_rule(&self) -> bool {
232        self.allow_all || !self.allowed.is_empty() || !self.allowed_wildcards.is_empty()
233    }
234}
235
236/// True when a package-pattern entry matches `(name, version)`.
237pub fn pattern_matches(pattern: &str, name: &str, version: &str) -> Result<bool, BuildPolicyError> {
238    let with_version = format!("{name}@{version}");
239    for expanded in expand_spec(pattern)? {
240        if expanded.contains('*') {
241            if matches_wildcard(name, &expanded) {
242                return Ok(true);
243            }
244        } else if expanded == name || expanded == with_version {
245            return Ok(true);
246        }
247    }
248    Ok(false)
249}
250
251/// Split one entry list from `expand_spec` across the exact-match set
252/// and the wildcard list. Wildcards are identified by a literal `*` in
253/// the string; since `expand_spec` rejects `wildcard@version`, a `*`
254/// can only appear in a bare name.
255fn sort_entries(entries: Vec<String>, exact: &mut HashSet<String>, wildcards: &mut Vec<String>) {
256    for entry in entries {
257        if entry.contains('*') {
258            if !wildcards.iter().any(|p| p == &entry) {
259                wildcards.push(entry);
260            }
261        } else {
262            exact.insert(entry);
263        }
264    }
265}
266
267/// Match `name` against a `*`-wildcard pattern. `*` matches any
268/// (possibly-empty) run of characters — including `/`, so `@babel/*`
269/// matches every package in the scope. Called only for patterns known
270/// to contain at least one `*`; a pattern with no `*` is routed to the
271/// exact-match set instead.
272///
273/// The algorithm is greedy-leftmost for the middle segments with the
274/// prefix anchored on the left and the suffix anchored on the right.
275/// That works for plain `*` globs (no `?`, no character classes): if
276/// any valid assignment of middle positions exists, the leftmost
277/// valid assignment is one of them, and greedy finds it. A fixed
278/// right anchor is what makes this safe — `ends_with(last)` is
279/// independent of greedy choices, and everything between the last
280/// greedy hit and the suffix anchor is a free `*`.
281fn matches_any_wildcard(name: &str, patterns: &[String]) -> bool {
282    patterns.iter().any(|p| matches_wildcard(name, p))
283}
284
285fn matches_wildcard(name: &str, pattern: &str) -> bool {
286    let parts: Vec<&str> = pattern.split('*').collect();
287    // `split` on a pattern with N wildcards yields N+1 parts, so the
288    // two-element case is the minimum we see here.
289    let (first, rest) = match parts.split_first() {
290        Some(pair) => pair,
291        None => return false,
292    };
293    let Some(after_prefix) = name.strip_prefix(first) else {
294        return false;
295    };
296    let (last, middle) = match rest.split_last() {
297        Some(pair) => pair,
298        // `rest` is never empty here — the caller guarantees the
299        // pattern contains at least one `*`, so `parts.len() >= 2`.
300        // Fail closed rather than silently allow if that invariant
301        // ever drifts: a default-allow here would be a security bypass.
302        None => {
303            debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
304            return false;
305        }
306    };
307
308    let mut remaining = after_prefix;
309    for mid in middle {
310        match remaining.find(mid) {
311            Some(idx) => remaining = &remaining[idx + mid.len()..],
312            None => return false,
313        }
314    }
315    remaining.len() >= last.len() && remaining.ends_with(last)
316}
317
318#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
319pub enum BuildPolicyError {
320    #[error("build policy entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
321    #[diagnostic(code(ERR_AUBE_BUILD_POLICY_UNSUPPORTED_VALUE))]
322    UnsupportedValue { pattern: String, raw: String },
323    #[error("build policy pattern {0:?} contains an invalid version union")]
324    #[diagnostic(code(ERR_AUBE_BUILD_POLICY_INVALID_VERSION_UNION))]
325    InvalidVersionUnion(String),
326    #[error("build policy pattern {0:?} mixes a wildcard name with a version union")]
327    #[diagnostic(code(ERR_AUBE_BUILD_POLICY_WILDCARD_WITH_VERSION))]
328    WildcardWithVersion(String),
329}
330
331/// Parse one entry from the allowBuilds map into the set of strings
332/// that will be matched at decide-time. Mirrors pnpm's
333/// `expandPackageVersionSpecs`.
334fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
335    let (name, versions_part) = split_name_and_versions(pattern);
336
337    if versions_part.is_empty() {
338        return Ok(vec![name.to_string()]);
339    }
340    if name.contains('*') {
341        return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
342    }
343
344    let mut out = Vec::new();
345    for raw in versions_part.split("||") {
346        let trimmed = raw.trim();
347        if trimmed.is_empty() || !is_exact_semver(trimmed) {
348            return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
349        }
350        out.push(format!("{name}@{trimmed}"));
351    }
352    Ok(out)
353}
354
355/// Split `pattern` into `(name, version_spec)`, respecting a leading
356/// `@` for scoped packages so `@scope/foo@1.0.0` parses correctly.
357fn split_name_and_versions(pattern: &str) -> (&str, &str) {
358    let scoped = pattern.starts_with('@');
359    let search_from = if scoped { 1 } else { 0 };
360    match pattern[search_from..].find('@') {
361        Some(rel) => {
362            let at = search_from + rel;
363            (&pattern[..at], &pattern[at + 1..])
364        }
365        None => (pattern, ""),
366    }
367}
368
369/// Minimal exact-semver validator — accepts `MAJOR.MINOR.PATCH` plus an
370/// optional `-prerelease` / `+build` tail. We intentionally don't pull
371/// in the `semver` crate here because the file is tiny and this is the
372/// only place in aube-scripts that cares about semver shape.
373fn is_exact_semver(s: &str) -> bool {
374    // Strip build metadata; it doesn't affect equality for our purposes.
375    let core = s.split('+').next().unwrap_or(s);
376    // Strip pre-release; the shape just needs to parse as numeric triple.
377    let main = core.split('-').next().unwrap_or(core);
378    let parts: Vec<&str> = main.split('.').collect();
379    if parts.len() != 3 {
380        return false;
381    }
382    parts
383        .iter()
384        .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
392        let map: BTreeMap<String, AllowBuildRaw> = pairs
393            .iter()
394            .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
395            .collect();
396        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
397        assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
398        p
399    }
400
401    #[test]
402    fn bare_name_allows_any_version() {
403        let p = policy(&[("esbuild", true)]);
404        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
405        assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
406        assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
407    }
408
409    #[test]
410    fn exact_version_is_strict() {
411        let p = policy(&[("esbuild@0.19.0", true)]);
412        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
413        assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
414    }
415
416    #[test]
417    fn version_union_splits() {
418        let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
419        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
420        assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
421        assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
422    }
423
424    #[test]
425    fn scoped_package_parses() {
426        let p = policy(&[("@swc/core@1.3.0", true)]);
427        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
428        assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
429    }
430
431    #[test]
432    fn scoped_bare_name() {
433        let p = policy(&[("@swc/core", true)]);
434        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
435    }
436
437    #[test]
438    fn pattern_matches_scoped_names_and_versions() {
439        assert!(pattern_matches("@swc/core", "@swc/core", "1.3.0").unwrap());
440        assert!(pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.0").unwrap());
441        assert!(!pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.1").unwrap());
442        assert!(pattern_matches("@swc/*", "@swc/core", "1.3.0").unwrap());
443        assert!(pattern_matches("aube-test-*", "aube-test-native", "1.0.0").unwrap());
444    }
445
446    #[test]
447    fn dangerously_allow_all_bypasses_deny_list() {
448        // pnpm's `createAllowBuildFunction` short-circuits to `() => true`
449        // when `dangerouslyAllowAllBuilds` is set, dropping the entire
450        // allowBuilds map — including any `false` entries. Pin that
451        // behavior so a future refactor doesn't accidentally start
452        // honoring deny rules under allow-all.
453        let mut map = BTreeMap::new();
454        map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
455        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
456        assert!(errs.is_empty());
457        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
458    }
459
460    #[test]
461    fn deny_wins_over_allow_when_both_listed() {
462        let map: BTreeMap<String, AllowBuildRaw> = [
463            ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
464            ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
465        ]
466        .into_iter()
467        .collect();
468        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
469        assert!(errs.is_empty());
470        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
471        assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
472    }
473
474    #[test]
475    fn deny_all_is_default() {
476        let p = BuildPolicy::deny_all();
477        assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
478        assert!(!p.has_any_allow_rule());
479    }
480
481    #[test]
482    fn allow_all_flag() {
483        let p = BuildPolicy::allow_all();
484        assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
485        assert!(p.has_any_allow_rule());
486    }
487
488    #[test]
489    fn invalid_version_union_reports_warning() {
490        let map: BTreeMap<String, AllowBuildRaw> = [(
491            "esbuild@not-a-version".to_string(),
492            AllowBuildRaw::Bool(true),
493        )]
494        .into_iter()
495        .collect();
496        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
497        assert_eq!(errs.len(), 1);
498        // The broken entry should not leak into the allowed set.
499        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
500    }
501
502    #[test]
503    fn non_bool_value_reports_warning() {
504        let map: BTreeMap<String, AllowBuildRaw> =
505            [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
506                .into_iter()
507                .collect();
508        let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
509        assert_eq!(errs.len(), 1);
510    }
511
512    #[test]
513    fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
514        // pnpm's canonical `onlyBuiltDependencies` flat list is additive
515        // with `allowBuilds`, so both sources populate the same allowed
516        // set. Same pattern vocabulary — bare name or exact version.
517        let map = BTreeMap::new();
518        let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
519        let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
520        assert!(errs.is_empty());
521        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
522        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
523        assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
524        assert!(p.has_any_allow_rule());
525    }
526
527    #[test]
528    fn never_built_dependencies_denies() {
529        let map = BTreeMap::new();
530        let only_built = vec!["esbuild".to_string()];
531        let never_built = vec!["esbuild@0.19.0".to_string()];
532        let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
533        assert!(errs.is_empty());
534        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
535        assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
536    }
537
538    #[test]
539    fn never_built_beats_allow_builds_map() {
540        // Cross-source precedence: a bare-name deny in
541        // `neverBuiltDependencies` overrides a bare-name allow in the
542        // `allowBuilds` map. Mirrors the in-map deny-wins test above.
543        let map: BTreeMap<String, AllowBuildRaw> =
544            [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
545                .into_iter()
546                .collect();
547        let never_built = vec!["esbuild".to_string()];
548        let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
549        assert!(errs.is_empty());
550        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
551    }
552
553    #[test]
554    fn splits_scoped_correctly() {
555        assert_eq!(
556            split_name_and_versions("@swc/core@1.3.0"),
557            ("@swc/core", "1.3.0")
558        );
559        assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
560        assert_eq!(
561            split_name_and_versions("esbuild@0.19.0"),
562            ("esbuild", "0.19.0")
563        );
564        assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
565    }
566
567    #[test]
568    fn wildcard_scope_allows_every_scope_member() {
569        let p = policy(&[("@babel/*", true)]);
570        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
571        assert_eq!(
572            p.decide("@babel/preset-env", "7.22.0"),
573            AllowDecision::Allow
574        );
575        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
576        assert_eq!(
577            p.decide("babel-loader", "9.0.0"),
578            AllowDecision::Unspecified
579        );
580        assert!(p.has_any_allow_rule());
581    }
582
583    #[test]
584    fn wildcard_suffix_matches_any_prefix() {
585        let p = policy(&[("*-loader", true)]);
586        assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
587        assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
588        assert_eq!(
589            p.decide("loader-utils", "3.0.0"),
590            AllowDecision::Unspecified
591        );
592    }
593
594    #[test]
595    fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
596        // `*` in the allowlist behaves like "allow every package" but
597        // is still a normal allow rule — deny entries still override
598        // it, unlike `dangerouslyAllowAllBuilds` which short-circuits.
599        let map: BTreeMap<String, AllowBuildRaw> = [
600            ("*".to_string(), AllowBuildRaw::Bool(true)),
601            ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
602        ]
603        .into_iter()
604        .collect();
605        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
606        assert!(errs.is_empty());
607        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
608        assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
609    }
610
611    #[test]
612    fn denied_wildcard_blocks_allowed_exact() {
613        let map: BTreeMap<String, AllowBuildRaw> = [
614            ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
615            ("@babel/*".to_string(), AllowBuildRaw::Bool(false)),
616        ]
617        .into_iter()
618        .collect();
619        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
620        assert!(errs.is_empty());
621        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Deny);
622        assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
623    }
624
625    #[test]
626    fn wildcard_with_version_is_rejected() {
627        let map: BTreeMap<String, AllowBuildRaw> =
628            [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
629                .into_iter()
630                .collect();
631        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
632        assert_eq!(errs.len(), 1);
633        assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
634        // The rejected entry should not leak through as either an
635        // exact or a wildcard allow.
636        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
637    }
638
639    #[test]
640    fn wildcards_flow_through_flat_lists_too() {
641        let only_built = vec!["@types/*".to_string()];
642        let never_built = vec!["*-internal".to_string()];
643        let (p, errs) =
644            BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
645        assert!(errs.is_empty());
646        assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
647        assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
648        assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
649    }
650
651    #[test]
652    fn matches_wildcard_handles_all_positions() {
653        assert!(matches_wildcard("@babel/core", "@babel/*"));
654        assert!(matches_wildcard("@babel/", "@babel/*"));
655        assert!(!matches_wildcard("@babe/core", "@babel/*"));
656
657        assert!(matches_wildcard("css-loader", "*-loader"));
658        assert!(matches_wildcard("-loader", "*-loader"));
659        assert!(!matches_wildcard("loader-x", "*-loader"));
660
661        assert!(matches_wildcard("foobar", "foo*bar"));
662        assert!(matches_wildcard("foo-x-bar", "foo*bar"));
663        assert!(!matches_wildcard("foobaz", "foo*bar"));
664
665        assert!(matches_wildcard("@x/anything", "*"));
666        assert!(matches_wildcard("", "*"));
667
668        // Adjacent wildcards collapse to a single match, same as glob.
669        assert!(matches_wildcard("anything", "**"));
670    }
671
672    #[test]
673    fn matches_wildcard_multi_segment_greedy_is_correct() {
674        // Three+ wildcards exercise the greedy-leftmost middle-segment
675        // scan with a fixed-right suffix anchor. Each case either has a
676        // valid assignment (should match) or none (should not), and
677        // greedy-leftmost finds it whenever one exists — the fixed
678        // right anchor prevents greedy from eating characters the
679        // suffix needs.
680        assert!(matches_wildcard("abca", "*a*bc*a"));
681        assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
682        assert!(matches_wildcard("abcaXa", "*a*bc*a"));
683        assert!(matches_wildcard("ababab", "*ab*ab*"));
684        assert!(matches_wildcard("abcd", "a*b*c*d"));
685        assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
686
687        // Needs two non-overlapping occurrences of the middle / last
688        // anchors but the input only provides enough characters for
689        // one, so no assignment exists.
690        assert!(!matches_wildcard("aab", "*ab*ab"));
691        assert!(!matches_wildcard("abab", "*abc*abc"));
692
693        // Four wildcards still obey the same rules.
694        assert!(matches_wildcard(
695            "@acme/core-loader-plugin",
696            "@acme/*-*-plugin"
697        ));
698        assert!(!matches_wildcard(
699            "@acme/core-plugin-extra",
700            "@acme/*-*-plugin"
701        ));
702    }
703
704    #[test]
705    fn semver_shape() {
706        assert!(is_exact_semver("1.2.3"));
707        assert!(is_exact_semver("0.19.0"));
708        assert!(is_exact_semver("1.0.0-alpha"));
709        assert!(is_exact_semver("1.0.0+build.42"));
710        assert!(!is_exact_semver("1.2"));
711        assert!(!is_exact_semver("^1.2.3"));
712        assert!(!is_exact_semver("1.x.0"));
713    }
714}