Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
c0d07d4eda fix: avoid invoking Symbol.toPrimitive when formatting RegExp
When formatting a RegExp for display (console.log, error messages, etc.),
the formatter was calling toString() which invokes Symbol.toPrimitive.
If toPrimitive was set to a class, this would throw "Cannot call a class
constructor without |new|" and cause assertion failures in debug builds.

The fix has two parts:

1. Add a new C++ binding `toRegExpStringNonThrowing` that directly reads
   the RegExp's internal `pattern()` and `flags()` without invoking any
   user code. The ConsoleObject formatter now uses this for RegExp objects.

2. Add a safety net in ZigFormatter that clears pending exceptions when
   converting JSError to WriteFailed. This prevents assertion failures for
   other types (like StringObject) that might have broken toPrimitive.

This matches Node.js behavior where console.log of a RegExp with a
broken toPrimitive still displays the pattern correctly.

Fixes ENG-22787

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:53:16 +00:00
4 changed files with 180 additions and 11 deletions

View File

@@ -1052,13 +1052,25 @@ pub const Formatter = struct {
self.formatter.remaining_values = &[_]JSValue{};
}
self.formatter.format(
Tag.get(self.value, self.formatter.globalThis) catch |e| return bun.deprecated.jsErrorToWriteError(e),
Tag.get(self.value, self.formatter.globalThis) catch |e| return self.jsErrorToWriteError(e),
@TypeOf(writer),
writer,
self.value,
self.formatter.globalThis,
false,
) catch |e| return bun.deprecated.jsErrorToWriteError(e);
) catch |e| return self.jsErrorToWriteError(e);
}
fn jsErrorToWriteError(self: ZigFormatter, e: bun.JSError) std.Io.Writer.Error {
// When converting JSError to WriteFailed, we must clear the pending exception
// from the VM. Otherwise, subsequent code may trigger assertion failures when
// it detects an unhandled exception (e.g., when Symbol.toPrimitive throws).
_ = self.formatter.globalThis.clearExceptionExceptTermination();
return switch (e) {
error.JSTerminated => error.WriteFailed,
error.JSError => error.WriteFailed,
error.OutOfMemory => bun.handleOom(error.OutOfMemory),
};
}
};
@@ -2120,7 +2132,24 @@ pub const Formatter = struct {
try this.writeWithFormatting(Writer, writer_, @TypeOf(slice), slice, this.globalThis, enable_ansi_colors);
},
.String => {
// This is called from the '%s' formatter, so it can actually be any value
// This is called from the '%s' formatter, so it can actually be any value.
// For RegExp, we use a non-throwing method that doesn't invoke Symbol.toPrimitive
// to avoid executing arbitrary user code that could throw.
if (jsType == .RegExpObject) {
const regexp_str = value.toRegExpStringNonThrowing();
defer regexp_str.deref();
this.addForNewLine(regexp_str.length());
if (comptime enable_ansi_colors) {
writer.print(comptime Output.prettyFmt("<r><red>", enable_ansi_colors), .{});
}
writer.print("{f}", .{regexp_str});
if (comptime enable_ansi_colors) {
writer.print(comptime Output.prettyFmt("<r>", enable_ansi_colors), .{});
}
return;
}
const str: bun.String = try bun.String.fromJS(value, this.globalThis);
defer str.deref();
this.addForNewLine(str.length());
@@ -2167,10 +2196,6 @@ pub const Formatter = struct {
return;
}
if (jsType == .RegExpObject and enable_ansi_colors) {
writer.print(comptime Output.prettyFmt("<r><red>", enable_ansi_colors), .{});
}
if (str.isUTF16()) {
// streaming print
writer.print("{f}", .{str});
@@ -2185,10 +2210,6 @@ pub const Formatter = struct {
writer.writeAll(buf);
}
}
if (jsType == .RegExpObject and enable_ansi_colors) {
writer.print(comptime Output.prettyFmt("<r>", enable_ansi_colors), .{});
}
},
.Integer => {
const int = try value.coerce(i64, this.globalThis);

View File

@@ -483,6 +483,15 @@ pub const JSValue = enum(i64) {
return this.jsType() == .JSDate;
}
extern fn JSC__JSValue__toRegExpStringNonThrowing(this: JSValue) bun.String;
/// Get the string representation of a RegExp without invoking Symbol.toPrimitive.
/// Returns the pattern and flags like "/pattern/flags".
/// Returns an empty string if the value is not a RegExp.
pub fn toRegExpStringNonThrowing(this: JSValue) bun.String {
return JSC__JSValue__toRegExpStringNonThrowing(this);
}
extern "c" fn Bun__JSValue__protect(value: JSValue) void;
extern "c" fn Bun__JSValue__unprotect(value: JSValue) void;

View File

@@ -122,6 +122,7 @@
#include "JavaScriptCore/TestRunnerUtils.h"
#include "JavaScriptCore/DateInstance.h"
#include "JavaScriptCore/RegExpObject.h"
#include "JavaScriptCore/YarrFlags.h"
#include "JavaScriptCore/PropertyNameArray.h"
#include "webcore/JSAbortSignal.h"
#include "JSAbortAlgorithm.h"
@@ -6081,3 +6082,28 @@ extern "C" void JSC__ArrayBuffer__asBunArrayBuffer(JSC::ArrayBuffer* self, Bun__
out->cell_type = JSC::JSType::ArrayBufferType;
out->shared = self->isShared();
}
// Get RegExp pattern and flags as a formatted string like "/pattern/flags"
// This does NOT invoke Symbol.toPrimitive, avoiding potential user code execution.
extern "C" BunString JSC__JSValue__toRegExpStringNonThrowing(JSC::EncodedJSValue encodedValue)
{
JSC::JSValue value = JSC::JSValue::decode(encodedValue);
if (!value.isCell() || value.asCell()->type() != JSC::RegExpObjectType) {
return BunStringEmpty;
}
JSC::RegExpObject* regExpObject = JSC::jsCast<JSC::RegExpObject*>(value);
JSC::RegExp* regExp = regExpObject->regExp();
const WTF::String& pattern = regExp->pattern();
auto flagsString = JSC::Yarr::flagsString(regExp->flags());
// Build the string: "/" + pattern + "/" + flags
WTF::StringBuilder builder;
builder.append('/');
builder.append(pattern);
builder.append('/');
builder.append(std::span<const Latin1Character> { reinterpret_cast<const Latin1Character*>(flagsString.data()), strlen(flagsString.data()) });
return Bun::toStringRef(builder.toString());
}

View File

@@ -0,0 +1,113 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Regression test for ENG-22787
// When Symbol.toPrimitive is set to a class on an object, and that object is
// passed to functions that try to format it for an error message, it should
// not crash with an assertion failure and should properly display the value.
test("expect.assertions with RegExp having Symbol.toPrimitive class does not crash", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const abc = /foo.*bar/gi;
abc[Symbol.toPrimitive] = class {};
try {
Bun.jest(abc).expect.assertions(abc);
} catch (e) {
// Should show the regex pattern, not crash or show {f}
console.log("error:", e.message.includes("/foo.*bar/gi"));
}
console.log("done");
`,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("error: true\ndone");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("console.log with RegExp having Symbol.toPrimitive class does not crash", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const abc = /test/gi;
abc[Symbol.toPrimitive] = class {};
console.log(abc);
`,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("/test/gi");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("Bun.inspect with RegExp having Symbol.toPrimitive class does not crash", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const abc = /hello/;
abc[Symbol.toPrimitive] = class {};
console.log(Bun.inspect(abc));
`,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("/hello/");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("expect.assertions with StringObject having Symbol.toPrimitive class does not crash", async () => {
// StringObject with broken toPrimitive should not crash when formatting error messages.
// The error message may show {f} as a fallback, but no assertion failure should occur.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const str = new String("hello");
str[Symbol.toPrimitive] = class {};
try {
Bun.jest(str).expect.assertions(str);
} catch (e) {
// Should not crash - error message may show {f} as fallback
console.log("caught");
}
console.log("done");
`,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("caught\ndone");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});