Compare commits

...

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
c6f246484c [autofix.ci] apply automated fixes 2026-02-26 22:11:27 +00:00
Claude Bot
deea9e810b fix(repl): prevent reassignment of const variables across lines
The REPL was converting all `const`/`let`/`var` declarations to `var`
for persistence across evaluations, which allowed `const` variables
to be silently reassigned. This fix preserves `const` semantics by
keeping const declarations inside the IIFE and using
Object.defineProperty with getter/setter to persist const bindings
on globalThis as non-reassignable properties.

Fixes #27485

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-26 22:08:59 +00:00
5 changed files with 204 additions and 31 deletions

View File

@@ -228,16 +228,16 @@ To build for macOS x64:
The order of the `--target` flag does not matter, as long as they're delimited by a `-`.
| --target | Operating System | Architecture | Modern | Baseline | Libc |
| --------------------- | ---------------- | ------------ | ------ | -------- | ----- |
| bun-linux-x64 | Linux | x64 | ✅ | ✅ | glibc |
| bun-linux-arm64 | Linux | arm64 | ✅ | N/A | glibc |
| bun-windows-x64 | Windows | x64 | ✅ | ✅ | - |
| bun-windows-arm64 | Windows | arm64 | ✅ | N/A | - |
| bun-darwin-x64 | macOS | x64 | ✅ | ✅ | - |
| bun-darwin-arm64 | macOS | arm64 | ✅ | N/A | - |
| bun-linux-x64-musl | Linux | x64 | ✅ | ✅ | musl |
| bun-linux-arm64-musl | Linux | arm64 | ✅ | N/A | musl |
| --target | Operating System | Architecture | Modern | Baseline | Libc |
| -------------------- | ---------------- | ------------ | ------ | -------- | ----- |
| bun-linux-x64 | Linux | x64 | ✅ | ✅ | glibc |
| bun-linux-arm64 | Linux | arm64 | ✅ | N/A | glibc |
| bun-windows-x64 | Windows | x64 | ✅ | ✅ | - |
| bun-windows-arm64 | Windows | arm64 | ✅ | N/A | - |
| bun-darwin-x64 | macOS | x64 | ✅ | ✅ | - |
| bun-darwin-arm64 | macOS | arm64 | ✅ | N/A | - |
| bun-linux-x64-musl | Linux | x64 | ✅ | ✅ | musl |
| bun-linux-arm64-musl | Linux | arm64 | ✅ | N/A | musl |
<Warning>
On x64 platforms, Bun uses SIMD optimizations which require a modern CPU supporting AVX2 instructions. The `-baseline`

View File

