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