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    /// Exact source-backed lockfile IDs such as `pkg@file+abc123`.
55    /// These are intentionally separate from normal `name@version`
56    /// entries so a typo like `pkg@latest` still warns.
57    allowed_sources: HashSet<String>,
58    denied_sources: HashSet<String>,
59    /// Bare-name patterns containing `*` wildcards. Checked with a
60    /// linear scan after the exact-match sets; wildcard rules are rare
61    /// enough that the linear pass is cheaper than building an
62    /// automaton.
63    allowed_wildcards: Vec<String>,
64    denied_wildcards: Vec<String>,
65}
66
67impl BuildPolicy {
68    /// A policy that denies every package (the aube default).
69    pub fn deny_all() -> Self {
70        Self::default()
71    }
72
73    /// A policy that allows every package, regardless of the map.
74    /// Corresponds to `--dangerously-allow-all-builds`.
75    pub fn allow_all() -> Self {
76        Self {
77            allow_all: true,
78            ..Self::default()
79        }
80    }
81
82    /// Build from a raw `allowBuilds` map plus pnpm's canonical
83    /// `onlyBuiltDependencies` / `neverBuiltDependencies` flat lists,
84    /// plus the `dangerouslyAllowAllBuilds` flag.
85    ///
86    /// All three sources merge into one allow/deny set — pnpm uses the
87    /// flat lists in most real-world projects, and aube's `allowBuilds`
88    /// map is the superset format. Unrecognized `allowBuilds` value
89    /// shapes are collected in the returned `warnings` vec so the
90    /// caller can surface them through the progress UI.
91    pub fn from_config(
92        allow_builds: &BTreeMap<String, AllowBuildRaw>,
93        only_built: &[String],
94        never_built: &[String],
95        dangerously_allow_all: bool,
96    ) -> (Self, Vec<BuildPolicyError>) {
97        if dangerously_allow_all {
98            return (Self::allow_all(), Vec::new());
99        }
100        let mut allowed = HashSet::new();
101        let mut denied = HashSet::new();
102        let mut allowed_sources = HashSet::new();
103        let mut denied_sources = HashSet::new();
104        let mut allowed_wildcards = Vec::new();
105        let mut denied_wildcards = Vec::new();
106        let mut warnings = Vec::new();
107
108        for (pattern, value) in allow_builds {
109            let bool_value = match value {
110                AllowBuildRaw::Bool(b) => *b,
111                AllowBuildRaw::Other(raw) => {
112                    // The canonical "set this to true or false" placeholder
113                    // is what pnpm auto-seeds for unreviewed builds. Aube
114                    // doesn't write it (we leave the manifest alone), but
115                    // pnpm-managed projects can still carry these strings.
116                    // Treat as Unspecified (skip silently); strict-dep-builds
117                    // surfaces the dep via `unreviewed_dep_builds` instead.
118                    // Any other string is a user-authored value we don't
119                    // understand; warn so it isn't silently misread.
120                    if raw == aube_manifest::workspace::ALLOW_BUILDS_REVIEW_PLACEHOLDER {
121                        continue;
122                    }
123                    warnings.push(BuildPolicyError::UnsupportedValue {
124                        pattern: pattern.clone(),
125                        raw: raw.clone(),
126                    });
127                    continue;
128                }
129            };
130            match expand_spec(pattern) {
131                Ok(expanded) => {
132                    let (exact, wild) = if bool_value {
133                        (&mut allowed, &mut allowed_wildcards)
134                    } else {
135                        (&mut denied, &mut denied_wildcards)
136                    };
137                    let source = if bool_value {
138                        &mut allowed_sources
139                    } else {
140                        &mut denied_sources
141                    };
142                    sort_entries(expanded, exact, source, wild);
143                }
144                Err(e) => warnings.push(e),
145            }
146        }
147
148        // `onlyBuiltDependencies` / `neverBuiltDependencies` support the
149        // same pattern forms as `allowBuilds` map keys (bare name, exact
150        // version, exact version union), so route them through the same
151        // `expand_spec` — a single `esbuild@0.20.0` pin works in either
152        // format.
153        for pattern in only_built {
154            match expand_spec(pattern) {
155                Ok(expanded) => sort_entries(
156                    expanded,
157                    &mut allowed,
158                    &mut allowed_sources,
159                    &mut allowed_wildcards,
160                ),
161                Err(e) => warnings.push(e),
162            }
163        }
164        for pattern in never_built {
165            match expand_spec(pattern) {
166                Ok(expanded) => sort_entries(
167                    expanded,
168                    &mut denied,
169                    &mut denied_sources,
170                    &mut denied_wildcards,
171                ),
172                Err(e) => warnings.push(e),
173            }
174        }
175
176        (
177            Self {
178                allow_all: false,
179                allowed,
180                denied,
181                allowed_sources,
182                denied_sources,
183                allowed_wildcards,
184                denied_wildcards,
185            },
186            warnings,
187        )
188    }
189
190    /// Build an allow-all policy with explicit package-pattern denies.
191    pub fn denylist(denied_patterns: &[String]) -> (Self, Vec<BuildPolicyError>) {
192        let mut denied = HashSet::new();
193        let mut denied_sources = HashSet::new();
194        let mut denied_wildcards = Vec::new();
195        let mut warnings = Vec::new();
196        for pattern in denied_patterns {
197            match expand_spec(pattern) {
198                Ok(expanded) => sort_entries(
199                    expanded,
200                    &mut denied,
201                    &mut denied_sources,
202                    &mut denied_wildcards,
203                ),
204                Err(e) => warnings.push(e),
205            }
206        }
207        (
208            Self {
209                allow_all: true,
210                allowed: HashSet::new(),
211                denied,
212                allowed_sources: HashSet::new(),
213                denied_sources,
214                allowed_wildcards: Vec::new(),
215                denied_wildcards,
216            },
217            warnings,
218        )
219    }
220
221    /// Decide whether `(name, version)` may run lifecycle scripts.
222    /// Explicit denies always win over allows (mirrors pnpm).
223    pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
224        self.decide_package(name, version, None)
225    }
226
227    /// Decide whether a package may run lifecycle scripts, with an
228    /// optional source identity for non-registry packages.
229    ///
230    /// Bare package-name approvals only apply to registry packages.
231    /// Source-backed packages (`file:`, `git:`, direct tarball, etc.)
232    /// require an exact source key such as `pkg@file+abc123`; otherwise
233    /// a trusted package name could approve arbitrary untrusted bytes.
234    pub fn decide_package(
235        &self,
236        name: &str,
237        version: &str,
238        source_key: Option<&str>,
239    ) -> AllowDecision {
240        // Reusable thread-local buffer for the `name@version` probe key.
241        // Avoids a `format!` allocation on every call — ~2k throwaway
242        // Strings on a typical install otherwise.
243        thread_local! {
244            static KEY_BUF: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
245        }
246        if self.denied.contains(name) {
247            return AllowDecision::Deny;
248        }
249        if matches_any_wildcard(name, &self.denied_wildcards) {
250            return AllowDecision::Deny;
251        }
252        if let Some(source_key) = source_key
253            && self.denied_sources.contains(source_key)
254        {
255            return AllowDecision::Deny;
256        }
257        // Build the `name@version` probe key once and answer both the
258        // deny and the allow lookups from a single buffer borrow.
259        let (denied_versioned, allowed_versioned) = KEY_BUF.with(|buf| {
260            let mut b = buf.borrow_mut();
261            b.clear();
262            use std::fmt::Write as _;
263            let _ = write!(b, "{name}@{version}");
264            let key = b.as_str();
265            (self.denied.contains(key), self.allowed.contains(key))
266        });
267        if denied_versioned {
268            return AllowDecision::Deny;
269        }
270        if self.allow_all {
271            return AllowDecision::Allow;
272        }
273        if let Some(source_key) = source_key {
274            return if self.allowed_sources.contains(source_key) {
275                AllowDecision::Allow
276            } else {
277                AllowDecision::Unspecified
278            };
279        }
280        if self.allowed.contains(name) || allowed_versioned {
281            return AllowDecision::Allow;
282        }
283        if matches_any_wildcard(name, &self.allowed_wildcards) {
284            return AllowDecision::Allow;
285        }
286        AllowDecision::Unspecified
287    }
288
289    /// True when the policy would allow something — any rule at all, or
290    /// allow-all mode. Lets callers cheaply skip the whole dep-script
291    /// phase when nothing could possibly run.
292    pub fn has_any_allow_rule(&self) -> bool {
293        self.allow_all
294            || !self.allowed.is_empty()
295            || !self.allowed_sources.is_empty()
296            || !self.allowed_wildcards.is_empty()
297    }
298
299    /// Merge another resolved policy into this one. Denies from either
300    /// policy still win at decision time.
301    pub fn merge(&mut self, other: &Self) {
302        self.allow_all |= other.allow_all;
303        self.allowed.extend(other.allowed.iter().cloned());
304        self.denied.extend(other.denied.iter().cloned());
305        self.allowed_sources
306            .extend(other.allowed_sources.iter().cloned());
307        self.denied_sources
308            .extend(other.denied_sources.iter().cloned());
309        merge_unique(&mut self.allowed_wildcards, &other.allowed_wildcards);
310        merge_unique(&mut self.denied_wildcards, &other.denied_wildcards);
311    }
312}
313
314fn merge_unique(target: &mut Vec<String>, source: &[String]) {
315    for value in source {
316        if !target.iter().any(|existing| existing == value) {
317            target.push(value.clone());
318        }
319    }
320}
321
322/// True when a package-pattern entry matches `(name, version)`.
323pub fn pattern_matches(pattern: &str, name: &str, version: &str) -> Result<bool, BuildPolicyError> {
324    let with_version = format!("{name}@{version}");
325    for expanded in expand_spec(pattern)? {
326        if expanded.contains('*') {
327            if matches_wildcard(name, &expanded) {
328                return Ok(true);
329            }
330        } else if expanded == name || expanded == with_version {
331            return Ok(true);
332        }
333    }
334    Ok(false)
335}
336
337/// Split one entry list from `expand_spec` across the exact-match set
338/// and the wildcard list. Wildcards are identified by a literal `*` in
339/// the string; since `expand_spec` rejects `wildcard@version`, a `*`
340/// can only appear in a bare name.
341fn sort_entries(
342    entries: Vec<String>,
343    exact: &mut HashSet<String>,
344    sources: &mut HashSet<String>,
345    wildcards: &mut Vec<String>,
346) {
347    for entry in entries {
348        if entry.contains('*') {
349            if !wildcards.iter().any(|p| p == &entry) {
350                wildcards.push(entry);
351            }
352        } else if is_source_key(&entry) {
353            sources.insert(entry);
354        } else {
355            exact.insert(entry);
356        }
357    }
358}
359
360/// Match `name` against a `*`-wildcard pattern. `*` matches any
361/// (possibly-empty) run of characters — including `/`, so `@babel/*`
362/// matches every package in the scope. Called only for patterns known
363/// to contain at least one `*`; a pattern with no `*` is routed to the
364/// exact-match set instead.
365///
366/// The algorithm is greedy-leftmost for the middle segments with the
367/// prefix anchored on the left and the suffix anchored on the right.
368/// That works for plain `*` globs (no `?`, no character classes): if
369/// any valid assignment of middle positions exists, the leftmost
370/// valid assignment is one of them, and greedy finds it. A fixed
371/// right anchor is what makes this safe — `ends_with(last)` is
372/// independent of greedy choices, and everything between the last
373/// greedy hit and the suffix anchor is a free `*`.
374fn matches_any_wildcard(name: &str, patterns: &[String]) -> bool {
375    patterns.iter().any(|p| matches_wildcard(name, p))
376}
377
378fn matches_wildcard(name: &str, pattern: &str) -> bool {
379    let parts: Vec<&str> = pattern.split('*').collect();
380    // `split` on a pattern with N wildcards yields N+1 parts, so the
381    // two-element case is the minimum we see here.
382    let (first, rest) = match parts.split_first() {
383        Some(pair) => pair,
384        None => return false,
385    };
386    let Some(after_prefix) = name.strip_prefix(first) else {
387        return false;
388    };
389    let (last, middle) = match rest.split_last() {
390        Some(pair) => pair,
391        // `rest` is never empty here — the caller guarantees the
392        // pattern contains at least one `*`, so `parts.len() >= 2`.
393        // Fail closed rather than silently allow if that invariant
394        // ever drifts: a default-allow here would be a security bypass.
395        None => {
396            debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
397            return false;
398        }
399    };
400
401    let mut remaining = after_prefix;
402    for mid in middle {
403        match remaining.find(mid) {
404            Some(idx) => remaining = &remaining[idx + mid.len()..],
405            None => return false,
406        }
407    }
408    remaining.len() >= last.len() && remaining.ends_with(last)
409}
410
411#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)]
412pub enum BuildPolicyError {
413    #[error("build policy entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
414    #[diagnostic(code(ERR_AUBE_BUILD_POLICY_UNSUPPORTED_VALUE))]
415    UnsupportedValue { pattern: String, raw: String },
416    #[error("build policy pattern {0:?} contains an invalid version union")]
417    #[diagnostic(code(ERR_AUBE_BUILD_POLICY_INVALID_VERSION_UNION))]
418    InvalidVersionUnion(String),
419    #[error("build policy pattern {0:?} mixes a wildcard name with a version union")]
420    #[diagnostic(code(ERR_AUBE_BUILD_POLICY_WILDCARD_WITH_VERSION))]
421    WildcardWithVersion(String),
422}
423
424/// Parse one entry from the allowBuilds map into the set of strings
425/// that will be matched at decide-time. Mirrors pnpm's
426/// `expandPackageVersionSpecs`.
427fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
428    let (name, versions_part) = split_name_and_versions(pattern);
429
430    if versions_part.is_empty() {
431        return Ok(vec![name.to_string()]);
432    }
433    if name.contains('*') {
434        return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
435    }
436
437    let mut out = Vec::new();
438    for raw in versions_part.split("||") {
439        let trimmed = raw.trim();
440        if is_source_version(trimmed) && !versions_part.contains("||") {
441            out.push(format!("{name}@{trimmed}"));
442            return Ok(out);
443        }
444        if trimmed.is_empty() || !is_exact_semver(trimmed) {
445            return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
446        }
447        out.push(format!("{name}@{trimmed}"));
448    }
449    Ok(out)
450}
451
452fn is_source_key(key: &str) -> bool {
453    let (_, version) = split_name_and_versions(key);
454    is_source_version(version)
455}
456
457fn is_source_version(version: &str) -> bool {
458    [
459        "file+",
460        "link+",
461        "portal+",
462        "exec+",
463        "git+",
464        "url+",
465        "file:",
466        "link:",
467        "portal:",
468        "exec:",
469        "git:",
470        "http://",
471        "https://",
472        "github:",
473        "workspace:",
474    ]
475    .iter()
476    .any(|prefix| version.starts_with(prefix))
477}
478
479/// Split `pattern` into `(name, version_spec)`, respecting a leading
480/// `@` for scoped packages so `@scope/foo@1.0.0` parses correctly.
481fn split_name_and_versions(pattern: &str) -> (&str, &str) {
482    let scoped = pattern.starts_with('@');
483    let search_from = if scoped { 1 } else { 0 };
484    match pattern[search_from..].find('@') {
485        Some(rel) => {
486            let at = search_from + rel;
487            (&pattern[..at], &pattern[at + 1..])
488        }
489        None => (pattern, ""),
490    }
491}
492
493/// Minimal exact-semver validator — accepts `MAJOR.MINOR.PATCH` plus an
494/// optional `-prerelease` / `+build` tail. We intentionally don't pull
495/// in the `semver` crate here because the file is tiny and this is the
496/// only place in aube-scripts that cares about semver shape.
497fn is_exact_semver(s: &str) -> bool {
498    // Strip build metadata; it doesn't affect equality for our purposes.
499    let core = s.split('+').next().unwrap_or(s);
500    // Strip pre-release; the shape just needs to parse as numeric triple.
501    let main = core.split('-').next().unwrap_or(core);
502    let parts: Vec<&str> = main.split('.').collect();
503    if parts.len() != 3 {
504        return false;
505    }
506    parts
507        .iter()
508        .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
516        let map: BTreeMap<String, AllowBuildRaw> = pairs
517            .iter()
518            .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
519            .collect();
520        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
521        assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
522        p
523    }
524
525    #[test]
526    fn bare_name_allows_any_version() {
527        let p = policy(&[("esbuild", true)]);
528        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
529        assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
530        assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
531    }
532
533    #[test]
534    fn bare_name_does_not_allow_source_backed_package() {
535        let p = policy(&[("esbuild", true)]);
536        assert_eq!(
537            p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
538            AllowDecision::Unspecified
539        );
540    }
541
542    #[test]
543    fn exact_source_key_allows_source_backed_package() {
544        let p = policy(&[("esbuild@file+abc123", true)]);
545        assert_eq!(
546            p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
547            AllowDecision::Allow
548        );
549        assert_eq!(
550            p.decide_package("esbuild", "0.25.0", Some("esbuild@file+def456")),
551            AllowDecision::Unspecified
552        );
553        assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Unspecified);
554    }
555
556    #[test]
557    fn source_keys_accept_url_and_git_tails() {
558        let p = policy(&[
559            ("native@url+abc123", true),
560            ("gitdep@git+def456", true),
561            ("raw-url@https://example.com/pkg.tgz", true),
562            ("raw-git@github:owner/repo", true),
563        ]);
564        assert_eq!(
565            p.decide_package("native", "1.0.0", Some("native@url+abc123")),
566            AllowDecision::Allow
567        );
568        assert_eq!(
569            p.decide_package("gitdep", "1.0.0", Some("gitdep@git+def456")),
570            AllowDecision::Allow
571        );
572        assert_eq!(
573            p.decide_package(
574                "raw-url",
575                "1.0.0",
576                Some("raw-url@https://example.com/pkg.tgz")
577            ),
578            AllowDecision::Allow
579        );
580        assert_eq!(
581            p.decide_package("raw-git", "1.0.0", Some("raw-git@github:owner/repo")),
582            AllowDecision::Allow
583        );
584    }
585
586    #[test]
587    fn source_backed_package_name_deny_still_wins() {
588        let p = policy(&[("esbuild", false), ("esbuild@file+abc123", true)]);
589        assert_eq!(
590            p.decide_package("esbuild", "0.25.0", Some("esbuild@file+abc123")),
591            AllowDecision::Deny
592        );
593    }
594
595    #[test]
596    fn exact_version_is_strict() {
597        let p = policy(&[("esbuild@0.19.0", true)]);
598        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
599        assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
600    }
601
602    #[test]
603    fn version_union_splits() {
604        let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
605        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
606        assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
607        assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
608    }
609
610    #[test]
611    fn scoped_package_parses() {
612        let p = policy(&[("@swc/core@1.3.0", true)]);
613        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
614        assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
615    }
616
617    #[test]
618    fn scoped_bare_name() {
619        let p = policy(&[("@swc/core", true)]);
620        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
621    }
622
623    #[test]
624    fn pattern_matches_scoped_names_and_versions() {
625        assert!(pattern_matches("@swc/core", "@swc/core", "1.3.0").unwrap());
626        assert!(pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.0").unwrap());
627        assert!(!pattern_matches("@swc/core@1.3.0", "@swc/core", "1.3.1").unwrap());
628        assert!(pattern_matches("@swc/*", "@swc/core", "1.3.0").unwrap());
629        assert!(pattern_matches("aube-test-*", "aube-test-native", "1.0.0").unwrap());
630    }
631
632    #[test]
633    fn dangerously_allow_all_bypasses_deny_list() {
634        // pnpm's `createAllowBuildFunction` short-circuits to `() => true`
635        // when `dangerouslyAllowAllBuilds` is set, dropping the entire
636        // allowBuilds map — including any `false` entries. Pin that
637        // behavior so a future refactor doesn't accidentally start
638        // honoring deny rules under allow-all.
639        let mut map = BTreeMap::new();
640        map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
641        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
642        assert!(errs.is_empty());
643        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
644    }
645
646    #[test]
647    fn deny_wins_over_allow_when_both_listed() {
648        let map: BTreeMap<String, AllowBuildRaw> = [
649            ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
650            ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
651        ]
652        .into_iter()
653        .collect();
654        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
655        assert!(errs.is_empty());
656        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
657        assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
658    }
659
660    #[test]
661    fn deny_all_is_default() {
662        let p = BuildPolicy::deny_all();
663        assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
664        assert!(!p.has_any_allow_rule());
665    }
666
667    #[test]
668    fn allow_all_flag() {
669        let p = BuildPolicy::allow_all();
670        assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
671        assert!(p.has_any_allow_rule());
672    }
673
674    #[test]
675    fn invalid_version_union_reports_warning() {
676        let map: BTreeMap<String, AllowBuildRaw> = [(
677            "esbuild@not-a-version".to_string(),
678            AllowBuildRaw::Bool(true),
679        )]
680        .into_iter()
681        .collect();
682        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
683        assert_eq!(errs.len(), 1);
684        // The broken entry should not leak into the allowed set.
685        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
686    }
687
688    #[test]
689    fn source_specs_cannot_be_union_members() {
690        let map: BTreeMap<String, AllowBuildRaw> = [(
691            "dependency@https://example.com/dep.tgz || 1.0.0".to_string(),
692            AllowBuildRaw::Bool(true),
693        )]
694        .into_iter()
695        .collect();
696        let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
697        assert_eq!(errs.len(), 1);
698        assert!(matches!(errs[0], BuildPolicyError::InvalidVersionUnion(_)));
699    }
700
701    #[test]
702    fn semver_then_source_spec_union_is_also_rejected() {
703        let map: BTreeMap<String, AllowBuildRaw> = [(
704            "dependency@1.0.0 || https://example.com/dep.tgz".to_string(),
705            AllowBuildRaw::Bool(true),
706        )]
707        .into_iter()
708        .collect();
709        let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
710        assert_eq!(errs.len(), 1);
711        assert!(matches!(errs[0], BuildPolicyError::InvalidVersionUnion(_)));
712    }
713
714    #[test]
715    fn non_bool_value_reports_warning() {
716        let map: BTreeMap<String, AllowBuildRaw> =
717            [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
718                .into_iter()
719                .collect();
720        let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
721        assert_eq!(errs.len(), 1);
722    }
723
724    #[test]
725    fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
726        // pnpm's canonical `onlyBuiltDependencies` flat list is additive
727        // with `allowBuilds`, so both sources populate the same allowed
728        // set. Same pattern vocabulary — bare name or exact version.
729        let map = BTreeMap::new();
730        let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
731        let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
732        assert!(errs.is_empty());
733        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
734        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
735        assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
736        assert!(p.has_any_allow_rule());
737    }
738
739    #[test]
740    fn never_built_dependencies_denies() {
741        let map = BTreeMap::new();
742        let only_built = vec!["esbuild".to_string()];
743        let never_built = vec!["esbuild@0.19.0".to_string()];
744        let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
745        assert!(errs.is_empty());
746        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
747        assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
748    }
749
750    #[test]
751    fn never_built_beats_allow_builds_map() {
752        // Cross-source precedence: a bare-name deny in
753        // `neverBuiltDependencies` overrides a bare-name allow in the
754        // `allowBuilds` map. Mirrors the in-map deny-wins test above.
755        let map: BTreeMap<String, AllowBuildRaw> =
756            [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
757                .into_iter()
758                .collect();
759        let never_built = vec!["esbuild".to_string()];
760        let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
761        assert!(errs.is_empty());
762        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
763    }
764
765    #[test]
766    fn merge_deduplicates_wildcards() {
767        let mut p = policy(&[("@babel/*", true), ("*-internal", false)]);
768        let other = policy(&[
769            ("@babel/*", true),
770            ("@types/*", true),
771            ("*-internal", false),
772        ]);
773        p.merge(&other);
774        p.merge(&other);
775
776        assert_eq!(p.allowed_wildcards, vec!["@babel/*", "@types/*"]);
777        assert_eq!(p.denied_wildcards, vec!["*-internal"]);
778        assert_eq!(p.decide("@types/node", "1.0.0"), AllowDecision::Allow);
779        assert_eq!(p.decide("pkg-internal", "1.0.0"), AllowDecision::Deny);
780    }
781
782    #[test]
783    fn splits_scoped_correctly() {
784        assert_eq!(
785            split_name_and_versions("@swc/core@1.3.0"),
786            ("@swc/core", "1.3.0")
787        );
788        assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
789        assert_eq!(
790            split_name_and_versions("esbuild@0.19.0"),
791            ("esbuild", "0.19.0")
792        );
793        assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
794    }
795
796    #[test]
797    fn wildcard_scope_allows_every_scope_member() {
798        let p = policy(&[("@babel/*", true)]);
799        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
800        assert_eq!(
801            p.decide("@babel/preset-env", "7.22.0"),
802            AllowDecision::Allow
803        );
804        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
805        assert_eq!(
806            p.decide("babel-loader", "9.0.0"),
807            AllowDecision::Unspecified
808        );
809        assert!(p.has_any_allow_rule());
810    }
811
812    #[test]
813    fn wildcard_suffix_matches_any_prefix() {
814        let p = policy(&[("*-loader", true)]);
815        assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
816        assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
817        assert_eq!(
818            p.decide("loader-utils", "3.0.0"),
819            AllowDecision::Unspecified
820        );
821    }
822
823    #[test]
824    fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
825        // `*` in the allowlist behaves like "allow every package" but
826        // is still a normal allow rule — deny entries still override
827        // it, unlike `dangerouslyAllowAllBuilds` which short-circuits.
828        let map: BTreeMap<String, AllowBuildRaw> = [
829            ("*".to_string(), AllowBuildRaw::Bool(true)),
830            ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
831        ]
832        .into_iter()
833        .collect();
834        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
835        assert!(errs.is_empty());
836        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
837        assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
838    }
839
840    #[test]
841    fn denied_wildcard_blocks_allowed_exact() {
842        let map: BTreeMap<String, AllowBuildRaw> = [
843            ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
844            ("@babel/*".to_string(), AllowBuildRaw::Bool(false)),
845        ]
846        .into_iter()
847        .collect();
848        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
849        assert!(errs.is_empty());
850        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Deny);
851        assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
852    }
853
854    #[test]
855    fn wildcard_with_version_is_rejected() {
856        let map: BTreeMap<String, AllowBuildRaw> =
857            [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
858                .into_iter()
859                .collect();
860        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
861        assert_eq!(errs.len(), 1);
862        assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
863        // The rejected entry should not leak through as either an
864        // exact or a wildcard allow.
865        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
866    }
867
868    #[test]
869    fn wildcards_flow_through_flat_lists_too() {
870        let only_built = vec!["@types/*".to_string()];
871        let never_built = vec!["*-internal".to_string()];
872        let (p, errs) =
873            BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
874        assert!(errs.is_empty());
875        assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
876        assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
877        assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
878    }
879
880    #[test]
881    fn matches_wildcard_handles_all_positions() {
882        assert!(matches_wildcard("@babel/core", "@babel/*"));
883        assert!(matches_wildcard("@babel/", "@babel/*"));
884        assert!(!matches_wildcard("@babe/core", "@babel/*"));
885
886        assert!(matches_wildcard("css-loader", "*-loader"));
887        assert!(matches_wildcard("-loader", "*-loader"));
888        assert!(!matches_wildcard("loader-x", "*-loader"));
889
890        assert!(matches_wildcard("foobar", "foo*bar"));
891        assert!(matches_wildcard("foo-x-bar", "foo*bar"));
892        assert!(!matches_wildcard("foobaz", "foo*bar"));
893
894        assert!(matches_wildcard("@x/anything", "*"));
895        assert!(matches_wildcard("", "*"));
896
897        // Adjacent wildcards collapse to a single match, same as glob.
898        assert!(matches_wildcard("anything", "**"));
899    }
900
901    #[test]
902    fn matches_wildcard_multi_segment_greedy_is_correct() {
903        // Three+ wildcards exercise the greedy-leftmost middle-segment
904        // scan with a fixed-right suffix anchor. Each case either has a
905        // valid assignment (should match) or none (should not), and
906        // greedy-leftmost finds it whenever one exists — the fixed
907        // right anchor prevents greedy from eating characters the
908        // suffix needs.
909        assert!(matches_wildcard("abca", "*a*bc*a"));
910        assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
911        assert!(matches_wildcard("abcaXa", "*a*bc*a"));
912        assert!(matches_wildcard("ababab", "*ab*ab*"));
913        assert!(matches_wildcard("abcd", "a*b*c*d"));
914        assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
915
916        // Needs two non-overlapping occurrences of the middle / last
917        // anchors but the input only provides enough characters for
918        // one, so no assignment exists.
919        assert!(!matches_wildcard("aab", "*ab*ab"));
920        assert!(!matches_wildcard("abab", "*abc*abc"));
921
922        // Four wildcards still obey the same rules.
923        assert!(matches_wildcard(
924            "@acme/core-loader-plugin",
925            "@acme/*-*-plugin"
926        ));
927        assert!(!matches_wildcard(
928            "@acme/core-plugin-extra",
929            "@acme/*-*-plugin"
930        ));
931    }
932
933    #[test]
934    fn semver_shape() {
935        assert!(is_exact_semver("1.2.3"));
936        assert!(is_exact_semver("0.19.0"));
937        assert!(is_exact_semver("1.0.0-alpha"));
938        assert!(is_exact_semver("1.0.0+build.42"));
939        assert!(!is_exact_semver("1.2"));
940        assert!(!is_exact_semver("^1.2.3"));
941        assert!(!is_exact_semver("1.x.0"));
942    }
943}