@@ -74,30 +74,61 @@ pub fn ReplTransforms(comptime P: type) type {
for (all_stmts) |stmt| {
switch (stmt.data) {
.s_local => |local| {
// Hoist all declarations as var so they become context properties
// In sloppy mode, var at top level becomes a property of the global/context object
// This is essential for REPL variable persistence across vm.runInContext calls
const kind: S.Local.Kind = .k_var;
const is_const = local.kind == .k_const or local.kind == .k_using or local.kind == .k_await_using;
// Extract individual identifiers from binding patterns for hoisting
var hoisted_decl_list = ListManaged(G.Decl).init(allocator);
for (local.decls.slice()) |decl| {
try extractIdentifiersFromBinding(p, decl.binding, &hoisted_decl_list);
}
if (is_const) {
// For const/using declarations, preserve the original declaration inside the IIFE
// to maintain immutability semantics. After the declaration, use
// Object.defineProperty(globalThis, name, { value, writable: false, configurable: true, enumerable: true })
// to persist each binding on globalThis as a non-writable property.
// configurable: true allows re-declaration in subsequent REPL lines.
try inner_stmts.append(stmt);
if (hoisted_decl_list.items.len > 0) {
try hoisted_stmts.append(p.s(S.Local{
.kind = kind,
.decls = Decl.List.fromOwnedSlice(hoisted_decl_list.items),
}, stmt.loc));
}
// Add Object.defineProperty calls for each identifier in the binding
for (local.decls.slice()) |decl| {
try emitDefinePropertyCalls(p, decl.binding, &inner_stmts, allocator, stmt.loc);
}
// Create assignment expressions for the inner statements
for (local.decls.slice()) |decl| {
if (decl.value) |value| {
// Create assignment expression: binding = value
const assign_expr = createBindingAssignment(p, decl.binding, value, allocator);
try inner_stmts.append(p.s(S.SExpr{ .value = assign_expr }, stmt.loc));
// Add the last declarator's first identifier as a result
// expression so wrapLastExpressionWithReturn can capture it
// for display. We emit the identifier (not the initializer)
// to avoid re-evaluating side-effectful expressions.
const decls = local.decls.slice();
if (decls.len > 0) {
const last_decl = decls[decls.len - 1];
if (last_decl.value != null) {
if (getFirstIdentifierRef(last_decl.binding)) |ref| {
try inner_stmts.append(p.s(S.SExpr{
.value = p.newExpr(E.Identifier{ .ref = ref }, stmt.loc),
}, stmt.loc));
}
}
}
} else {
// For var/let declarations, hoist as var so they become global properties
// In sloppy mode, var at top level becomes a property of the global/context object
// This is essential for REPL variable persistence across vm.runInContext calls
// Extract individual identifiers from binding patterns for hoisting
var hoisted_decl_list = ListManaged(G.Decl).init(allocator);
for (local.decls.slice()) |decl| {
try extractIdentifiersFromBinding(p, decl.binding, &hoisted_decl_list);
}
if (hoisted_decl_list.items.len > 0) {
try hoisted_stmts.append(p.s(S.Local{
.kind = .k_var,
.decls = Decl.List.fromOwnedSlice(hoisted_decl_list.items),
}, stmt.loc));
}
// Create assignment expressions for the inner statements
for (local.decls.slice()) |decl| {
if (decl.value) |value| {
// Create assignment expression: binding = value
const assign_expr = createBindingAssignment(p, decl.binding, value, allocator);
try inner_stmts.append(p.s(S.SExpr{ .value = assign_expr }, stmt.loc));
}
}
}
},
@@ -384,6 +415,63 @@ pub fn ReplTransforms(comptime P: type) type {
}
}
/// Get the first identifier ref from a binding pattern
fn getFirstIdentifierRef(binding: Binding) ?Ref {
switch (binding.data) {
.b_identifier => |ident| return ident.ref,
.b_array => |arr| {
for (arr.items) |item| {
if (getFirstIdentifierRef(item.binding)) |ref| return ref;
}
return null;
},
.b_object => |obj| {
for (obj.properties) |prop| {
if (getFirstIdentifierRef(prop.value)) |ref| return ref;
}
return null;
},
.b_missing => return null,
}
}
/// Emit __repl_defineConst("name", name) for each identifier in a binding pattern.
/// This persists const bindings on globalThis using getter/setter so that
/// subsequent REPL evaluations cannot reassign them.
/// The __repl_defineConst helper is initialized during REPL startup (see repl.zig).
fn emitDefinePropertyCalls(p: *P, binding: Binding, inner_stmts: *ListManaged(Stmt), allocator: Allocator, loc: logger.Loc) !void {
switch (binding.data) {
.b_identifier => |ident| {
const name = p.symbols.items[ident.ref.innerIndex()].original_name;
// __repl_defineConst("name", name)
const helper_ref = try p.newSymbol(.unbound, "__repl_defineConst");
const helper = p.newExpr(E.Identifier{ .ref = helper_ref }, loc);
var args = bun.handleOom(allocator.alloc(Expr, 2));
args[0] = p.newExpr(E.String{ .data = name }, loc);
args[1] = p.newExpr(E.Identifier{ .ref = ident.ref }, loc);
const call = p.newExpr(E.Call{
.target = helper,
.args = ExprNodeList.fromOwnedSlice(args),
}, loc);
try inner_stmts.append(p.s(S.SExpr{ .value = call }, loc));
},
.b_array => |arr| {
for (arr.items) |item| {
try emitDefinePropertyCalls(p, item.binding, inner_stmts, allocator, loc);
}
},
.b_object => |obj| {
for (obj.properties) |prop| {
try emitDefinePropertyCalls(p, prop.value, inner_stmts, allocator, loc);
}
},
.b_missing => {},
}
}
/// Create { __proto__: null, value: expr } wrapper object
/// Uses null prototype to create a clean data object
fn wrapExprInValueObject(p: *P, expr: Expr, allocator: Allocator) Expr {
@@ -503,6 +591,7 @@ const Binding = js_ast.Binding;
const E = js_ast.E;
const Expr = js_ast.Expr;
const ExprNodeList = js_ast.ExprNodeList;
const Ref = js_ast.Ref;
const S = js_ast.S;
const Stmt = js_ast.Stmt;

View File

@@ -176,6 +176,9 @@ const ReplRunner = struct {
}
vm.transpiler.env.loadTracy();
// Set up the const variable helper for REPL persistence
this.repl.initConstHelper();
}
};

View File

@@ -708,6 +708,38 @@ pub fn init(allocator: Allocator) Repl {
};
}
/// Initialize the REPL helper for const variable protection.
/// This defines a helper function on globalThis that uses getter/setter
/// to make const variables non-reassignable across REPL evaluations.
/// Must be called after the VM and global object are fully initialized.
pub fn initConstHelper(self: *Repl) void {
const global = self.global orelse return;
const helper_code =
\\Object.defineProperty(globalThis, "__repl_defineConst", {
\\ value: function(name, value) {
\\ Object.defineProperty(globalThis, name, {
\\ get: function() { return value; },
\\ set: function() { throw new TypeError("Assignment to constant variable."); },
\\ configurable: true,
\\ enumerable: true
\\ });
\\ },
\\ writable: false,
\\ configurable: false,
\\ enumerable: false
\\});
;
var exception: jsc.JSValue = .js_undefined;
_ = Bun__REPL__evaluate(
global,
helper_code.ptr,
helper_code.len,
"[repl-init]".ptr,
"[repl-init]".len,
&exception,
);
}
pub fn deinit(self: *Repl) void {
self.restoreTerminal();
self.history.save();

View File

@@ -619,6 +619,55 @@ describe.concurrent("Bun REPL", () => {
expect(exitCode).toBe(0);
});
test("const cannot be reassigned across lines (#27485)", async () => {
const { stdout, stderr, exitCode } = await runRepl(["const a = 1", "a = 2", ".exit"]);
const output = stripAnsi(stdout + stderr);
expect(output).toMatch(/TypeError.*Assignment to constant variable/i);
expect(exitCode).toBe(0);
});
test("const destructured variables cannot be reassigned (#27485)", async () => {
const { stdout, stderr, exitCode } = await runRepl(["const [x, y] = [10, 20]", "x = 99", ".exit"]);
const output = stripAnsi(stdout + stderr);
expect(output).toMatch(/TypeError.*Assignment to constant variable/i);
expect(exitCode).toBe(0);
});
test("const object destructured variables cannot be reassigned (#27485)", async () => {
const { stdout, stderr, exitCode } = await runRepl(["const { a, b } = { a: 1, b: 2 }", "a = 99", ".exit"]);
const output = stripAnsi(stdout + stderr);
expect(output).toMatch(/TypeError.*Assignment to constant variable/i);
expect(exitCode).toBe(0);
});
test("const value is preserved after failed reassignment (#27485)", async () => {
const { stdout, stderr, exitCode } = await runRepl(["const a = 42", "try { a = 0 } catch(e) {}", "a", ".exit"]);
const output = stripAnsi(stdout + stderr);
// The last "a" evaluation should still return 42
// Split output lines and check the last result line before .exit
const lines = output
.split("\n")
.map((l: string) => l.trim())
.filter((l: string) => l === "42");
// Should find 42 at least twice: once from declaration, once from final read
expect(lines.length).toBeGreaterThanOrEqual(2);
expect(exitCode).toBe(0);
});
test("let can still be reassigned across lines", async () => {
const { stdout, exitCode } = await runRepl(["let v = 1", "v = 2", "v", ".exit"]);
const output = stripAnsi(stdout);
expect(output).toContain("2");
expect(exitCode).toBe(0);
});
test("var can still be reassigned across lines", async () => {
const { stdout, exitCode } = await runRepl(["var v = 1", "v = 2", "v", ".exit"]);
const output = stripAnsi(stdout);
expect(output).toContain("2");
expect(exitCode).toBe(0);
});
test("array destructuring persists", async () => {
const { stdout, exitCode } = await runRepl(["const [a, b, c] = [10, 20, 30]", "a + b + c", ".exit"]);
expect(stripAnsi(stdout)).toContain("60");