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
2 changes: 1 addition & 1 deletion crates/perry-hir/src/destructuring/var_decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub(crate) fn lower_var_decl_with_destructuring(
// native-instance registration (extracted to `native_fetch`).
// Returns true when nothing observable is bound (the `require`
// of a resolvable native module).
if register_native_fetch_and_streams(ctx, decl, &name, &mut ty) {
if register_native_fetch_and_streams(ctx, decl, &name, &mut ty, mutable) {
return Ok(result);
}

Expand Down
38 changes: 25 additions & 13 deletions crates/perry-hir/src/destructuring/var_decl/native_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub(crate) fn register_native_fetch_and_streams(
decl: &ast::VarDeclarator,
name: &str,
ty: &mut Type,
mutable: bool,
) -> bool {
// #5216: `const <name> = require("<spec>")` of a statically
// resolvable native/Node-builtin module lowers to the same
Expand Down Expand Up @@ -53,19 +54,30 @@ pub(crate) fn register_native_fetch_and_streams(
}
}

if let Some(init_expr) = &decl.init {
// #8342: inside a CJS-wrapped module the wrap's synthetic
// `function require(...)` (with a `createRequire`-backed built-in arm)
// shadows the bare global `require`. Don't steal `let x =
// require("process")` into a native-module namespace binding here — the
// native namespace isn't initialized in a CJS-wrapped module, so `x`
// would be undefined at runtime (`ReferenceError: node_process is not
// defined`). Let the call flow through to the synthetic require, which
// resolves builtins via `createRequire` (see `cjs_wrap::wrap`).
if !require_is_shadowed_by_local(ctx) {
if let Some(module_name) = require_resolvable_native_specifier(init_expr) {
register_require_namespace_binding(ctx, name, &module_name);
return true;
// Gated on `!mutable` (a `const`, never `let`/`var`): a real namespace
// import is immutable, and this optimization emits no runtime binding at
// all, so a later reassignment of `name` would reference a slot that was
// never created. Rolldown's own ESM-interop preamble does exactly that —
// `let node_process = require("node:process"); node_process =
// __toESM(node_process);` — to wrap the require result in a `.default`
// shape. Applying the optimization there dropped the runtime `let`
// entirely, so the reassignment (and every later `node_process.default.*`
// read) threw `ReferenceError: node_process is not defined`.
if !mutable {
if let Some(init_expr) = &decl.init {
// #8342: inside a CJS-wrapped module the wrap's synthetic
// `function require(...)` (with a `createRequire`-backed built-in arm)
// shadows the bare global `require`. Don't steal `let x =
// require("process")` into a native-module namespace binding here — the
// native namespace isn't initialized in a CJS-wrapped module, so `x`
// would be undefined at runtime (`ReferenceError: node_process is not
// defined`). Let the call flow through to the synthetic require, which
// resolves builtins via `createRequire` (see `cjs_wrap::wrap`).
if !require_is_shadowed_by_local(ctx) {
if let Some(module_name) = require_resolvable_native_specifier(init_expr) {
register_require_namespace_binding(ctx, name, &module_name);
return true;
}
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions crates/perry-runtime/src/object/class_registry/class_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@ pub(crate) fn identify_global_builtin_constructor(func_value: f64) -> Option<&'s
// bare-call thunk (which throws "Constructor WeakMap requires 'new'").
|| func_ptr == map_constructor_call_thunk as *const u8 as usize
|| func_ptr == set_constructor_call_thunk as *const u8 as usize
// #2889's own arm handles `new (rebound RegExp)(...)`, but this
// recognition step never accepted RegExp's dedicated thunk, so it
// never reached that arm: `const RegExpCtor = RegExp; new
// RegExpCtor(pattern)` (socket-lib's rolldown-bundled primordials
// module does exactly this) fell through to the generic
// empty-object path, producing an object `.source`/`.flags`
// readers reject as an unbranded receiver.
|| func_ptr == regexp_constructor_call_thunk as *const u8 as usize
Comment thread
coderabbitai[bot] marked this conversation as resolved.
|| func_ptr == weak_map_constructor_call_thunk as *const u8 as usize
|| func_ptr == weak_set_constructor_call_thunk as *const u8 as usize
|| func_ptr == weak_ref_constructor_call_thunk as *const u8 as usize
Expand Down Expand Up @@ -277,6 +285,8 @@ pub(crate) fn identify_global_builtin_constructor(func_value: f64) -> Option<&'s
Some("WeakRef")
} else if func_ptr == promise_constructor_call_thunk as *const u8 as usize {
Some("Promise")
} else if func_ptr == regexp_constructor_call_thunk as *const u8 as usize {
Some("RegExp")
} else {
None
};
Expand Down
103 changes: 103 additions & 0 deletions crates/perry/tests/rebound_regexp_constructor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Regression test: `new` on a value that IS the global `RegExp` constructor,
//! reached through a local rebinding rather than the literal `RegExp`
//! identifier, must produce a real, correctly-branded regex instance.
//!
//! This is the shape of `@socketsecurity/lib`'s rolldown-bundled
//! `dist/primordials/regexp.js` (`const RegExpCtor = RegExp; exports
//! .RegExpCtor = RegExpCtor;`), consumed elsewhere as
//! `new _p_RegExpCtor(pattern)`.
//!
//! Pre-fix, `identify_global_builtin_constructor` never recognized RegExp's
//! own constructor thunk, so a rebound `RegExp` value fell through to the
//! generic empty-object construction path instead of the `"RegExp"` arm
//! already in `construct.rs`. The resulting object wasn't registered as a
//! real regex, so reading `.source`/`.flags` off it threw `TypeError: get
//! RegExp.prototype.source called on incompatible receiver`, and `.test()`
//! would have silently misbehaved rather than matching.

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)
.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)
);
assert!(
run.stderr.is_empty(),
"compiled binary wrote to stderr\nstderr:\n{}",
String::from_utf8_lossy(&run.stderr)
);
String::from_utf8_lossy(&run.stdout).into_owned()
}

#[test]
fn rebound_regexp_constructor_produces_real_regex() {
let dir = tempfile::tempdir().expect("tempdir");
let stdout = compile_and_run(
dir.path(),
r#"
const RegExpCtor = RegExp;
const re = new RegExpCtor("^\\.[a-z]+$", "i");
console.log("source:", re.source);
console.log("flags:", re.flags);
console.log("test-match:", re.test(".JS"));
console.log("test-nomatch:", re.test("nope"));
console.log("instanceof:", re instanceof RegExp);
"#,
);
assert_eq!(
stdout,
"source: ^\\.[a-z]+$\nflags: i\ntest-match: true\ntest-nomatch: false\ninstanceof: true\n"
);
}

/// The exact socket-lib shape: derive a second regex's source from an
/// already-rebound-constructed one (`rSlash.source` inside another `new
/// RegExpCtor(...)` call) — the concrete pattern from `@npmcli/promise-spawn`'s
/// bundled `dist` that surfaced this bug.
#[test]
fn rebound_regexp_source_composes_into_another_rebound_regexp() {
let dir = tempfile::tempdir().expect("tempdir");
let stdout = compile_and_run(
dir.path(),
r#"
const RegExpCtor = RegExp;
const rSlash = new RegExpCtor("[/]");
const rRel = new RegExpCtor(`^\\.${rSlash.source}`);
console.log("rSlash-source:", rSlash.source);
console.log("rRel-test:", rRel.test("./foo"));
"#,
);
assert_eq!(stdout, "rSlash-source: [/]\nrRel-test: true\n");
}
148 changes: 148 additions & 0 deletions crates/perry/tests/require_reassign_unwrapped_module.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! Contract test: a CJS dependency that does `let x = require("<native
//! module>"); x = someHelper.__toESM(x);` must keep a real runtime binding
//! for `x`, so the reassignment (and every later `x.default.*` read) resolves
//! instead of throwing `ReferenceError: x is not defined`.
//!
//! This is the exact shape of `@socketsecurity/lib`'s rolldown-bundled
//! `dist/bin/trusted.js` and `dist/process/spawn/child.js`, which crashed on
//! startup at the perry commit `@socketsecurity/lib` was pinned to
//! (`06137858d`, before this fix): `register_native_fetch_and_streams`
//! treated ANY `let/const/var x = require("<native>")` as an immutable
//! namespace binding and emitted no runtime variable for `x` at all — correct
//! for a `const` that's never reassigned, wrong here.
//!
//! `#8342`'s CJS-wrap shadow check (already on `main`) independently protects
//! this exact shape when the module goes through the `compilePackages` wrap,
//! which is why this specific minimized reproduction no longer reproduces the
//! crash in isolation on top of the current tree — the two fixes overlap for
//! the common case. This test locks down the observable contract (the
//! reassignment resolves correctly) regardless of which of the two checks is
//! providing it, since `register_native_fetch_and_streams`'s own gate is the
//! only protection for a module reached outside the CJS-wrap path.

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, entry_name: &str) -> String {
let entry = dir.join(entry_name);
let output = dir.join("main_bin");

let compile = Command::new(perry_bin())
.current_dir(dir)
.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)
);
assert!(
run.stderr.is_empty(),
"compiled binary wrote to stderr\nstderr:\n{}",
String::from_utf8_lossy(&run.stderr)
);
String::from_utf8_lossy(&run.stdout).into_owned()
}

