Skip to main content

afterburner_core/
manifold.rs

1// SPDX-License-Identifier: BUSL-1.1
2// Copyright (c) 2026 vertexclique
3// Licensed under the Business Source License 1.1.
4// Change Date: 4 years after this version's release. Change License: Apache-2.0.
5
6//! `Manifold` - capability gate controlling which Node.js-style built-in
7//! modules (and which parts of them) are available to a running script.
8//!
9//! The metaphor: the intake manifold decides what air can enter the
10//! combustion chamber. By default - [`Manifold::sealed`] - nothing enters.
11//! Hosts that trust their scripts open specific flaps (FS roots, env
12//! allow-lists, outbound HTTP allow-lists) explicitly.
13//!
14//! A `Manifold` rides alongside every call via `FuelGauge`, so different
15//! scripts on the same engine can have different capability profiles.
16
17use serde::{Deserialize, Serialize};
18use std::path::PathBuf;
19
20/// Filesystem capability. Roots are resolved via `fs::canonicalize` at
21/// call time; any path escape (via `..`, symlinks, etc.) outside the
22/// listed roots is rejected with `AfterburnerError::PermissionDenied`.
23#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
24pub enum FsAccess {
25    /// No FS access. Any call into `fs.*` from a script returns
26    /// `PermissionDenied`.
27    #[default]
28    None,
29    /// Read-only access rooted at the given paths. Writes return
30    /// `PermissionDenied`.
31    ReadOnly(Vec<PathBuf>),
32    /// Read-write access rooted at the given paths.
33    ReadWrite(Vec<PathBuf>),
34}
35
36/// Outbound networking capability. Inbound listening is governed
37/// separately by [`ListenAccess`] - `net` models outbound only.
38#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
39pub enum NetAccess {
40    /// No network access.
41    #[default]
42    None,
43    /// Outbound HTTP/HTTPS only. `Some(list)` is a host-allow-list of
44    /// regexes/globs matched against the request host; `None` is
45    /// "any host."
46    OutboundHttp(Option<Vec<String>>),
47    /// Outbound TCP + HTTP. Same allow-list semantics.
48    OutboundFull(Option<Vec<String>>),
49}
50
51/// Inbound listening capability - which ports daemon-mode servers
52/// (`http.createServer().listen(port)`, the HTTP/3 listener) may bind.
53/// Checked by the listen host-calls before any socket bind; a denied
54/// port surfaces as `PermissionDenied`, exactly like outbound `net`
55/// and `fs` denials.
56#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
57pub enum ListenAccess {
58    /// No inbound listening. Any `.listen(port)` returns
59    /// `PermissionDenied`.
60    #[default]
61    None,
62    /// Only the listed ports may be bound.
63    Ports(Vec<u16>),
64    /// Any port in the inclusive range `[lo, hi]` may be bound.
65    PortRange(u16, u16),
66    /// Any port. Use only for trusted scripts ([`Manifold::open`]).
67    Any,
68}
69
70impl ListenAccess {
71    /// Whether binding `port` is permitted under this grant.
72    #[must_use]
73    pub fn allows(&self, port: u16) -> bool {
74        match self {
75            Self::None => false,
76            Self::Ports(ports) => ports.contains(&port),
77            Self::PortRange(lo, hi) => (*lo..=*hi).contains(&port),
78            Self::Any => true,
79        }
80    }
81}
82
83/// Process-environment access for `process.env` and `getenv`.
84#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
85pub enum EnvAccess {
86    /// `process.env` is an empty object. No env vars readable.
87    #[default]
88    None,
89    /// Only the listed keys are readable. Unknown keys return `undefined`.
90    AllowList(Vec<String>),
91    /// Full process environment is visible. Use only for trusted scripts.
92    Full,
93}
94
95/// A full capability profile for one script execution.
96///
97/// Construct via [`Manifold::sealed`] (safe default) or
98/// [`Manifold::open`] (trusted admin contexts), then adjust individual
99/// fields.
100#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
101pub struct Manifold {
102    pub fs: FsAccess,
103    pub net: NetAccess,
104    pub crypto: bool,
105    pub child_process: bool,
106    pub env: EnvAccess,
107    /// Whether `process.exit()` terminates the script early. When `false`,
108    /// `process.exit(code)` throws instead, so the host always receives
109    /// a trap-or-value result.
110    pub allow_exit: bool,
111    /// Per-call HTTP-request wall-clock cap, in milliseconds. `None`
112    /// uses the host implementation's default (currently 30 s). Lets
113    /// callers tighten the budget for SLA-strict scripts or loosen
114    /// it for batch jobs.
115    pub http_timeout_ms: Option<u64>,
116    /// Inbound listening grant for daemon-mode servers. `#[serde(default)]`
117    /// so manifolds serialized before this axis existed deserialize
118    /// unchanged (absent field = `ListenAccess::None` - sealed stays
119    /// sealed, never widened).
120    #[serde(default)]
121    pub listen: ListenAccess,
122}
123
124impl Manifold {
125    /// Zero-capability manifold. Safe for untrusted code - pure-JS
126    /// modules (`path`, `url`, `buffer`, …) still work; host-backed
127    /// modules (`fs`, `crypto`, `http`, …) return `PermissionDenied`.
128    pub const fn sealed() -> Self {
129        Self {
130            fs: FsAccess::None,
131            net: NetAccess::None,
132            crypto: false,
133            child_process: false,
134            env: EnvAccess::None,
135            allow_exit: false,
136            http_timeout_ms: None,
137            listen: ListenAccess::None,
138        }
139    }
140
141    /// Full capabilities - every flap open. Only appropriate for
142    /// admin/trusted contexts; never expose to untrusted user JS.
143    pub fn open() -> Self {
144        Self {
145            fs: FsAccess::ReadWrite(Vec::new()),
146            net: NetAccess::OutboundFull(None),
147            crypto: true,
148            child_process: true,
149            env: EnvAccess::Full,
150            allow_exit: true,
151            http_timeout_ms: None,
152            listen: ListenAccess::Any,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn sealed_is_the_default() {
163        assert_eq!(Manifold::default(), Manifold::sealed());
164    }
165
166    #[test]
167    fn sealed_has_no_capabilities() {
168        let m = Manifold::sealed();
169        assert!(matches!(m.fs, FsAccess::None));
170        assert!(matches!(m.net, NetAccess::None));
171        assert!(!m.crypto);
172        assert!(!m.child_process);
173        assert!(matches!(m.env, EnvAccess::None));
174        assert!(!m.allow_exit);
175        assert!(matches!(m.listen, ListenAccess::None));
176    }
177
178    #[test]
179    fn open_grants_everything() {
180        let m = Manifold::open();
181        assert!(matches!(m.fs, FsAccess::ReadWrite(_)));
182        assert!(matches!(m.net, NetAccess::OutboundFull(_)));
183        assert!(m.crypto);
184        assert!(m.child_process);
185        assert!(matches!(m.env, EnvAccess::Full));
186        assert!(m.allow_exit);
187        assert!(matches!(m.listen, ListenAccess::Any));
188    }
189
190    #[test]
191    fn listen_allows_matches_grant_shapes() {
192        assert!(!ListenAccess::None.allows(80));
193        let ports = ListenAccess::Ports(vec![8080, 9090]);
194        assert!(ports.allows(8080));
195        assert!(ports.allows(9090));
196        assert!(!ports.allows(8081));
197        let range = ListenAccess::PortRange(9000, 9100);
198        assert!(range.allows(9000));
199        assert!(range.allows(9100));
200        assert!(!range.allows(8999));
201        assert!(!range.allows(9101));
202        assert!(ListenAccess::Any.allows(1));
203        assert!(ListenAccess::Any.allows(65535));
204    }
205
206    #[test]
207    fn listen_serde_roundtrips_every_variant() {
208        for v in [
209            ListenAccess::None,
210            ListenAccess::Ports(vec![9090]),
211            ListenAccess::Ports(vec![80, 443, 8080]),
212            ListenAccess::PortRange(9000, 9100),
213            ListenAccess::Any,
214        ] {
215            let json = serde_json::to_string(&v).expect("serialize");
216            let back: ListenAccess = serde_json::from_str(&json).expect("deserialize");
217            assert_eq!(v, back, "roundtrip drift for {json}");
218        }
219    }
220
221    #[test]
222    fn listen_serde_is_externally_tagged_like_the_other_axes() {
223        // The wire shapes are part of the `.afb` digest contract - pin them.
224        assert_eq!(
225            serde_json::to_string(&ListenAccess::None).expect("ser"),
226            r#""None""#
227        );
228        assert_eq!(
229            serde_json::to_string(&ListenAccess::Ports(vec![9090])).expect("ser"),
230            r#"{"Ports":[9090]}"#
231        );
232        assert_eq!(
233            serde_json::to_string(&ListenAccess::PortRange(9000, 9100)).expect("ser"),
234            r#"{"PortRange":[9000,9100]}"#
235        );
236        assert_eq!(
237            serde_json::to_string(&ListenAccess::Any).expect("ser"),
238            r#""Any""#
239        );
240    }
241
242    #[test]
243    fn manifold_without_listen_field_parses_as_none() {
244        // Manifolds serialized before the listen axis existed must
245        // deserialize unchanged: absent field = None (never widened).
246        let legacy = r#"{
247            "fs": "None",
248            "net": "None",
249            "crypto": false,
250            "child_process": false,
251            "env": "None",
252            "allow_exit": false,
253            "http_timeout_ms": null
254        }"#;
255        let m: Manifold = serde_json::from_str(legacy).expect("legacy manifold parses");
256        assert_eq!(m, Manifold::sealed());
257        assert!(matches!(m.listen, ListenAccess::None));
258    }
259
260    #[test]
261    fn manifold_listen_roundtrips_through_json() {
262        let mut m = Manifold::sealed();
263        m.listen = ListenAccess::PortRange(18200, 18210);
264        let json = serde_json::to_string(&m).expect("serialize");
265        let back: Manifold = serde_json::from_str(&json).expect("deserialize");
266        assert_eq!(m, back);
267    }
268}