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 with `*` are not yet supported — pnpm's `@pnpm/config.matcher`
23//! handles them but they're rare in practice and we can add them later
24//! if users ask.
25
26use aube_manifest::AllowBuildRaw;
27use std::collections::{BTreeMap, HashSet};
28
29/// The decision for a single `(name, version)` lookup.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AllowDecision {
32    /// Package is explicitly allowed — run its lifecycle scripts.
33    Allow,
34    /// Package is explicitly denied — skip even if a broader rule would allow.
35    Deny,
36    /// No rule matched; caller applies its default (aube denies).
37    Unspecified,
38}
39
40/// Resolved policy for deciding whether a package may run its
41/// lifecycle scripts.
42#[derive(Debug, Clone, Default)]
43pub struct BuildPolicy {
44    allow_all: bool,
45    /// Expanded allow-keys: bare names (match any version) and
46    /// `name@version` strings (match that specific version).
47    allowed: HashSet<String>,
48    denied: HashSet<String>,
49}
50
51impl BuildPolicy {
52    /// A policy that denies every package (the aube default).
53    pub fn deny_all() -> Self {
54        Self::default()
55    }
56
57    /// A policy that allows every package, regardless of the map.
58    /// Corresponds to `--dangerously-allow-all-builds`.
59    pub fn allow_all() -> Self {
60        Self {
61            allow_all: true,
62            ..Self::default()
63        }
64    }
65
66    /// Build from a raw `allowBuilds` map plus pnpm's canonical
67    /// `onlyBuiltDependencies` / `neverBuiltDependencies` flat lists,
68    /// plus the `dangerouslyAllowAllBuilds` flag.
69    ///
70    /// All three sources merge into one allow/deny set — pnpm uses the
71    /// flat lists in most real-world projects, and aube's `allowBuilds`
72    /// map is the superset format. Unrecognized `allowBuilds` value
73    /// shapes are collected in the returned `warnings` vec so the
74    /// caller can surface them through the progress UI.
75    pub fn from_config(
76        allow_builds: &BTreeMap<String, AllowBuildRaw>,
77        only_built: &[String],
78        never_built: &[String],
79        dangerously_allow_all: bool,
80    ) -> (Self, Vec<BuildPolicyError>) {
81        if dangerously_allow_all {
82            return (Self::allow_all(), Vec::new());
83        }
84        let mut allowed = HashSet::new();
85        let mut denied = HashSet::new();
86        let mut warnings = Vec::new();
87
88        for (pattern, value) in allow_builds {
89            let bool_value = match value {
90                AllowBuildRaw::Bool(b) => *b,
91                AllowBuildRaw::Other(raw) => {
92                    warnings.push(BuildPolicyError::UnsupportedValue {
93                        pattern: pattern.clone(),
94                        raw: raw.clone(),
95                    });
96                    continue;
97                }
98            };
99            match expand_spec(pattern) {
100                Ok(expanded) => {
101                    let target = if bool_value {
102                        &mut allowed
103                    } else {
104                        &mut denied
105                    };
106                    target.extend(expanded);
107                }
108                Err(e) => warnings.push(e),
109            }
110        }
111
112        // `onlyBuiltDependencies` / `neverBuiltDependencies` support the
113        // same pattern forms as `allowBuilds` map keys (bare name, exact
114        // version, exact version union), so route them through the same
115        // `expand_spec` — a single `esbuild@0.20.0` pin works in either
116        // format.
117        for pattern in only_built {
118            match expand_spec(pattern) {
119                Ok(expanded) => allowed.extend(expanded),
120                Err(e) => warnings.push(e),
121            }
122        }
123        for pattern in never_built {
124            match expand_spec(pattern) {
125                Ok(expanded) => denied.extend(expanded),
126                Err(e) => warnings.push(e),
127            }
128        }
129
130        (
131            Self {
132                allow_all: false,
133                allowed,
134                denied,
135            },
136            warnings,
137        )
138    }
139
140    /// Decide whether `(name, version)` may run lifecycle scripts.
141    /// Explicit denies always win over allows (mirrors pnpm).
142    pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
143        let with_version = format!("{name}@{version}");
144        if self.denied.contains(name) || self.denied.contains(&with_version) {
145            return AllowDecision::Deny;
146        }
147        if self.allow_all {
148            return AllowDecision::Allow;
149        }
150        if self.allowed.contains(name) || self.allowed.contains(&with_version) {
151            return AllowDecision::Allow;
152        }
153        AllowDecision::Unspecified
154    }
155
156    /// True when the policy would allow something — any rule at all, or
157    /// allow-all mode. Lets callers cheaply skip the whole dep-script
158    /// phase when nothing could possibly run.
159    pub fn has_any_allow_rule(&self) -> bool {
160        self.allow_all || !self.allowed.is_empty()
161    }
162}
163
164#[derive(Debug, Clone, thiserror::Error)]
165pub enum BuildPolicyError {
166    #[error("allowBuilds entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
167    UnsupportedValue { pattern: String, raw: String },
168    #[error("allowBuilds pattern {0:?} contains an invalid version union")]
169    InvalidVersionUnion(String),
170    #[error("allowBuilds pattern {0:?} mixes a wildcard name with a version union")]
171    WildcardWithVersion(String),
172}
173
174/// Parse one entry from the allowBuilds map into the set of strings
175/// that will be matched at decide-time. Mirrors pnpm's
176/// `expandPackageVersionSpecs`.
177fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
178    let (name, versions_part) = split_name_and_versions(pattern);
179
180    if versions_part.is_empty() {
181        return Ok(vec![name.to_string()]);
182    }
183    if name.contains('*') {
184        return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
185    }
186
187    let mut out = Vec::new();
188    for raw in versions_part.split("||") {
189        let trimmed = raw.trim();
190        if trimmed.is_empty() || !is_exact_semver(trimmed) {
191            return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
192        }
193        out.push(format!("{name}@{trimmed}"));
194    }
195    Ok(out)
196}
197
198/// Split `pattern` into `(name, version_spec)`, respecting a leading
199/// `@` for scoped packages so `@scope/foo@1.0.0` parses correctly.
200fn split_name_and_versions(pattern: &str) -> (&str, &str) {
201    let scoped = pattern.starts_with('@');
202    let search_from = if scoped { 1 } else { 0 };
203    match pattern[search_from..].find('@') {
204        Some(rel) => {
205            let at = search_from + rel;
206            (&pattern[..at], &pattern[at + 1..])
207        }
208        None => (pattern, ""),
209    }
210}
211
212/// Minimal exact-semver validator — accepts `MAJOR.MINOR.PATCH` plus an
213/// optional `-prerelease` / `+build` tail. We intentionally don't pull
214/// in the `semver` crate here because the file is tiny and this is the
215/// only place in aube-scripts that cares about semver shape.
216fn is_exact_semver(s: &str) -> bool {
217    // Strip build metadata; it doesn't affect equality for our purposes.
218    let core = s.split('+').next().unwrap_or(s);
219    // Strip pre-release; the shape just needs to parse as numeric triple.
220    let main = core.split('-').next().unwrap_or(core);
221    let parts: Vec<&str> = main.split('.').collect();
222    if parts.len() != 3 {
223        return false;
224    }
225    parts
226        .iter()
227        .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
235        let map: BTreeMap<String, AllowBuildRaw> = pairs
236            .iter()
237            .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
238            .collect();
239        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
240        assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
241        p
242    }
243
244    #[test]
245    fn bare_name_allows_any_version() {
246        let p = policy(&[("esbuild", true)]);
247        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
248        assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
249        assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
250    }
251
252    #[test]
253    fn exact_version_is_strict() {
254        let p = policy(&[("esbuild@0.19.0", true)]);
255        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
256        assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
257    }
258
259    #[test]
260    fn version_union_splits() {
261        let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
262        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
263        assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
264        assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
265    }
266
267    #[test]
268    fn scoped_package_parses() {
269        let p = policy(&[("@swc/core@1.3.0", true)]);
270        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
271        assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
272    }
273
274    #[test]
275    fn scoped_bare_name() {
276        let p = policy(&[("@swc/core", true)]);
277        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
278    }
279
280    #[test]
281    fn dangerously_allow_all_bypasses_deny_list() {
282        // pnpm's `createAllowBuildFunction` short-circuits to `() => true`
283        // when `dangerouslyAllowAllBuilds` is set, dropping the entire
284        // allowBuilds map — including any `false` entries. Pin that
285        // behavior so a future refactor doesn't accidentally start
286        // honoring deny rules under allow-all.
287        let mut map = BTreeMap::new();
288        map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
289        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
290        assert!(errs.is_empty());
291        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
292    }
293
294    #[test]
295    fn deny_wins_over_allow_when_both_listed() {
296        let map: BTreeMap<String, AllowBuildRaw> = [
297            ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
298            ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
299        ]
300        .into_iter()
301        .collect();
302        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
303        assert!(errs.is_empty());
304        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
305        assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
306    }
307
308    #[test]
309    fn deny_all_is_default() {
310        let p = BuildPolicy::deny_all();
311        assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
312        assert!(!p.has_any_allow_rule());
313    }
314
315    #[test]
316    fn allow_all_flag() {
317        let p = BuildPolicy::allow_all();
318        assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
319        assert!(p.has_any_allow_rule());
320    }
321
322    #[test]
323    fn invalid_version_union_reports_warning() {
324        let map: BTreeMap<String, AllowBuildRaw> = [(
325            "esbuild@not-a-version".to_string(),
326            AllowBuildRaw::Bool(true),
327        )]
328        .into_iter()
329        .collect();
330        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
331        assert_eq!(errs.len(), 1);
332        // The broken entry should not leak into the allowed set.
333        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
334    }
335
336    #[test]
337    fn non_bool_value_reports_warning() {
338        let map: BTreeMap<String, AllowBuildRaw> =
339            [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
340                .into_iter()
341                .collect();
342        let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
343        assert_eq!(errs.len(), 1);
344    }
345
346    #[test]
347    fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
348        // pnpm's canonical `onlyBuiltDependencies` flat list is additive
349        // with `allowBuilds`, so both sources populate the same allowed
350        // set. Same pattern vocabulary — bare name or exact version.
351        let map = BTreeMap::new();
352        let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
353        let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
354        assert!(errs.is_empty());
355        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
356        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
357        assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
358        assert!(p.has_any_allow_rule());
359    }
360
361    #[test]
362    fn never_built_dependencies_denies() {
363        let map = BTreeMap::new();
364        let only_built = vec!["esbuild".to_string()];
365        let never_built = vec!["esbuild@0.19.0".to_string()];
366        let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
367        assert!(errs.is_empty());
368        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
369        assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
370    }
371
372    #[test]
373    fn never_built_beats_allow_builds_map() {
374        // Cross-source precedence: a bare-name deny in
375        // `neverBuiltDependencies` overrides a bare-name allow in the
376        // `allowBuilds` map. Mirrors the in-map deny-wins test above.
377        let map: BTreeMap<String, AllowBuildRaw> =
378            [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
379                .into_iter()
380                .collect();
381        let never_built = vec!["esbuild".to_string()];
382        let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
383        assert!(errs.is_empty());
384        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
385    }
386
387    #[test]
388    fn splits_scoped_correctly() {
389        assert_eq!(
390            split_name_and_versions("@swc/core@1.3.0"),
391            ("@swc/core", "1.3.0")
392        );
393        assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
394        assert_eq!(
395            split_name_and_versions("esbuild@0.19.0"),
396            ("esbuild", "0.19.0")
397        );
398        assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
399    }
400
401    #[test]
402    fn semver_shape() {
403        assert!(is_exact_semver("1.2.3"));
404        assert!(is_exact_semver("0.19.0"));
405        assert!(is_exact_semver("1.0.0-alpha"));
406        assert!(is_exact_semver("1.0.0+build.42"));
407        assert!(!is_exact_semver("1.2"));
408        assert!(!is_exact_semver("^1.2.3"));
409        assert!(!is_exact_semver("1.x.0"));
410    }
411}