#[test]
fn unwrapped_cjs_package_reassigning_native_require_keeps_runtime_binding() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();

std::fs::write(
root.join("package.json"),
r#"{
"name": "require-reassign-unwrapped",
"type": "module",
"perry": {
"compilePackages": ["trusted-pkg"],
"allow": { "compilePackages": ["trusted-pkg"] }
}
}"#,
)
.expect("write root package.json");

let pkg = root.join("node_modules").join("trusted-pkg");
std::fs::create_dir_all(&pkg).expect("mkdir trusted-pkg");
std::fs::write(
pkg.join("package.json"),
r#"{ "name": "trusted-pkg", "version": "1.0.0", "main": "./trusted.js" }"#,
)
.expect("write trusted-pkg package.json");

// Mirrors socket-lib's `dist/_virtual/_rolldown/runtime.js`: a plain CJS
// helper, required by relative path.
std::fs::write(
pkg.join("runtime.js"),
r#""use strict";
exports.__toESM = function (mod) {
if (mod && typeof mod === "object" && mod.__esModule) return mod;
var target = {};
Object.defineProperty(target, "default", { value: mod, enumerable: true });
return target;
};
"#,
)
.expect("write runtime.js");

// Mirrors socket-lib's `dist/bin/trusted.js` exactly: the
// `Symbol.toStringTag` ESM-interop marker (not the wrap-triggering
// `__esModule` string key), a relative require for the local helper, then
// the `let x = require(...); x = helper.__toESM(x);` reassignment this
// regression is about.
std::fs::write(
pkg.join("trusted.js"),
r#""use strict";
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const require_runtime = require("./runtime.js");
let node_process = require("node:process");
node_process = require_runtime.__toESM(node_process);

function currentPlatform() {
return node_process.default.platform;
}
exports.currentPlatform = currentPlatform;
"#,
)
.expect("write trusted.js");

std::fs::write(
root.join("main.ts"),
r#"
import { currentPlatform } from "trusted-pkg";
console.log("platform:", currentPlatform());
"#,
)
.expect("write entry");

let stdout = compile_and_run(root, "main.ts");
let platform = std::env::consts::OS;
let expected_platform = match platform {
"macos" => "darwin",
"linux" => "linux",
"windows" => "win32",
_ => platform,
};
assert_eq!(stdout, format!("platform: {expected_platform}\n"));
}
Loading