Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions crates/perry-runtime/src/node_vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,14 @@ fn get_reference(name: &str, env: &EvalEnv) -> f64 {
}

fn eval_property_path(expr: &str, env: &EvalEnv) -> Option<f64> {
let expr = expr.trim();
// Peel a trailing computed accessor (`a.b["k"]`) and read through it after
// resolving the receiver expression. Recurses so chained accessors work.
if let Some((object_expr, accessor)) = split_trailing_computed(expr) {
let object = eval_property_path(object_expr, env)?;
let key = computed_key_name(accessor, env)?;
return Some(get_object_field(object, &key));
}
let mut parts = expr.split('.');
let first = parts.next()?.trim();
if first.is_empty() {
Expand All @@ -1200,8 +1208,67 @@ fn eval_property_path(expr: &str, env: &EvalEnv) -> Option<f64> {
Some(value)
}

/// Split a trailing computed-member accessor off a member-access expression,
/// e.g. `globalThis.M["/a/b"]` -> (`globalThis.M`, `["/a/b"]`). Returns `None`
/// when the expression does not end in a top-level `[...]` accessor.
fn split_trailing_computed(expr: &str) -> Option<(&str, &str)> {
let trimmed = expr.trim_end();
if !trimmed.ends_with(']') {
return None;
}
let bytes = trimmed.as_bytes();
let mut depth = 0_i32;
let mut quote = None::<u8>;
let mut open = None;
for idx in (0..bytes.len()).rev() {
let ch = bytes[idx];
if let Some(q) = quote {
// Walking backwards through a quoted span: a quote char that is not
// backslash-escaped closes (opens, in reverse) the span.
if ch == q && (idx == 0 || bytes[idx - 1] != b'\\') {
quote = None;
}
continue;
}
match ch {
b'\'' | b'"' | b'`' => quote = Some(ch),
b']' => depth += 1,
b'[' => {
depth -= 1;
if depth == 0 {
open = Some(idx);
break;
}
}
_ => {}
}
}
let open = open?;
if open == 0 {
return None;
}
Some((trimmed[..open].trim(), &trimmed[open..]))
}

/// Evaluate the key inside a `[...]` accessor to a property name string.
fn computed_key_name(accessor: &str, env: &EvalEnv) -> Option<String> {
let inner = accessor.trim();
let inner = inner.strip_prefix('[')?.strip_suffix(']')?.trim();
let value = eval_expr(inner, env);
Some(coerce_to_string(value))
}

fn set_reference(lhs: &str, value: f64, env: &mut EvalEnv) {
let lhs = lhs.trim();
if let Some((object_expr, accessor)) = split_trailing_computed(lhs) {
if let (Some(object), Some(key)) = (
eval_property_path(object_expr, env),
computed_key_name(accessor, env),
) {
set_object_field(object, &key, value);
}
return;
}
if let Some((head, tail)) = lhs.rsplit_once('.') {
if let Some(object) = eval_property_path(head, env) {
set_object_field(object, tail.trim(), value);
Expand All @@ -1215,11 +1282,175 @@ fn set_reference(lhs: &str, value: f64, env: &mut EvalEnv) {
}
}

/// Rewrite a JS object/array literal into strict JSON so the runtime JSON
/// parser can build it. Next.js serializes the RSC manifest payload with
/// `JSON.stringify` (already strict JSON), but the broader contract accepts
/// plain object literals too, so we quote bare identifier keys (`{a:1}` ->
/// `{"a":1}`) and normalize single-quoted strings to double-quoted. Returns
/// `None` if the text is not a well-formed literal we can normalize.
fn normalize_literal_to_json(expr: &str) -> Option<String> {
let bytes = expr.as_bytes();
let mut out = String::with_capacity(expr.len() + 8);
let mut i = 0;
// Tracks whether the next bare-identifier run is in a key position (right
// after `{` or a `,` while inside an object). The brace stack records the
// kind of each open container: `true` = object, `false` = array.
let mut object_stack: Vec<bool> = Vec::new();
let mut expect_key = false;
while i < bytes.len() {
let ch = bytes[i];
match ch {
b' ' | b'\t' | b'\n' | b'\r' => {
out.push(ch as char);
i += 1;
}
b'"' | b'\'' => {
// Copy a quoted string, re-emitting as a double-quoted JSON
// string. Track escapes so a quote inside the string doesn't end
// it early.
let quote = ch;
out.push('"');
i += 1;
while i < bytes.len() {
let c = bytes[i];
if c == b'\\' && i + 1 < bytes.len() {
// `\'` is valid inside a JS single-quoted string but is
// NOT a legal JSON escape, so it would make an otherwise
// valid literal like `{name: 'can\'t'}` fail JSON
// parsing. Emit a plain apostrophe; pass every
// JSON-valid escape (\\, \", \n, …) through unchanged.
if bytes[i + 1] == b'\'' {
out.push('\'');
} else {
out.push('\\');
out.push(bytes[i + 1] as char);
}
i += 2;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
continue;
}
if c == quote {
i += 1;
break;
}
if c == b'"' {
out.push('\\');
}
out.push(c as char);
i += 1;
}
out.push('"');
expect_key = false;
}
b'{' => {
out.push('{');
object_stack.push(true);
expect_key = true;
i += 1;
}
b'[' => {
out.push('[');
object_stack.push(false);
expect_key = false;
i += 1;
}
b'}' | b']' => {
out.push(ch as char);
object_stack.pop();
expect_key = false;
i += 1;
}
b',' => {
out.push(',');
expect_key = object_stack.last().copied().unwrap_or(false);
i += 1;
}
b':' => {
out.push(':');
expect_key = false;
i += 1;
}
c if c == b'_' || c == b'$' || c.is_ascii_alphabetic() => {
// Bare identifier run. In key position, JSON-quote it. As a value
// it can only be a literal keyword (true/false/null); anything
// else (a context reference) is outside what we normalize here.
let start = i;
while i < bytes.len() {
let c = bytes[i];
if c == b'_' || c == b'$' || c.is_ascii_alphanumeric() {
i += 1;
} else {
break;
}
}
let ident = &expr[start..i];
if expect_key {
out.push('"');
out.push_str(ident);
out.push('"');
expect_key = false;
} else if matches!(ident, "true" | "false" | "null") {
out.push_str(ident);
} else {
return None;
}
}
_ => {
out.push(ch as char);
i += 1;
}
}
}
Some(out)
}

/// Parse an object/array literal expression into a value. Tries strict JSON
/// first (the common Next.js manifest case), then a lenient JS-literal->JSON
/// normalization. Returns `None` when the text is not a literal we can build.
fn eval_object_or_array_literal(expr: &str) -> Option<f64> {
let trimmed = expr.trim();
if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
return None;
}
let attempt = |text: &str| -> Option<f64> {
let ptr = string_ptr(text);
match unsafe { crate::json::js_json_parse_result(ptr) } {
Ok(value) => Some(f64::from_bits(value.bits())),
Err(_) => None,
}
};
if let Some(value) = attempt(trimmed) {
return Some(value);
}
let normalized = normalize_literal_to_json(trimmed)?;
attempt(&normalized)
}

fn is_truthy(value: f64) -> bool {
crate::value::js_is_truthy(value) != 0
}

fn eval_expr(expr: &str, env: &EvalEnv) -> f64 {
let expr = strip_wrapping_parens(expr);
if expr.is_empty() {
return undefined_value();
}
// Logical operators bind looser than comparison/arithmetic, so resolve them
// first. `find_top_level_operator` returns the last top-level occurrence,
// which yields correct left-associative short-circuit grouping.
if let Some(idx) = find_top_level_operator(expr, "||") {
let left = eval_expr(&expr[..idx], env);
if is_truthy(left) {
return left;
}
return eval_expr(&expr[idx + 2..], env);
}
if let Some(idx) = find_top_level_operator(expr, "&&") {
let left = eval_expr(&expr[..idx], env);
if !is_truthy(left) {
return left;
}
return eval_expr(&expr[idx + 2..], env);
}
if let Some(idx) = find_top_level_operator(expr, "===") {
let left = eval_expr(&expr[..idx], env);
let right = eval_expr(&expr[idx + 3..], env);
Expand Down Expand Up @@ -1253,6 +1484,9 @@ fn eval_expr(expr: &str, env: &EvalEnv) -> f64 {
if let Ok(n) = expr.parse::<f64>() {
return number_value(n);
}
if let Some(value) = eval_object_or_array_literal(expr) {
return value;
}
eval_property_path(expr, env).unwrap_or_else(undefined_value)
}

Expand Down
121 changes: 121 additions & 0 deletions crates/perry/tests/vm_run_in_context_global_alias.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! Regression test (#5437, Next.js dynamic-page cluster): `vm.runInNewContext`
//! / `vm.runInContext` must run the supplied code with the context object AS
//! the sandbox global, so that `globalThis.X = …`, bare `var Y = …`, and
//! top-level `this.Z = …` writes land on the context object — and bare reads
//! inside the code resolve against it.
//!
//! The original gap was not the global aliasing (that already worked for simple
//! assignments) but the toy VM interpreter's inability to evaluate the *shapes*
//! Next.js feeds it: the RSC client-reference manifest is two statements —
//! `globalThis.__RSC_MANIFEST = globalThis.__RSC_MANIFEST || {};` and
//! `globalThis.__RSC_MANIFEST["/page"] = { …JSON.stringify payload… };`. That
//! exercises the `||` operator, an object/array literal right-hand side, and a
//! computed-member (`obj["key"]`) assignment target — none of which the
//! interpreter handled, so the manifest write was silently dropped and Next's
//! `evalManifest` read back `undefined`, tripping the manifests-singleton
//! invariant and 500ing `/posts/[id]` + `/fetcher`.

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn compile_and_run(dir: &std::path::Path, source: &str) -> String {
let entry = dir.join("main.ts");
let output = dir.join("main_bin");
std::fs::write(&entry, source).expect("write entry");

let compile = Command::new(perry_bin())
.current_dir(dir)
.env("PERRY_ALLOW_PERRY_FEATURES", "1")
.env("PERRY_ALLOW_EVAL", "1")
.env("PERRY_ALLOW_UNIMPLEMENTED", "1")
.arg("compile")
.arg(&entry)
.arg("-o")
.arg(&output)
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);

let run = Command::new(&output)
.current_dir(dir)
.output()
.expect("run compiled binary");
assert!(
run.status.success(),
"compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
run.status,
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
String::from_utf8_lossy(&run.stdout).into_owned()
}

#[test]
fn run_in_new_context_aliases_context_object_as_global() {
let dir = tempfile::tempdir().expect("tempdir");
let stdout = compile_and_run(
dir.path(),
r#"
const vm = require("vm");

// Case 1: the context object is the sandbox global — globalThis/var/this all
// land on it. (node: 42 7 9)
const ctx1: any = {};
vm.runInNewContext("globalThis.X = 42; var Y = 7; this.Z = 9", ctx1);
if (ctx1.X !== 42 || ctx1.Y !== 7 || ctx1.Z !== 9) {
throw new Error("case1: " + ctx1.X + " " + ctx1.Y + " " + ctx1.Z);
}
console.log("case1-ok");

// Case 2: createContext + runInContext share the same context object.
const ctx2: any = {};
vm.createContext(ctx2);
vm.runInContext("globalThis.X = 1", ctx2);
if (ctx2.X !== 1) throw new Error("case2: " + ctx2.X);
console.log("case2-ok");

// Case 3: bare reads inside the code resolve against the context object.
const ctx3: any = { pre: 5 };
vm.runInNewContext("globalThis.out = pre + 1", ctx3);
if (ctx3.out !== 6) throw new Error("case3: " + ctx3.out);
console.log("case3-ok");

// Case 4: the exact Next.js RSC-manifest shape — `|| {}`, an object-literal
// right-hand side, and a computed-member assignment target.
const ctx4: any = {};
vm.runInNewContext(
'globalThis.__RSC_MANIFEST = globalThis.__RSC_MANIFEST || {};' +
'globalThis.__RSC_MANIFEST["/fetcher/page"] = {"moduleLoading":{"prefix":"","crossOrigin":null},"clientModules":{}};',
ctx4,
);
const entry = ctx4.__RSC_MANIFEST && ctx4.__RSC_MANIFEST["/fetcher/page"];
if (!entry) throw new Error("case4: manifest entry missing");
if (entry.moduleLoading.prefix !== "") throw new Error("case4: prefix " + entry.moduleLoading.prefix);
if (entry.moduleLoading.crossOrigin !== null) throw new Error("case4: crossOrigin not null");
if (typeof entry.clientModules !== "object") throw new Error("case4: clientModules shape");
console.log("case4-ok");

// Case 5: plain JS object literal with an unquoted key (not strict JSON) still
// builds, and computed-member read-back works.
const ctx5: any = {};
vm.runInNewContext('globalThis.M = {}; globalThis.M["x"] = {a: 1};', ctx5);
if (!ctx5.M || ctx5.M.x.a !== 1) throw new Error("case5: " + JSON.stringify(ctx5.M));
console.log("case5-ok");

console.log("ok");
"#,
);
assert_eq!(
stdout, "case1-ok\ncase2-ok\ncase3-ok\ncase4-ok\ncase5-ok\nok\n",
"vm.runIn*Context must alias the context object as the sandbox global and evaluate manifest-shaped code"
);
}
Loading
Loading