From 2041756755af91fe4fb1349aa11766d977837c9d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:30:54 +0000 Subject: [PATCH 1/3] node: $toClass installs the prototype of built-in constructors A function in a built-in module has no `prototype` property of its own. Ten constructors made one by assignment (`Fn.prototype = {}`). That property is enumerable and configurable, and the object has no `constructor`. $toClass put `prototype` without DontDelete, so it was configurable on the 44 constructors that used the helper already. A copy of the own property descriptors of such a function onto another function throws (#32160). $toClass now defines `prototype` as writable, not enumerable and not configurable. It takes an existing prototype object as an optional fourth argument and does not write to that object. Its `name` argument is the name of the function: before, JSC replaced it with the name of the declaration on the first lookup, which the bundler can rename (crypto.Cipheriv was "Cipheriv2"). The helper no longer marks a Structure as a prototype in place. That Structure is shared by every empty object with the same [[Prototype]], and JSC marks a prototype at its first use. EventEmitter, url.Url, crypto.Certificate, assert.Assert, console.Console, the ReadableState and WritableState of streams, tls.SecureContext, Bun.$.Shell, the Date of node:test mock timers and the default original of mock.fn() call $toClass. tty.ReadStream calls it when node:tty loads and has no lazy `prototype` accessor any more. node:events assigned `constructor` onto its hand-made prototype. That assignment throws once a program has frozen Object.prototype, so no module that needs node:events loaded after the freeze. The EventEmitter constructor read `this.__proto__`, an accessor that a program can delete, and each stream constructor threw after that. It reads the prototype with $getPrototypeOf now. The browser polyfill of node:events keeps the prototype that its function has, and reads the prototype with Object.getPrototypeOf. `bun lint` rejects `Fn.prototype = ...` and `.__proto__` in src/js. --- bunfig.toml | 8 + src/js/builtins.d.ts | 8 +- src/js/builtins/ConsoleObject.ts | 3 +- src/js/builtins/shell.ts | 3 +- src/js/internal-for-testing.ts | 13 + src/js/internal/streams/readable.ts | 2 +- src/js/internal/streams/writable.ts | 2 +- src/js/internal/test_runner/mock_timers.ts | 2 +- src/js/node/assert.ts | 11 +- src/js/node/crypto.ts | 2 +- src/js/node/events.ts | 8 +- src/js/node/test.ts | 9 +- src/js/node/tls.ts | 2 +- src/js/node/tty.ts | 129 +++++----- src/js/node/url.ts | 5 +- src/jsc/bindings/ZigGlobalObject.cpp | 51 ++-- src/node-fallbacks/events.js | 7 +- test/bundler/bundler_browser.test.ts | 62 +++++ test/internal/to-class.test.ts | 105 ++++++++ test/js/node/events/event-emitter.test.ts | 271 ++++++++++++++++++++- test/js/node/test_runner/node-test.test.ts | 5 + test/js/node/tty.test.ts | 24 ++ 22 files changed, 596 insertions(+), 136 deletions(-) create mode 100644 test/internal/to-class.test.ts diff --git a/bunfig.toml b/bunfig.toml index 4d352159a6f3..ad1d4858719d 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -43,3 +43,11 @@ ignorePatterns = [ # `bun lint` lints src/js. config = "oxlint.json" ignorePatterns = ["/*", "!/src", "/src/*", "!/src/js"] + +[lint.rules] +# A program can delete the `__proto__` accessor of Object.prototype. Use $getPrototypeOf(value). +"no-proto" = "error" +"no-restricted-syntax" = [ + "error", + { selector = "AssignmentExpression[left.type='MemberExpression']:matches([left.property.name='prototype'], [left.property.value='prototype'])", message = "A function in a built-in module has no `prototype` of its own, and an assignment creates an enumerable, configurable one with no `constructor`. Use $toClass(fn, name, base)." }, +] diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 36b78df986ff..251fc820a9a0 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -589,14 +589,15 @@ declare function $ERR_HTTP2_PING_CANCEL(): Error; * * This does: * - Sets the name of the function to the given name - * - Sets .prototype to Object.create(base?.prototype, { constructor: { value: fn } }) + * - Defines .prototype, not enumerable and not configurable: `prototype`, or Object.create(base?.prototype, { constructor: { value: fn } }) * - Calls Object.setPrototypeOf(fn, base ?? Function.prototype) * * @param fn - The function to convert to a class * @param name - The name of the class * @param base - The base class to inherit from + * @param prototype - An existing object to use as fn.prototype. It is not written to. */ -declare function $toClass(fn: Function, name: string, base?: Function | undefined | null); +declare function $toClass(fn: Function, name: string, base?: Function | undefined | null, prototype?: object); declare function $min(a: number, b: number): number; @@ -617,6 +618,9 @@ interface ObjectConstructor { /** gets a property on an object */ declare function $getByIdDirect(obj: T, key: K): T[K]; +/** `Object.getPrototypeOf(value)`, as the `op_get_prototype_of` bytecode */ +declare function $getPrototypeOf(value: unknown): object | null; + /** * Gets a private property on an object. * Translates to the `op_get_by_id_direct` bytecode. diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index 3f84f23d0e73..9db1cb6e8208 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -347,7 +347,8 @@ export function createConsoleConstructor(console: typeof globalThis.console) { const kColorInspectOptions = { colors: true }; const kNoColorInspectOptions = {}; - Object.defineProperties((Console.prototype = {}), { + $toClass(Console, "Console"); + Object.defineProperties(Console.prototype, { [kBindStreamsEager]: { ...consolePropAttributes, // Eager version for the Console constructor diff --git a/src/js/builtins/shell.ts b/src/js/builtins/shell.ts index 7390ab6caac7..a4c0da139865 100644 --- a/src/js/builtins/shell.ts +++ b/src/js/builtins/shell.ts @@ -355,8 +355,7 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa return Shell; } - Shell.prototype = ShellPrototype.prototype; - Object.setPrototypeOf(Shell, ShellPrototype); + $toClass(Shell, "Shell", ShellPrototype, ShellPrototype.prototype); Object.setPrototypeOf(BunShell, ShellPrototype.prototype); BunShell[cwdSymbol] = defaultCwd; diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 75e32fea6bef..ade555c40b60 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -1003,3 +1003,16 @@ export const internalModuleBytecode: { (index: number): { name: string; bytecode: Uint8Array; strings: Uint8Array } | null; (source: string, name: string): { name: string; bytecode: Uint8Array; strings: Uint8Array }; } = $newCppFunction("InternalModuleRegistry.cpp", "jsInternalModuleBytecode", 2); + +// What $toClass installs on a new function of a builtin module. `readNameFirst` reifies the lazy `name` before the call. +export function toClassForTesting( + name: string, + base?: unknown, + prototype?: unknown, + readNameFirst?: boolean, +): Function { + function Target() {} + if (readNameFirst && Target.name !== "Target") throw new Error("unreachable"); + $toClass(Target, name, base as Function, prototype as object); + return Target; +} diff --git a/src/js/internal/streams/readable.ts b/src/js/internal/streams/readable.ts index b99193e02763..30fc865c751e 100644 --- a/src/js/internal/streams/readable.ts +++ b/src/js/internal/streams/readable.ts @@ -128,7 +128,7 @@ function ReadableState(options, stream, isDuplex) { this.encoding = options.encoding; } } -ReadableState.prototype = {}; +$toClass(ReadableState, "ReadableState"); ObjectDefineProperties(ReadableState.prototype, { objectMode: makeBitMapDescriptor(kObjectMode), ended: makeBitMapDescriptor(kEnded), diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index d192409e5c13..124dc1404b6f 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -79,7 +79,7 @@ function makeBitMapDescriptor(bit) { }, }; } -WritableState.prototype = {}; +$toClass(WritableState, "WritableState"); ObjectDefineProperties(WritableState.prototype, { // Object stream flag to indicate whether or not this stream // contains buffers or objects. diff --git a/src/js/internal/test_runner/mock_timers.ts b/src/js/internal/test_runner/mock_timers.ts index 2846b4add0d0..a5fe8325fc4d 100644 --- a/src/js/internal/test_runner/mock_timers.ts +++ b/src/js/internal/test_runner/mock_timers.ts @@ -425,7 +425,7 @@ class MockTimers { }, }); - MockDate.prototype = NativeDateConstructor.prototype; + $toClass(MockDate, "Date", undefined, NativeDateConstructor.prototype); MockDate.parse = NativeDateConstructor.parse; MockDate.UTC = NativeDateConstructor.UTC; MockDate.prototype.toUTCString = NativeDateConstructor.prototype.toUTCString; diff --git a/src/js/node/assert.ts b/src/js/node/assert.ts index 600681e709fe..f0b139b0acf9 100644 --- a/src/js/node/assert.ts +++ b/src/js/node/assert.ts @@ -120,16 +120,7 @@ function Assert(options) { } } -// Functions compiled as builtins have no automatic `.prototype`; assign one -// explicitly (same pattern as EventEmitter in node/events.ts). -Assert.prototype = {}; -ObjectDefineProperty(Assert.prototype, "constructor", { - __proto__: null, - value: Assert, - writable: true, - enumerable: false, - configurable: true, -}); +$toClass(Assert, "Assert"); // All of the following functions must throw an AssertionError // when a corresponding condition is not met, with a message that diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 4f589e56a4ea..eecf49c0413d 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -103,7 +103,7 @@ function Certificate(): void { this.exportPublicKey = exportPublicKey; this.exportChallenge = exportChallenge; } -Certificate.prototype = {}; +$toClass(Certificate, "Certificate"); Certificate.verifySpkac = verifySpkac; Certificate.exportPublicKey = exportPublicKey; Certificate.exportChallenge = exportChallenge; diff --git a/src/js/node/events.ts b/src/js/node/events.ts index 49921b283fd1..4c138252e95b 100644 --- a/src/js/node/events.ts +++ b/src/js/node/events.ts @@ -102,7 +102,7 @@ declare class EventEmitter { // EventEmitter must be a standard function because some old code will do weird tricks like `EventEmitter.$apply(this)`. function EventEmitter(opts) { - if (this._events === undefined || this._events === this.__proto__._events) { + if (this._events === undefined || this._events === ($getPrototypeOf(this) as EventEmitter)._events) { this._events = Object.create(null); this._eventsCount = 0; this[kShapeMode] = false; @@ -127,8 +127,8 @@ function EventEmitter(opts) { } } } -Object.defineProperty(EventEmitter, "name", { value: "EventEmitter", configurable: true }); -const EventEmitterPrototype = (EventEmitter.prototype = {} as EventEmitter); +$toClass(EventEmitter, "EventEmitter"); +const EventEmitterPrototype = EventEmitter.prototype; EventEmitterPrototype.setMaxListeners = function setMaxListeners(n) { validateNumber(n, "setMaxListeners", 0); @@ -137,8 +137,6 @@ EventEmitterPrototype.setMaxListeners = function setMaxListeners(n) { }; Object.defineProperty(EventEmitterPrototype.setMaxListeners, "name", { value: "setMaxListeners" }); -EventEmitterPrototype.constructor = EventEmitter; - EventEmitterPrototype.getMaxListeners = function getMaxListeners() { return _getMaxListeners(this); }; diff --git a/src/js/node/test.ts b/src/js/node/test.ts index f99f5b0b3f1b..67159227f374 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -975,14 +975,7 @@ function validateStringOrSymbol(value: unknown, name: string) { // default original is a plain `function () {}`, so give it one explicitly. function createDefaultOriginal(): Function { const original = function () {}; - Object.defineProperty(original, "prototype", { - // @ts-ignore - __proto__: null, - value: {}, - writable: true, - enumerable: false, - configurable: false, - }); + $toClass(original, "original"); return original; } diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 8d08f24e8d1b..99d9d1e3f101 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -575,7 +575,7 @@ var InternalSecureContext = class SecureContext { function SecureContext(options): void { return createSecureContext(options) as never; } -SecureContext.prototype = InternalSecureContext.prototype; +$toClass(SecureContext, "SecureContext", undefined, InternalSecureContext.prototype); InternalSecureContext.prototype.constructor = SecureContext; function createSecureContext(options) { diff --git a/src/js/node/tty.ts b/src/js/node/tty.ts index cad1915116f7..c5f042b4d423 100644 --- a/src/js/node/tty.ts +++ b/src/js/node/tty.ts @@ -28,87 +28,68 @@ function ReadStream(fd): void { this.isTTY = isatty(fd); } $toClass(ReadStream, "ReadStream", fs.ReadStream); - -Object.defineProperty(ReadStream, "prototype", { - get() { - const Prototype = Object.create(fs.ReadStream.prototype); - - // Add ref/unref methods to make tty.ReadStream behave like Node.js - // where TTY streams have socket-like behavior - Prototype.ref = function () { - // Get the underlying native stream source if available - const source = this.$bunNativePtr; - if (source?.updateRef) { - source.updateRef(true); +const ReadStreamPrototype = ReadStream.prototype; + +// In Node.js a TTY stream has ref() and unref(), like a socket. +ReadStreamPrototype.ref = function () { + // Get the underlying native stream source if available + const source = this.$bunNativePtr; + if (source?.updateRef) { + source.updateRef(true); + } + return this; +}; + +ReadStreamPrototype.unref = function () { + // Get the underlying native stream source if available + const source = this.$bunNativePtr; + if (source?.updateRef) { + source.updateRef(false); + } + return this; +}; + +ReadStreamPrototype.setRawMode = function (flag) { + flag = !!flag; + + // Windows calls uv_tty_set_mode on the stream's uv_tty_t. POSIX uses termios on the fd: the handle was not reliable there. + if (process.platform === "win32") { + // stdin has a shared uv_tty handle, and its stream is constructed differently. + if (this.fd === 0) { + const err = ttySetMode(flag); + if (err) { + this.emit("error", new Error("setRawMode failed with errno: " + err)); + return this; } - return this; - }; - - Prototype.unref = function () { - // Get the underlying native stream source if available - const source = this.$bunNativePtr; - if (source?.updateRef) { - source.updateRef(false); + } else { + const handle = this.$bunNativePtr; + if (!handle) { + this.emit("error", new Error("setRawMode failed because it was called on something that is not a TTY")); + return this; } - return this; - }; - Prototype.setRawMode = function (flag) { - flag = !!flag; - - // On windows, this goes through the stream handle itself, as it must call - // uv_tty_set_mode on the uv_tty_t. - // - // On POSIX, I tried to use the same approach, but it didn't work reliably, - // so we just use the file descriptor and use termios APIs directly. - if (process.platform === "win32") { - // Special case for stdin, as it has a shared uv_tty handle - // and it's stream is constructed differently - if (this.fd === 0) { - const err = ttySetMode(flag); - if (err) { - this.emit("error", new Error("setRawMode failed with errno: " + err)); - return this; - } - } else { - const handle = this.$bunNativePtr; - if (!handle) { - this.emit("error", new Error("setRawMode failed because it was called on something that is not a TTY")); - return this; - } - - // If you call setRawMode before you call on('data'), the stream will - // not be constructed, leading to EBADF - // This corresponds to the `ensureConstructed` function in `native-readable.ts` - this.$start(); - - const err = handle.setRawMode(flag); - if (err) { - this.emit("error", err); - return this; - } - } - } else { - const state = (this[kRawModeState] ??= new Uint8Array(rawModeStateSize)); - const err = ttySetMode(this.fd, flag, state); - if (err) { - this.emit("error", new Error("setRawMode failed with errno: " + err)); - return this; - } - } - - this.isRaw = flag; + // setRawMode before on('data') finds the stream not constructed (EBADF). See ensureConstructed in native-readable.ts. + this.$start(); + const err = handle.setRawMode(flag); + if (err) { + this.emit("error", err); + return this; + } + } + } else { + const state = (this[kRawModeState] ??= new Uint8Array(rawModeStateSize)); + const err = ttySetMode(this.fd, flag, state); + if (err) { + this.emit("error", new Error("setRawMode failed with errno: " + err)); return this; - }; + } + } - Object.defineProperty(ReadStream, "prototype", { value: Prototype }); + this.isRaw = flag; - return Prototype; - }, - enumerable: true, - configurable: true, -}); + return this; +}; function WriteStream(fd): void { if (!(this instanceof WriteStream)) return new WriteStream(fd); diff --git a/src/js/node/url.ts b/src/js/node/url.ts index eae623ea73cb..407a2e4d6c8d 100644 --- a/src/js/node/url.ts +++ b/src/js/node/url.ts @@ -64,7 +64,10 @@ function Url() { this.path = null; this.href = null; } -Url.prototype = {} as Url; +declare namespace Url { + export let prototype: Url; +} +$toClass(Url, "Url"); // Reference: RFC 3986, RFC 1808, RFC 2396 diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 7f4ae0c3aacc..4b83834983aa 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2881,39 +2881,46 @@ BUN_DEFINE_LAZY_GLOBAL_BUILTIN_GETTER(getInternalRequireBuiltin, commonJSInterna JSC_DEFINE_HOST_FUNCTION(jsFunctionToClass, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - // Mimick the behavior of class Foo {} for a regular JSFunction. + // Gives a function in a builtin the `prototype` and `name` that JSC creates for a function declaration outside one. auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto target = callFrame->argument(0).toObject(globalObject); RETURN_IF_EXCEPTION(scope, encodedJSValue()); auto name = callFrame->argument(1); JSObject* base = callFrame->argument(2).getObject(); - JSObject* prototypeBase = nullptr; - - if (!base) { - base = globalObject->functionPrototype(); - } else { - auto proto = base->getIfPropertyExists(globalObject, vm.propertyNames->prototype); - RETURN_IF_EXCEPTION(scope, encodedJSValue()); - if (proto) { - if (auto protoObject = proto.getObject()) { - prototypeBase = protoObject; + JSObject* prototype = callFrame->argument(3).getObject(); + ASSERT(!target->getDirect(vm, vm.propertyNames->prototype)); + + if (!prototype) { + JSObject* prototypeBase = globalObject->objectPrototype(); + if (base) { + auto proto = base->getIfPropertyExists(globalObject, vm.propertyNames->prototype); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + if (!proto) { + JSC::throwTypeError(globalObject, scope, "Base class must have a prototype property"_s); + return encodedJSValue(); } - } else { - JSC::throwTypeError(globalObject, scope, "Base class must have a prototype property"_s); - return encodedJSValue(); + if (auto protoObject = proto.getObject()) + prototypeBase = protoObject; } + prototype = JSC::constructEmptyObject(globalObject, prototypeBase); + prototype->putDirect(vm, vm.propertyNames->constructor, target, PropertyAttribute::DontEnum | 0); } - JSObject* prototype = prototypeBase ? JSC::constructEmptyObject(globalObject, prototypeBase) : JSC::constructEmptyObject(globalObject); - RETURN_IF_EXCEPTION(scope, encodedJSValue()); - - prototype->structure()->setMayBePrototype(true); - prototype->putDirect(vm, vm.propertyNames->constructor, target, PropertyAttribute::DontEnum | 0); + // JSC takes a builtin function's `name` from its declaration on the first lookup, over a direct put, and the bundler can rename the declaration. + if (auto* function = dynamicDowncast(target); function && !function->isHostFunction()) { + auto nameString = name.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + if (function->jsExecutable()->ecmaName().string() != nameString) { + FunctionRareData* rareData = function->ensureRareData(vm); + rareData->setHasReifiedName(); + rareData->setHasModifiedNameForBoundOrNonHostFunction(); + } + } - target->setPrototypeDirect(vm, base); - target->putDirect(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | 0); - target->putDirect(vm, vm.propertyNames->name, name, PropertyAttribute::DontEnum | 0); + target->setPrototypeDirect(vm, base ? base : globalObject->functionPrototype()); + target->putDirect(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete); + target->putDirect(vm, vm.propertyNames->name, name, PropertyAttribute::DontEnum | PropertyAttribute::ReadOnly); return JSValue::encode(jsUndefined()); } diff --git a/src/node-fallbacks/events.js b/src/node-fallbacks/events.js index 60a19d471083..4a54f06a1a31 100644 --- a/src/node-fallbacks/events.js +++ b/src/node-fallbacks/events.js @@ -15,12 +15,13 @@ const kMaxEventTargetListenersWarned = Symbol("events.maxEventTargetListenersWar const kRejection = SymbolFor("nodejs.rejection"); const captureRejectionSymbol = SymbolFor("nodejs.rejection"); const ArrayPrototypeSlice = Array.prototype.slice; +const ObjectGetPrototypeOf = Object.getPrototypeOf; var defaultMaxListeners = 10; // EventEmitter must be a standard function because some old code will do weird tricks like `EventEmitter.apply(this)`. const EventEmitter = function EventEmitter(opts) { - if (this._events === undefined || this._events === this.__proto__._events) { + if (this._events === undefined || this._events === ObjectGetPrototypeOf(this)._events) { this._events = { __proto__: null }; this._eventsCount = 0; } @@ -30,7 +31,7 @@ const EventEmitter = function EventEmitter(opts) { this.emit = emitWithRejectionCapture; } }; -const EventEmitterPrototype = (EventEmitter.prototype = {}); +const EventEmitterPrototype = EventEmitter.prototype; EventEmitterPrototype._events = undefined; EventEmitterPrototype._eventsCount = 0; @@ -41,8 +42,6 @@ EventEmitterPrototype.setMaxListeners = function setMaxListeners(n) { return this; }; -EventEmitterPrototype.constructor = EventEmitter; - EventEmitterPrototype.getMaxListeners = function getMaxListeners() { return this?._maxListeners ?? defaultMaxListeners; }; diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 92c277d551c6..65a686454a88 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -166,6 +166,68 @@ describe("bundler", () => { assert(!out.includes("$newPromiseCapability"), "events polyfill must not reference a JSC builtin intrinsic"); }, }); + // A program that guards against prototype pollution changes Object.prototype before + // anything else is evaluated. The polyfill loads and works after that. + itBundled("browser/NodeEventsAfterObjectPrototypeIsFrozen", { + files: { + "/entry.js": /* js */ ` + import "./harden.js"; + import { EventEmitter } from "node:events"; + const emitter = new EventEmitter(); + emitter.on("greeting", value => console.log(value)); + emitter.emit("greeting", "hello"); + const { value, enumerable } = Object.getOwnPropertyDescriptor(EventEmitter.prototype, "constructor"); + console.log(value === EventEmitter, enumerable); + `, + "/harden.js": /* js */ ` + Object.freeze(Object.prototype); + `, + }, + target: "browser", + run: { + stdout: "hello\ntrue false", + }, + }); + itBundled("browser/NodeEventsPrototypeHasTheShapeOfNode", { + files: { + "/entry.js": /* js */ ` + import { EventEmitter } from "node:events"; + const keys = []; + for (const key in new EventEmitter()) keys.push(key); + function Mixed() {} + Object.assign(Mixed.prototype, EventEmitter.prototype); + console.log( + new EventEmitter().constructor === EventEmitter, + keys.includes("constructor"), + new Mixed().constructor === Mixed, + ); + `, + }, + target: "browser", + run: { + stdout: "true false true", + }, + }); + itBundled("browser/NodeEventsWithoutProtoAccessor", { + files: { + "/entry.js": /* js */ ` + import { EventEmitter } from "node:events"; + delete Object.prototype.__proto__; + // The constructor reads the prototype only when its receiver has _events already. + function Child() { + EventEmitter.call(this); + } + Child.prototype = new EventEmitter(); + const child = new Child(); + child.on("x", () => {}); + console.log(child.listenerCount("x"), Child.prototype.listenerCount("x")); + `, + }, + target: "browser", + run: { + stdout: "1 0", + }, + }); itBundled("browser/NodeUrlProtocolTablesIgnorePrototype", { files: { "/entry.js": /* js */ ` diff --git a/test/internal/to-class.test.ts b/test/internal/to-class.test.ts new file mode 100644 index 000000000000..55b9fb17ecec --- /dev/null +++ b/test/internal/to-class.test.ts @@ -0,0 +1,105 @@ +import { toClassForTesting } from "bun:internal-for-testing"; +import { describe, expect, test } from "bun:test"; + +// $toClass(fn, name, base?, prototype?) gives a function of a built-in module its `prototype`. +// toClassForTesting calls it on a new function that is declared as `function Target() {}`. + +const descriptor = Object.getOwnPropertyDescriptor; +const prototypeAttributes = { writable: true, enumerable: false, configurable: false }; +const constructorAttributes = { writable: true, enumerable: false, configurable: true }; +const nameAttributes = { writable: false, enumerable: false, configurable: true }; + +class Base {} +class Other {} + +describe("$toClass creates the prototype", () => { + test.each([ + ["no base", undefined, () => Function.prototype, () => Object.prototype], + ["a class as the base", Base, () => Base, () => Base.prototype], + ["null as the base", null, () => Function.prototype, () => Object.prototype], + ["a number as the base", 5, () => Function.prototype, () => Object.prototype], + ] as const)("%s", (_label, base, parent, prototypeParent) => { + const Target = toClassForTesting("Target", base); + expect(descriptor(Target, "prototype")).toEqual({ value: Target.prototype, ...prototypeAttributes }); + expect(Object.getPrototypeOf(Target.prototype)).toBe(prototypeParent()); + expect(Reflect.ownKeys(Target.prototype)).toEqual(["constructor"]); + expect(descriptor(Target.prototype, "constructor")).toEqual({ value: Target, ...constructorAttributes }); + expect(Object.getPrototypeOf(Target)).toBe(parent()); + expect(descriptor(Target, "name")).toEqual({ value: "Target", ...nameAttributes }); + }); + + test("a base whose `prototype` is not an object: the new prototype inherits from Object.prototype", () => { + function base() {} + base.prototype = null as any; + const Target = toClassForTesting("Target", base); + expect(Object.getPrototypeOf(Target.prototype)).toBe(Object.prototype); + expect(Object.getPrototypeOf(Target)).toBe(base); + }); + + test("a base with no `prototype` property is a TypeError", () => { + expect(() => toClassForTesting("Target", () => {})).toThrow( + new TypeError("Base class must have a prototype property"), + ); + }); + + test("an exception from the `prototype` getter of the base propagates", () => { + const error = new RangeError("from the getter"); + const base = { + get prototype() { + throw error; + }, + }; + expect(() => toClassForTesting("Target", base)).toThrow(error); + }); + + test("a fourth argument that is not an object is ignored", () => { + const Target = toClassForTesting("Target", Base, 5); + expect(Object.getPrototypeOf(Target.prototype)).toBe(Base.prototype); + expect(descriptor(Target.prototype, "constructor")).toEqual({ value: Target, ...constructorAttributes }); + }); +}); + +describe("$toClass installs an existing prototype", () => { + test("the object keeps its `constructor`", () => { + const Target = toClassForTesting("Target", undefined, Other.prototype); + expect(descriptor(Target, "prototype")).toEqual({ value: Other.prototype, ...prototypeAttributes }); + expect(descriptor(Other.prototype, "constructor")).toEqual({ value: Other, ...constructorAttributes }); + expect(Object.getPrototypeOf(Target)).toBe(Function.prototype); + }); + + test("an object with no `constructor` of its own is not written to", () => { + const existing = Object.create(Other.prototype); + const Target = toClassForTesting("Target", undefined, existing); + expect(Target.prototype).toBe(existing); + expect(Reflect.ownKeys(existing)).toEqual([]); + }); + + test("the base gives the parent of the function only", () => { + const Target = toClassForTesting("Target", Base, Other.prototype); + expect(Target.prototype).toBe(Other.prototype); + expect(Object.getPrototypeOf(Target)).toBe(Base); + expect(Object.getPrototypeOf(Other.prototype)).toBe(Object.prototype); + }); +}); + +describe("$toClass sets the name", () => { + // The bundler can rename a function declaration, and JSC takes the `name` of a function in a + // built-in module from its declaration on the first lookup. + test.each([ + ["before a lookup of `name`", false], + ["after a lookup of `name`", true], + ])("the argument is the name: %s", (_label, readNameFirst) => { + const Target = toClassForTesting("Renamed", undefined, undefined, readNameFirst); + expect(descriptor(Target, "name")).toEqual({ value: "Renamed", ...nameAttributes }); + expect(Target.bind(null).name).toBe("bound Renamed"); + }); + + test("one call site reads the same name from two functions each time", () => { + const first = toClassForTesting("First"); + const second = toClassForTesting("Second"); + const nameOf = (fn: Function) => fn.name; + const names = new Set(); + for (let i = 0; i < 20_000; i++) names.add(nameOf(i & 1 ? second : first)); + expect([...names].sort()).toEqual(["First", "Second"]); + }); +}); diff --git a/test/js/node/events/event-emitter.test.ts b/test/js/node/events/event-emitter.test.ts index 21777281472d..ae6b3cadba96 100644 --- a/test/js/node/events/event-emitter.test.ts +++ b/test/js/node/events/event-emitter.test.ts @@ -1,7 +1,22 @@ import { sleep } from "bun"; import { describe, expect, mock, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; -import { createRequire } from "module"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { builtinModules, createRequire } from "module"; +import assert from "node:assert"; +import cluster from "node:cluster"; +import { Console } from "node:console"; +import crypto from "node:crypto"; +import dgram from "node:dgram"; +import fs from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import net from "node:net"; +import readline from "node:readline"; +import stream from "node:stream"; +import tls from "node:tls"; +import tty from "node:tty"; +import url from "node:url"; +import zlib from "node:zlib"; // this is also testing that imports with default and named imports in the same statement work // our transpiler transform changes this to a var with import.meta.require @@ -12,6 +27,7 @@ import EventEmitter, { listenerCount, setMaxListeners, } from "node:events"; +import * as eventsNamespace from "node:events"; describe("node:events", () => { test("captureRejectionSymbol", () => { @@ -1206,3 +1222,254 @@ describe("native EventEmitter propagates an exception from a `_events` getter", expect(fired).toBe(1); }); }); + +// A program that guards against prototype pollution changes Object.prototype before it loads +// anything else. The built-in modules load and work after that, as they do in Node.js. +describe("Object.prototype is hardened before node:events loads", () => { + async function run(args: string[], cwd?: string) { + await using proc = Bun.spawn({ cmd: [bunExe(), ...args], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + } + + // No variable in these scripts has the name of a built-in module: `bun -e` imports a module + // that the script names that way before the first statement runs. + const freeze = `Object.freeze(Object.prototype);`; + const useStream = ` + const through = new PassThrough(); + through.on("data", chunk => console.log(String(chunk))); + through.end("hello"); + `; + + test.concurrent.each([ + ["require()", `const { PassThrough } = require("node:stream"); ${useStream}`], + ["import()", `const { PassThrough } = await import("node:stream"); ${useStream}`], + ["process.getBuiltinModule()", `const { PassThrough } = process.getBuiltinModule("node:stream"); ${useStream}`], + [ + "createRequire()", + `const { PassThrough } = require("node:module").createRequire(process.execPath)("node:stream"); ${useStream}`, + ], + ["process.stdout with no import", `process.stdout.write("hello\\n");`], + ])("Object.freeze(Object.prototype), then %s", async (_wayIn, script) => { + expect(await run(["-e", `${freeze} ${script}`])).toEqual({ stdout: "hello", stderr: "", exitCode: 0 }); + }); + + test.concurrent("Object.freeze(Object.prototype) in a --preload file, then a static import", async () => { + using dir = tempDir("events-frozen-object-prototype", { + "harden.mjs": freeze, + "app.mjs": `import { PassThrough } from "node:stream"; ${useStream}`, + }); + expect(await run(["--preload", "./harden.mjs", "app.mjs"], String(dir))).toEqual({ + stdout: "hello", + stderr: "", + exitCode: 0, + }); + }); + + // stderr is not compared: three of the modules print a deprecation warning. The timeout is for + // a debug build, which needs some seconds to load every module. + test.concurrent( + "Object.freeze(Object.prototype), then each built-in module loads", + async () => { + const { stdout, exitCode } = await run([ + "-e", + `${freeze} + const failed = []; + for (const name of require("node:module").builtinModules) { + try { + require(name); + } catch (error) { + failed.push(name + ": " + error); + } + } + console.log(JSON.stringify(failed));`, + ]); + expect({ failed: JSON.parse(stdout), exitCode }).toEqual({ failed: [], exitCode: 0 }); + }, + 30_000, + ); + + // `node --disable-proto=delete` does this. Every stream gives the EventEmitter constructor a + // `this` that has `_events` already, and only then does the constructor read the prototype. + const deleteAccessor = `delete Object.prototype.__proto__;`; + const load = ` + const EventEmitter = require("node:events"); + const { PassThrough } = require("node:stream"); + const { Socket } = require("node:net"); + const { createReadStream } = require("node:fs"); + `; + const useEmitters = ` + function Child() { + EventEmitter.call(this); + } + Child.prototype = new EventEmitter(); + const child = new Child(); + child.on("x", () => {}); + + const emitter = new EventEmitter(); + emitter.on("x", () => {}); + EventEmitter.call(emitter); + + new PassThrough().destroy(); + new Socket().destroy(); + createReadStream(process.execPath).destroy(); + process.stdout.write( + JSON.stringify({ + // An emitter whose prototype carries _events gets its own. + child: child.listenerCount("x"), + childPrototype: Child.prototype.listenerCount("x"), + // A second call of the constructor keeps the listeners. + emitter: emitter.listenerCount("x"), + }) + "\\n", + ); + `; + + test.concurrent.each([ + ["before the modules load", `${deleteAccessor} ${load} ${useEmitters}`], + ["after the modules load", `${load} ${deleteAccessor} ${useEmitters}`], + ])("delete Object.prototype.__proto__ %s", async (_when, script) => { + expect(await run(["-e", script])).toEqual({ + stdout: JSON.stringify({ child: 1, childPrototype: 0, emitter: 1 }), + stderr: "", + exitCode: 0, + }); + }); +}); + +// https://github.com/oven-sh/bun/issues/32160 +describe("built-in constructors have the own properties of a function declaration", () => { + test("EventEmitter.prototype.constructor is not enumerable", () => { + expect(Reflect.ownKeys(EventEmitter.prototype)[0]).toBe("constructor"); + const keys: string[] = []; + for (const key in new EventEmitter()) keys.push(key); + expect(keys).not.toContain("constructor"); + }); + + test("a constructor that mixes in EventEmitter.prototype keeps its own `constructor`", () => { + function Mixed() {} + Object.assign(Mixed.prototype, EventEmitter.prototype); + expect(new Mixed().constructor).toBe(Mixed); + }); + + test("the node:events module namespace has no `prototype` export", () => { + expect(Object.keys(eventsNamespace)).not.toContain("prototype"); + }); + + // Each of these is a function in a built-in module. The label ends with the function's name. + const constructors: [label: string, constructor: Function][] = [ + ["events.EventEmitter", EventEmitter], + ["stream.Stream", stream.Stream], + ["stream.Readable", stream.Readable], + ["stream.Writable", stream.Writable], + ["stream.Duplex", stream.Duplex], + ["stream.Transform", stream.Transform], + ["stream.PassThrough", stream.PassThrough], + ["stream.ReadableState", new stream.Readable()._readableState.constructor], + ["stream.WritableState", new stream.Writable()._writableState.constructor], + ["fs.ReadStream", fs.ReadStream], + ["fs.WriteStream", fs.WriteStream], + ["net.Socket", net.Socket], + ["net.Server", net.Server], + ["tls.TLSSocket", tls.TLSSocket], + ["tls.Server", tls.Server], + ["tls.SecureContext", tls.SecureContext], + ["http.Agent", http.Agent], + ["http.Server", http.Server], + ["http.IncomingMessage", http.IncomingMessage], + ["http.OutgoingMessage", http.OutgoingMessage], + ["http.ServerResponse", http.ServerResponse], + ["http.ClientRequest", http.ClientRequest], + ["https.Agent", https.Agent], + ["https.Server", https.Server], + ["zlib.Gzip", zlib.Gzip], + ["zlib.BrotliCompress", zlib.BrotliCompress], + ["crypto.Sign", crypto.Sign], + ["crypto.Verify", crypto.Verify], + ["crypto.Cipheriv", crypto.Cipheriv], + ["crypto.Decipheriv", crypto.Decipheriv], + ["crypto.Certificate", crypto.Certificate], + ["dgram.Socket", dgram.Socket], + ["readline.Interface", readline.Interface], + ["cluster.Worker", cluster.Worker], + ["tty.ReadStream", tty.ReadStream], + ["url.Url", url.Url], + ["assert.Assert", assert.Assert], + ["console.Console", Console], + ]; + + test.each(constructors)("%s", (label, constructor) => { + // The copy that #32160 reports: it throws when `prototype` is configurable. + Object.defineProperties(function () {}, Object.getOwnPropertyDescriptors(constructor)); + + const prototype = constructor.prototype; + expect(Object.getOwnPropertyDescriptor(constructor, "prototype")).toEqual({ + value: prototype, + writable: true, + enumerable: false, + configurable: false, + }); + expect(Object.getOwnPropertyDescriptor(constructor, "name")).toEqual({ + value: label.slice(label.lastIndexOf(".") + 1), + writable: false, + enumerable: false, + configurable: true, + }); + expect(Object.getOwnPropertyDescriptor(prototype, "constructor")).toEqual({ + value: constructor, + writable: true, + enumerable: false, + configurable: true, + }); + }); + + // The copy that #32160 reports, for each function that a built-in module exports. It throws when + // `prototype` is configurable or an accessor. tty.WriteStream installs its prototype, which is + // the one of fs.WriteStream, on the first read. + test("only tty.WriteStream cannot be copied onto another function", () => { + const failed: string[] = []; + const seen = new Set(); + function visit(label: string, value: unknown, depth: number) { + if (value === null || (typeof value !== "object" && typeof value !== "function") || seen.has(value)) return; + seen.add(value); + if (typeof value === "function") { + try { + Object.defineProperties(function () {}, Object.getOwnPropertyDescriptors(value)); + } catch { + failed.push(label); + } + } + if (depth === 2) return; + for (const key of Object.getOwnPropertyNames(value)) { + let member: unknown; + try { + member = (value as Record)[key]; + } catch { + continue; + } + visit(`${label}.${key}`, member, depth + 1); + } + } + for (const name of builtinModules) { + let loaded: unknown; + try { + loaded = require(name); + } catch { + continue; + } + visit(name, loaded, 0); + } + expect(failed.sort()).toEqual(["tty.WriteStream"]); + }); + + // Bun.$.Shell shares the prototype object of an internal class, which keeps its `constructor`. + test("Bun.$.Shell", () => { + const { Shell } = Bun.$; + expect(Object.getOwnPropertyDescriptor(Shell, "prototype")).toEqual({ + value: Shell.prototype, + writable: true, + enumerable: false, + configurable: false, + }); + expect(new Shell()).toBeInstanceOf(Shell); + }); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 2a27963203c0..0795a5606fca 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -436,6 +436,11 @@ describe("node:test mock", () => { mock.restoreAll(); expect(fn()).toBe("original"); }); + + test("the prototype of a mock.fn with no original has its own `constructor`", () => { + const { writable, enumerable, configurable } = Object.getOwnPropertyDescriptor(mock.fn().prototype, "constructor")!; + expect({ writable, enumerable, configurable }).toEqual({ writable: true, enumerable: false, configurable: true }); + }); }); describe("node:test mock tracker semantics", () => { diff --git a/test/js/node/tty.test.ts b/test/js/node/tty.test.ts index 74b28d2e09f2..a10905d472ab 100644 --- a/test/js/node/tty.test.ts +++ b/test/js/node/tty.test.ts @@ -193,6 +193,30 @@ describe("ReadStream.prototype.setRawMode", () => { }); }); +describe("ReadStream", () => { + test("a frozen constructor makes streams and stays frozen", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { ReadStream } = require("node:tty"); + Object.freeze(ReadStream); + const input = new ReadStream(0); + console.log(input instanceof ReadStream, input.constructor === ReadStream, Object.isFrozen(ReadStream)); + process.exit(0); + `, + ], + env: bunEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "true true true", stderr: "", exitCode: 0 }); + }); +}); + describe("WriteStream end()", () => { // The parent does not read the child's stdout until the child has called // end(), so most of the 1 MiB sits in the stream's sink at that point. The From 56563f6e0dd77eaa4f750be17c5442e7f04312a0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:10:04 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- test/js/node/events/event-emitter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/events/event-emitter.test.ts b/test/js/node/events/event-emitter.test.ts index ae6b3cadba96..fcde91750868 100644 --- a/test/js/node/events/event-emitter.test.ts +++ b/test/js/node/events/event-emitter.test.ts @@ -20,6 +20,7 @@ import zlib from "node:zlib"; // this is also testing that imports with default and named imports in the same statement work // our transpiler transform changes this to a var with import.meta.require +import * as eventsNamespace from "node:events"; import EventEmitter, { captureRejectionSymbol, getEventListeners, @@ -27,7 +28,6 @@ import EventEmitter, { listenerCount, setMaxListeners, } from "node:events"; -import * as eventsNamespace from "node:events"; describe("node:events", () => { test("captureRejectionSymbol", () => { From b84978679ba362b89a73751ccac1f4adbcc3c3bf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:55:54 +0000 Subject: [PATCH 3/3] tty: WriteStream.prototype gets the attributes of a function declaration's The accessor that installs the prototype of fs.WriteStream on the first read is not enumerable now. The data property that replaces it is writable, not enumerable and not configurable. The test that loads each built-in module after the freeze has three children, and no timeout of its own. The frozen tty.ReadStream test checks the methods of the prototype. --- src/js/node/tty.ts | 10 ++++-- test/js/node/events/event-emitter.test.ts | 24 +++++++------- test/js/node/tty.test.ts | 39 +++++++++++++++++++++-- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/js/node/tty.ts b/src/js/node/tty.ts index c5f042b4d423..3d09b9705822 100644 --- a/src/js/node/tty.ts +++ b/src/js/node/tty.ts @@ -113,7 +113,13 @@ function WriteStream(fd): void { Object.defineProperty(WriteStream, "prototype", { get() { const Real = fs.WriteStream.prototype; - Object.defineProperty(WriteStream, "prototype", { value: Real }); + // $toClass takes a function that has no `prototype` yet, and this one has the accessor. + Object.defineProperty(WriteStream, "prototype", { + value: Real, + writable: true, + enumerable: false, + configurable: false, + }); WriteStream.prototype._refreshSize = function () { const oldCols = this.columns; @@ -178,7 +184,7 @@ Object.defineProperty(WriteStream, "prototype", { return Real; }, - enumerable: true, + enumerable: false, configurable: true, }); diff --git a/test/js/node/events/event-emitter.test.ts b/test/js/node/events/event-emitter.test.ts index fcde91750868..2c9a390e3a01 100644 --- a/test/js/node/events/event-emitter.test.ts +++ b/test/js/node/events/event-emitter.test.ts @@ -1266,16 +1266,18 @@ describe("Object.prototype is hardened before node:events loads", () => { }); }); - // stderr is not compared: three of the modules print a deprecation warning. The timeout is for - // a debug build, which needs some seconds to load every module. - test.concurrent( - "Object.freeze(Object.prototype), then each built-in module loads", - async () => { + // Three children share the list of modules. stderr is not compared: three of the modules print a + // deprecation warning. + test.concurrent.each([1, 2, 3])( + "Object.freeze(Object.prototype), then each built-in module loads (part %d of 3)", + async part => { + const size = Math.ceil(builtinModules.length / 3); + const names = builtinModules.slice((part - 1) * size, part * size); const { stdout, exitCode } = await run([ "-e", `${freeze} const failed = []; - for (const name of require("node:module").builtinModules) { + for (const name of ${JSON.stringify(names)}) { try { require(name); } catch (error) { @@ -1286,7 +1288,6 @@ describe("Object.prototype is hardened before node:events loads", () => { ]); expect({ failed: JSON.parse(stdout), exitCode }).toEqual({ failed: [], exitCode: 0 }); }, - 30_000, ); // `node --disable-proto=delete` does this. Every stream gives the EventEmitter constructor a @@ -1423,9 +1424,10 @@ describe("built-in constructors have the own properties of a function declaratio }); // The copy that #32160 reports, for each function that a built-in module exports. It throws when - // `prototype` is configurable or an accessor. tty.WriteStream installs its prototype, which is - // the one of fs.WriteStream, on the first read. - test("only tty.WriteStream cannot be copied onto another function", () => { + // `prototype` is configurable or an accessor. tty.WriteStream has an accessor until the first + // read of `prototype`. + test("each function of a built-in module can be copied onto another function", () => { + expect(tty.WriteStream.prototype).toBeObject(); const failed: string[] = []; const seen = new Set(); function visit(label: string, value: unknown, depth: number) { @@ -1458,7 +1460,7 @@ describe("built-in constructors have the own properties of a function declaratio } visit(name, loaded, 0); } - expect(failed.sort()).toEqual(["tty.WriteStream"]); + expect(failed.sort()).toEqual([]); }); // Bun.$.Shell shares the prototype object of an internal class, which keeps its `constructor`. diff --git a/test/js/node/tty.test.ts b/test/js/node/tty.test.ts index a10905d472ab..30ada639c304 100644 --- a/test/js/node/tty.test.ts +++ b/test/js/node/tty.test.ts @@ -203,7 +203,15 @@ describe("ReadStream", () => { const { ReadStream } = require("node:tty"); Object.freeze(ReadStream); const input = new ReadStream(0); - console.log(input instanceof ReadStream, input.constructor === ReadStream, Object.isFrozen(ReadStream)); + console.log( + JSON.stringify({ + instance: input instanceof ReadStream, + constructor: input.constructor === ReadStream, + prototype: Object.getPrototypeOf(input) === ReadStream.prototype, + methods: [typeof input.setRawMode, typeof input.ref, typeof input.unref], + frozen: Object.isFrozen(ReadStream), + }), + ); process.exit(0); `, ], @@ -213,7 +221,34 @@ describe("ReadStream", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "true true true", stderr: "", exitCode: 0 }); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ + instance: true, + constructor: true, + prototype: true, + methods: ["function", "function", "function"], + frozen: true, + }), + stderr: "", + exitCode: 0, + }); + }); +}); + +describe("WriteStream", () => { + // The prototype is the one of fs.WriteStream, and the first read of `prototype` installs it. + test("`prototype` has the attributes that a function declaration gives it", () => { + const prototype = WriteStream.prototype; + expect({ + keys: Object.keys(WriteStream), + descriptor: Object.getOwnPropertyDescriptor(WriteStream, "prototype"), + }).toEqual({ + keys: [], + descriptor: { value: prototype, writable: true, enumerable: false, configurable: false }, + }); + // The copy from https://github.com/oven-sh/bun/issues/32160 + const copy = Object.defineProperties(function () {}, Object.getOwnPropertyDescriptors(WriteStream)); + expect(copy.prototype).toBe(prototype); }); });