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  // `node:path` reads the same value straight from the realm rather
126  // than calling back through here on every `resolve`.
127  crate::node::path::set_cwd(ctx, &options.cwd);
128  let root = options.cwd.clone();
129  p.set("cwd", Func::from(move || root.clone()))?;
130
131  // nextTick -> microtask; the host installs `queueMicrotask`.
132  let next_tick = ctx.eval::<Value<'_>, _>(
133    "((cb, ...a) => { if (typeof cb !== 'function') throw new TypeError('callback required'); \
134       queueMicrotask(() => cb(...a)); })",
135  )?;
136  p.set("nextTick", next_tick)?;
137
138  // stdout/stderr: only `.write(chunk)` — routed into the same console
139  // capture the `console` global feeds (so output surfaces in
140  // `ScriptResult.console[]`), one trailing newline trimmed so a
141  // `write("x\n")` is one line, not a line + blank. Returns `true`
142  // (Node's "not backpressured"). No fd, not a TTY.
143  for (name, level) in [("stdout", "log"), ("stderr", "error")] {
144    let stream = Object::new(ctx.clone())?;
145    let f = rquickjs::Function::new(
146      ctx.clone(),
147      move |c: Ctx<'_>, chunk: Value<'_>| -> rquickjs::Result<bool> {
148        let s = chunk
149          .as_string()
150          .and_then(|v| v.to_string().ok())
151          .or_else(|| chunk.as_number().map(|n| n.to_string()))
152          .unwrap_or_default();
153        let s = s.strip_suffix('\n').unwrap_or(&s).to_string();
154        let console: Object<'_> = c.globals().get("console")?;
155        let sink: rquickjs::Function<'_> = console.get(level)?;
156        sink.call::<_, ()>((s,))?;
157        Ok(true)
158      },
159    )?;
160    stream.set("write", f)?;
161    stream.set("isTTY", false)?;
162    p.set(name, stream)?;
163  }
164
165  // hrtime([prev]) -> [seconds, nanos], monotonic from session start;
166  // hrtime.bigint() -> BigInt nanoseconds (Node parity).
167  //
168  // The SAME base `performance.now()` counts from, so the two clocks
169  // line up: Node derives both from one libuv hrtime, and a script that
170  // takes an hrtime reading and a `performance.now()` reading of the
171  // same moment expects them to agree on how far apart two moments are.
172  // A second `Instant::now()` here would start a few hundred
173  // microseconds later and put a constant, invisible skew between them.
174  let start = crate::web::performance::monotonic_base();
175  let hrtime = rquickjs::Function::new(ctx.clone(), move |prev: Rest<Value<'_>>| -> Vec<i64> {
176    let now = start.elapsed();
177    let (mut s, mut n) = (
178      i64::try_from(now.as_secs()).unwrap_or(i64::MAX),
179      i64::from(now.subsec_nanos()),
180    );
181    if let Some(arr) = prev.0.first().and_then(|v| v.as_array()) {
182      let ps = arr.get::<i64>(0).unwrap_or(0);
183      let pn = arr.get::<i64>(1).unwrap_or(0);
184      s -= ps;
185      n -= pn;
186      if n < 0 {
187        s -= 1;
188        n += 1_000_000_000;
189      }
190    }
191    vec![s, n]
192  })?;
193  // Forward into a generic fn so the `Ctx` and the returned `Value`
194  // share one `'js` (an inline closure gives each its own lifetime).
195  let bigint = rquickjs::Function::new(ctx.clone(), move |c| hrtime_bigint(c, start))?;
196  hrtime.set("bigint", bigint)?;
197  p.set("hrtime", hrtime)?;
198
199  // exit(): never kill the host — surface intent as an error so a
200  // script that relies on it fails loudly instead of silently no-oping.
201  p.set(
202    "exit",
203    Func::from(|code: Rest<Value<'_>>| -> rquickjs::Result<()> {
204      let c = code.0.first().and_then(rquickjs::Value::as_int).unwrap_or(0);
205      Err(rquickjs::Error::new_from_js_message(
206        "process.exit",
207        "Error",
208        format!("process.exit({c}) is not available: this runtime is embedded in a host process"),
209      ))
210    }),
211  )?;
212
213  // permission: Node's `process.permission.has(scope, reference)`, plus
214  // `drop`, which only ever narrows. The scopes are this runtime's
215  // (`read`, `write`, `net`, `env`, `sys`), not Node's `fs.read`
216  // spellings: a script that reads them can see exactly the model it is
217  // running under.
218  let permission = Object::new(ctx.clone())?;
219  permission.set(
220    "has",
221    Func::from(|ctx: Ctx<'_>, scope: String, reference: Rest<Value<'_>>| -> rquickjs::Result<bool> {
222      let reference = reference
223        .0
224        .first()
225        .and_then(|v| v.as_string())
226        .and_then(|s| s.to_string().ok());
227      crate::permissions::has(&ctx, &scope, reference.as_deref())
228    }),
229  )?;
230  permission.set(
231    "drop",
232    Func::from(|ctx: Ctx<'_>, scope: String, reference: Rest<Value<'_>>| -> rquickjs::Result<()> {
233      let reference = reference
234        .0
235        .first()
236        .and_then(|v| v.as_string())
237        .and_then(|s| s.to_string().ok());
238      crate::permissions::drop(&ctx, &scope, reference.as_deref())
239    }),
240  )?;
241  freeze(ctx, &permission)?;
242  p.set("permission", permission)?;
243
244  g.set("process", p)?;
245  Ok(())
246}
247
248/// `process.hrtime.bigint()` — nanoseconds since session start as a
249/// JS `BigInt`. Free fn so the closure's `Ctx`/return share `'js`.
250fn hrtime_bigint(ctx: Ctx<'_>, start: Instant) -> rquickjs::Result<Value<'_>> {
251  let nanos = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX);
252  Ok(rquickjs::BigInt::from_u64(ctx, nanos)?.into_value())
253}
254
255fn freeze<'js>(ctx: &Ctx<'js>, obj: &Object<'js>) -> rquickjs::Result<()> {
256  let freeze: rquickjs::Function<'js> = ctx.globals().get::<_, Object<'js>>("Object")?.get("freeze")?;
257  freeze.call::<_, Value<'js>>((obj.clone(),))?;
258  Ok(())
259}