Skip to main content

ferrijs_std/node/
process.rs

1//! A deliberately small, sandbox-safe `process`, and the `node:process`
2//! module form of it.
3//!
4//! Node's `process` is mostly ambient authority; this exposes only the
5//! members that are either inert (platform/version/timing) or supplied by
6//! the host (`env`, `cwd`, `argv`). Everything that could escape a sandbox
7//! (`binding`, `dlopen`, `chdir`, `kill`, `setuid`, real `exit`) is absent
8//! or neutered, and `env` carries exactly the variables the host passes —
9//! empty unless it passes some.
10//!
11//! The module form is not a second implementation: the object it hands
12//! back IS `globalThis.process`, so `import process from 'node:process'`,
13//! `require('process')` and the bare global are one object, as in Node.
14
15use std::time::Instant;
16
17use rquickjs::function::{Func, Rest};
18use rquickjs::{Ctx, Object, Result, Value};
19
20/// The names the module re-exports. `process` itself is installed by the
21/// host; anything it does not set simply does not appear.
22pub const PROCESS_MEMBERS: &[&str] = &[
23  "argv",
24  "argv0",
25  "arch",
26  "cwd",
27  "env",
28  "exit",
29  "hrtime",
30  "nextTick",
31  "permission",
32  "pid",
33  "platform",
34  "release",
35  "stderr",
36  "stdout",
37  "version",
38  "versions",
39];
40
41/// What the host supplies to [`install`].
42#[derive(Debug, Clone, Default)]
43pub struct ProcessOptions {
44  /// `process.env`, already reduced to what the script may see. The
45  /// permission model's `env` grant decides what goes in here; this
46  /// module never reads the real environment itself.
47  pub env: Vec<(String, String)>,
48  /// What `process.cwd()` answers. A sandbox root rather than the real
49  /// working directory, so a script learns nothing about where the host
50  /// runs from.
51  pub cwd: String,
52  /// `process.argv` after the runtime name, so `argv[1..]`. Empty by
53  /// default: a script takes its inputs from the host, not from the
54  /// command line the host was started with.
55  pub argv: Vec<String>,
56}
57
58/// `globalThis.process`.
59///
60/// # Errors
61///
62/// When the host installed no `process` global.
63pub fn process_object<'js>(ctx: &Ctx<'js>) -> Result<Object<'js>> {
64  ctx.globals().get("process")
65}
66
67/// Install `globalThis.process`. Called once per realm (the values are
68/// realm-stable: `env` is the host's resolved allow-list, `cwd` its
69/// sandbox root, and the monotonic clock anchors here). The runtime's
70/// name and version come from [`crate::identity`].
71///
72/// # Errors
73///
74/// Propagates the property writes.
75pub fn install(ctx: &Ctx<'_>, options: &ProcessOptions) -> rquickjs::Result<()> {
76  let g = ctx.globals();
77  let p = Object::new(ctx.clone())?;
78  let identity = crate::identity::get(ctx);
79
80  // -- env: the only sensitive surface, default-deny ----------------
81  let env = Object::new(ctx.clone())?;
82  for (k, v) in &options.env {
83    env.set(k.as_str(), v.as_str())?;
84  }
85  // Frozen so a script cannot stuff values in and mislead later code
86  // into thinking an env var is set.
87  freeze(ctx, &env)?;
88  p.set("env", env)?;
89
90  // -- inert platform identity --------------------------------------
91  // Node's spelling, not Rust's: a suite branching on `process.platform
92  // === 'darwin'` (or comparing it to `os.platform()`) must see one
93  // answer, so both read the same constants.
94  p.set("platform", crate::utils::sysinfo::PLATFORM)?;
95  p.set("arch", crate::utils::sysinfo::ARCH)?;
96  // `process.version` is `v<semver>` in Node. The runtime's own name is
97  // in `release.name` and `versions`, where Node keeps it too, so a
98  // `process.version` parser sees the shape it expects and a runtime
99  // sniffer finds the truth where Node puts it.
100  p.set("version", format!("v{}", identity.version))?;
101  let versions = Object::new(ctx.clone())?;
102  versions.set(identity.name.as_str(), identity.version.as_str())?;
103  versions.set("ferrijs", env!("CARGO_PKG_VERSION"))?;
104  versions.set("quickjs", crate::identity::quickjs_version())?;
105  freeze(ctx, &versions)?;
106  p.set("versions", versions)?;
107  let release = Object::new(ctx.clone())?;
108  release.set("name", identity.name.as_str())?;
109  freeze(ctx, &release)?;
110  p.set("release", release)?;
111
112  // argv: `[argv0, ...host-supplied]`. Node's own layout is
113  // `[node, script, ...args]`; the host decides whether a script name
114  // belongs there, since only it knows whether one exists.
115  let argv = rquickjs::Array::new(ctx.clone())?;
116  argv.set(0, identity.name.as_str())?;
117  for (i, arg) in options.argv.iter().enumerate() {
118    argv.set(i + 1, arg.as_str())?;
119  }
120  p.set("argv", argv)?;
121  p.set("argv0", identity.name.as_str())?;
122  p.set("pid", i64::from(std::process::id()))?;
123
124  // cwd(): the sandbox root, never the real process cwd (no path leak).
125  let root = options.cwd.clone();
126  p.set("cwd", Func::from(move || root.clone()))?;
127
128  // nextTick -> microtask; the host installs `queueMicrotask`.
129  let next_tick = ctx.eval::<Value<'_>, _>(
130    "((cb, ...a) => { if (typeof cb !== 'function') throw new TypeError('callback required'); \
131       queueMicrotask(() => cb(...a)); })",
132  )?;
133  p.set("nextTick", next_tick)?;
134
135  // stdout/stderr: only `.write(chunk)` — routed into the same console
136  // capture the `console` global feeds (so output surfaces in
137  // `ScriptResult.console[]`), one trailing newline trimmed so a
138  // `write("x\n")` is one line, not a line + blank. Returns `true`
139  // (Node's "not backpressured"). No fd, not a TTY.
140  for (name, level) in [("stdout", "log"), ("stderr", "error")] {
141    let stream = Object::new(ctx.clone())?;
142    let f = rquickjs::Function::new(
143      ctx.clone(),
144      move |c: Ctx<'_>, chunk: Value<'_>| -> rquickjs::Result<bool> {
145        let s = chunk
146          .as_string()
147          .and_then(|v| v.to_string().ok())
148          .or_else(|| chunk.as_number().map(|n| n.to_string()))
149          .unwrap_or_default();
150        let s = s.strip_suffix('\n').unwrap_or(&s).to_string();
151        let console: Object<'_> = c.globals().get("console")?;
152        let sink: rquickjs::Function<'_> = console.get(level)?;
153        sink.call::<_, ()>((s,))?;
154        Ok(true)
155      },
156    )?;
157    stream.set("write", f)?;
158    stream.set("isTTY", false)?;
159    p.set(name, stream)?;
160  }
161
162  // hrtime([prev]) -> [seconds, nanos], monotonic from session start;
163  // hrtime.bigint() -> BigInt nanoseconds (Node parity).
164  //
165  // The SAME base `performance.now()` counts from, so the two clocks
166  // line up: Node derives both from one libuv hrtime, and a script that
167  // takes an hrtime reading and a `performance.now()` reading of the
168  // same moment expects them to agree on how far apart two moments are.
169  // A second `Instant::now()` here would start a few hundred
170  // microseconds later and put a constant, invisible skew between them.
171  let start = crate::web::performance::monotonic_base();
172  let hrtime = rquickjs::Function::new(ctx.clone(), move |prev: Rest<Value<'_>>| -> Vec<i64> {
173    let now = start.elapsed();
174    let (mut s, mut n) = (
175      i64::try_from(now.as_secs()).unwrap_or(i64::MAX),
176      i64::from(now.subsec_nanos()),
177    );
178    if let Some(arr) = prev.0.first().and_then(|v| v.as_array()) {
179      let ps = arr.get::<i64>(0).unwrap_or(0);
180      let pn = arr.get::<i64>(1).unwrap_or(0);
181      s -= ps;
182      n -= pn;
183      if n < 0 {
184        s -= 1;
185        n += 1_000_000_000;
186      }
187    }
188    vec![s, n]
189  })?;
190  // Forward into a generic fn so the `Ctx` and the returned `Value`
191  // share one `'js` (an inline closure gives each its own lifetime).
192  let bigint = rquickjs::Function::new(ctx.clone(), move |c| hrtime_bigint(c, start))?;
193  hrtime.set("bigint", bigint)?;
194  p.set("hrtime", hrtime)?;
195
196  // exit(): never kill the host — surface intent as an error so a
197  // script that relies on it fails loudly instead of silently no-oping.
198  p.set(
199    "exit",
200    Func::from(|code: Rest<Value<'_>>| -> rquickjs::Result<()> {
201      let c = code.0.first().and_then(rquickjs::Value::as_int).unwrap_or(0);
202      Err(rquickjs::Error::new_from_js_message(
203        "process.exit",
204        "Error",
205        format!("process.exit({c}) is not available: this runtime is embedded in a host process"),
206      ))
207    }),
208  )?;
209
210  // permission: Node's `process.permission.has(scope, reference)`, plus
211  // `drop`, which only ever narrows. The scopes are this runtime's
212  // (`read`, `write`, `net`, `env`, `sys`), not Node's `fs.read`
213  // spellings: a script that reads them can see exactly the model it is
214  // running under.
215  let permission = Object::new(ctx.clone())?;
216  permission.set(
217    "has",
218    Func::from(|ctx: Ctx<'_>, scope: String, reference: Rest<Value<'_>>| -> rquickjs::Result<bool> {
219      let reference = reference
220        .0
221        .first()
222        .and_then(|v| v.as_string())
223        .and_then(|s| s.to_string().ok());
224      crate::permissions::has(&ctx, &scope, reference.as_deref())
225    }),
226  )?;
227  permission.set(
228    "drop",
229    Func::from(|ctx: Ctx<'_>, scope: String, reference: Rest<Value<'_>>| -> rquickjs::Result<()> {
230      let reference = reference
231        .0
232        .first()
233        .and_then(|v| v.as_string())
234        .and_then(|s| s.to_string().ok());
235      crate::permissions::drop(&ctx, &scope, reference.as_deref())
236    }),
237  )?;
238  freeze(ctx, &permission)?;
239  p.set("permission", permission)?;
240
241  g.set("process", p)?;
242  Ok(())
243}
244
245/// `process.hrtime.bigint()` — nanoseconds since session start as a
246/// JS `BigInt`. Free fn so the closure's `Ctx`/return share `'js`.
247fn hrtime_bigint(ctx: Ctx<'_>, start: Instant) -> rquickjs::Result<Value<'_>> {
248  let nanos = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX);
249  Ok(rquickjs::BigInt::from_u64(ctx, nanos)?.into_value())
250}
251
252fn freeze<'js>(ctx: &Ctx<'js>, obj: &Object<'js>) -> rquickjs::Result<()> {
253  let freeze: rquickjs::Function<'js> = ctx.globals().get::<_, Object<'js>>("Object")?.get("freeze")?;
254  freeze.call::<_, Value<'js>>((obj.clone(),))?;
255  Ok(())
256}