From 44e4978225eda2875ddd531b7c6f682c9211c5c7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:09:10 +0000 Subject: [PATCH 1/3] node:buffer: match Node's input handling in isUtf8() and isAscii() Both functions downcast to JSArrayBufferView, which a DataView also is, so a DataView was scanned instead of rejected, and the rejection used a hand-written message instead of Node's ERR_INVALID_ARG_TYPE text. The two functions also disagreed on a view over a detached buffer (isUtf8 threw a plain TypeError, isAscii ERR_INVALID_STATE) where Node returns true, and the detached ArrayBuffer message carried an "Invalid state: " prefix Node's C++-thrown error does not have. Route both through one helper that accepts a TypedArray (isTypedArrayType excludes DataView), an ArrayBuffer or a SharedArrayBuffer, throws ERR_INVALID_STATE with Node's message for a detached ArrayBuffer, and ERR_INVALID_ARG_TYPE through INVALID_ARG_INSTANCE for everything else. --- src/jsc/modules/NodeBufferModule.h | 131 ++++++++--------------------- test/js/node/buffer.test.js | 86 +++++++++++++++++++ 2 files changed, 122 insertions(+), 95 deletions(-) diff --git a/src/jsc/modules/NodeBufferModule.h b/src/jsc/modules/NodeBufferModule.h index fcf346bcdfe7..81e8ff637891 100644 --- a/src/jsc/modules/NodeBufferModule.h +++ b/src/jsc/modules/NodeBufferModule.h @@ -14,113 +14,54 @@ namespace Zig { using namespace WebCore; using namespace JSC; -// TODO: Add DOMJIT fast path -JSC_DEFINE_HOST_FUNCTION(jsBufferConstructorFunction_isUtf8, - (JSC::JSGlobalObject * lexicalGlobalObject, - JSC::CallFrame* callframe)) +// Shared by buffer.isUtf8() and buffer.isAscii(). Node accepts a TypedArray (so not a +// DataView), an ArrayBuffer or a SharedArrayBuffer and throws ERR_INVALID_ARG_TYPE for +// anything else; only a detached ArrayBuffer is ERR_INVALID_STATE, a view whose buffer +// was detached has a byteLength of 0 and validates like any other empty input. +// https://github.com/nodejs/node/blob/v26.3.0/lib/buffer.js#L1415-L1429 +// https://github.com/nodejs/node/blob/v26.3.0/src/node_buffer.cc#L1305-L1333 +template +static JSC::EncodedJSValue validateBytesOf(JSC::JSGlobalObject* globalObject, JSC::JSValue input, Validate validate) { - auto throwScope = DECLARE_THROW_SCOPE(lexicalGlobalObject->vm()); - - auto buffer = callframe->argument(0); - auto* bufferView = dynamicDowncast(buffer); - const char* ptr = nullptr; - size_t byteLength = 0; - if (bufferView) { - if (bufferView->isDetached()) [[unlikely]] { - throwTypeError(lexicalGlobalObject, throwScope, - "ArrayBufferView is detached"_s); - return {}; + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + + std::span bytes; + if (auto* view = dynamicDowncast(input); view && isTypedArrayType(view->type())) { + bytes = view->span(); + } else if (auto* arrayBuffer = dynamicDowncast(input)) { + if (arrayBuffer->impl()->isDetached()) [[unlikely]] { + // Thrown from C++ in Node too, so without the "Invalid state: " prefix Bun::ERR::INVALID_STATE adds. + return Bun::throwError(globalObject, scope, Bun::ErrorCode::ERR_INVALID_STATE, "Cannot validate on a detached buffer"_s); } - - byteLength = bufferView->byteLength(); - - if (byteLength == 0) { - return JSValue::encode(jsBoolean(true)); - } - - ptr = reinterpret_cast(bufferView->vector()); - } else if (auto* arrayBuffer = dynamicDowncast(buffer)) { - auto* impl = arrayBuffer->impl(); - - if (!impl) { - return JSValue::encode(jsBoolean(true)); - } - - if (impl->isDetached()) [[unlikely]] { - return Bun::ERR::INVALID_STATE(throwScope, lexicalGlobalObject, - "Cannot validate on a detached buffer"_s); - } - - byteLength = impl->byteLength(); - - if (byteLength == 0) { - return JSValue::encode(jsBoolean(true)); - } - - ptr = reinterpret_cast(impl->data()); + bytes = arrayBuffer->impl()->span(); } else { - Bun::throwError(lexicalGlobalObject, throwScope, - Bun::ErrorCode::ERR_INVALID_ARG_TYPE, - "First argument must be an ArrayBufferView"_s); - return {}; + return Bun::ERR::INVALID_ARG_INSTANCE(scope, globalObject, "input"_s, "ArrayBuffer, Buffer, or TypedArray"_s, input); } - RELEASE_AND_RETURN(throwScope, JSValue::encode(jsBoolean(simdutf::validate_utf8(ptr, byteLength)))); + if (bytes.empty()) + return JSValue::encode(jsBoolean(true)); + + return JSValue::encode(jsBoolean(validate(reinterpret_cast(bytes.data()), bytes.size()))); } // TODO: Add DOMJIT fast path -JSC_DEFINE_HOST_FUNCTION(jsBufferConstructorFunction_isAscii, +JSC_DEFINE_HOST_FUNCTION(jsBufferConstructorFunction_isUtf8, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) { - auto throwScope = DECLARE_THROW_SCOPE(lexicalGlobalObject->vm()); - - auto buffer = callframe->argument(0); - auto* bufferView = dynamicDowncast(buffer); - const char* ptr = nullptr; - size_t byteLength = 0; - if (bufferView) { - - if (bufferView->isDetached()) [[unlikely]] { - return Bun::ERR::INVALID_STATE(throwScope, lexicalGlobalObject, - "Cannot validate on a detached buffer"_s); - } - - byteLength = bufferView->byteLength(); - - if (byteLength == 0) { - return JSValue::encode(jsBoolean(true)); - } - - ptr = reinterpret_cast(bufferView->vector()); - } else if (auto* arrayBuffer = dynamicDowncast(buffer)) { - auto* impl = arrayBuffer->impl(); - if (impl->isDetached()) [[unlikely]] { - return Bun::ERR::INVALID_STATE(throwScope, lexicalGlobalObject, - "Cannot validate on a detached buffer"_s); - } - - if (!impl) { - return JSValue::encode(jsBoolean(true)); - } - - byteLength = impl->byteLength(); - - if (byteLength == 0) { - return JSValue::encode(jsBoolean(true)); - } - - ptr = reinterpret_cast(impl->data()); - } else { - Bun::throwError(lexicalGlobalObject, throwScope, - Bun::ErrorCode::ERR_INVALID_ARG_TYPE, - "First argument must be an ArrayBufferView"_s); - return {}; - } + return validateBytesOf(lexicalGlobalObject, callframe->argument(0), [](const char* data, size_t length) { + return simdutf::validate_utf8(data, length); + }); +} - RELEASE_AND_RETURN( - throwScope, - JSValue::encode(jsBoolean(simdutf::validate_ascii(ptr, byteLength)))); +// TODO: Add DOMJIT fast path +JSC_DEFINE_HOST_FUNCTION(jsBufferConstructorFunction_isAscii, + (JSC::JSGlobalObject * lexicalGlobalObject, + JSC::CallFrame* callframe)) +{ + return validateBytesOf(lexicalGlobalObject, callframe->argument(0), [](const char* data, size_t length) { + return simdutf::validate_ascii(data, length); + }); } BUN_DECLARE_HOST_FUNCTION(jsFunctionResolveObjectURL); diff --git a/test/js/node/buffer.test.js b/test/js/node/buffer.test.js index a81ac2723497..e6c12ab3740c 100644 --- a/test/js/node/buffer.test.js +++ b/test/js/node/buffer.test.js @@ -4746,3 +4746,89 @@ it.skipIf(os.totalmem() < 10 * 1024 ** 3)( expect(exitCode).toBe(0); }, ); + +describe.each([ + ["isUtf8", isUtf8], + ["isAscii", isAscii], +])("buffer.%s() input handling", (name, validate) => { + // "ab" followed by "é" in UTF-8: valid UTF-8, not ASCII. 8 bytes so every TypedArray kind can view it. + const bytes = [0x61, 0x62, 0x20, 0x20, 0x20, 0x20, 0xc3, 0xa9]; + const expected = name === "isUtf8"; + const invalidInput = received => + expect.objectContaining({ + name: "TypeError", + code: "ERR_INVALID_ARG_TYPE", + message: `The "input" argument must be an instance of ArrayBuffer, Buffer, or TypedArray. Received ${received}`, + }); + + // Node types the parameter as TypedArray | ArrayBuffer | Buffer. A DataView is an ArrayBufferView + // in JSC as well, but it is not a TypedArray, so it is rejected like any other object. + it.each([ + ["the whole buffer", ab => new DataView(ab)], + ["a sub-range", ab => new DataView(ab, 2, 4)], + ["zero bytes", () => new DataView(new ArrayBuffer(0))], + ])("rejects a DataView over %s", (_, makeView) => { + expect(() => validate(makeView(new Uint8Array(bytes).buffer))).toThrow(invalidInput("an instance of DataView")); + }); + + it.each([ + ["undefined", undefined, "undefined"], + ["null", null, "null"], + ["a string", "abc", "type string ('abc')"], + ["a number", 1, "type number (1)"], + ["a plain object", {}, "an instance of Object"], + ["an array of bytes", [0x61], "an instance of Array"], + ])("rejects %s with Node's message", (_, input, received) => { + expect(() => validate(input)).toThrow(invalidInput(received)); + }); + + it("throws ERR_INVALID_STATE for a detached ArrayBuffer", () => { + const ab = new Uint8Array(bytes).buffer; + structuredClone(ab, { transfer: [ab] }); + expect(() => validate(ab)).toThrow( + expect.objectContaining({ + name: "Error", + code: "ERR_INVALID_STATE", + message: "Cannot validate on a detached buffer", + }), + ); + }); + + // Node only checks the ArrayBuffer form for detachment; a detached view reports byteLength 0 + // and validates like any other empty input. + it("validates a view whose buffer was detached as empty input", () => { + const view = new Uint8Array([0xff, 0xfe]); + structuredClone(view.buffer, { transfer: [view.buffer] }); + expect(view.byteLength).toBe(0); + expect(validate(view)).toBe(true); + }); + + it("accepts every TypedArray kind, Buffer, ArrayBuffer and SharedArrayBuffer", () => { + const ab = new Uint8Array(bytes).buffer; + const sab = new SharedArrayBuffer(bytes.length); + new Uint8Array(sab).set(bytes); + const inputs = [ + new Int8Array(ab), + new Uint8Array(ab), + new Uint8ClampedArray(ab), + new Int16Array(ab), + new Uint16Array(ab), + new Int32Array(ab), + new Uint32Array(ab), + new Float16Array(ab), + new Float32Array(ab), + new Float64Array(ab), + new BigInt64Array(ab), + new BigUint64Array(ab), + Buffer.from(ab), + ab, + sab, + new Uint8Array(sab), + ]; + expect(inputs.map(input => validate(input))).toEqual(inputs.map(() => expected)); + // The view decides which bytes are looked at: the non-ASCII tail is outside this one. + expect(validate(new Uint8Array(ab, 0, 6))).toBe(true); + expect(validate(new Uint8Array(0))).toBe(true); + expect(validate(new ArrayBuffer(0))).toBe(true); + }); +}); From f7e5b746adfb91d36ef1b6c04438c22d1035a135 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:46:23 +0000 Subject: [PATCH 2/3] node:buffer: drop the per-function fast path notes now that isUtf8/isAscii share one body --- src/jsc/modules/NodeBufferModule.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/jsc/modules/NodeBufferModule.h b/src/jsc/modules/NodeBufferModule.h index 81e8ff637891..8e9a6f4d6d70 100644 --- a/src/jsc/modules/NodeBufferModule.h +++ b/src/jsc/modules/NodeBufferModule.h @@ -44,7 +44,6 @@ static JSC::EncodedJSValue validateBytesOf(JSC::JSGlobalObject* globalObject, JS return JSValue::encode(jsBoolean(validate(reinterpret_cast(bytes.data()), bytes.size()))); } -// TODO: Add DOMJIT fast path JSC_DEFINE_HOST_FUNCTION(jsBufferConstructorFunction_isUtf8, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) @@ -54,7 +53,6 @@ JSC_DEFINE_HOST_FUNCTION(jsBufferConstructorFunction_isUtf8, }); } -// TODO: Add DOMJIT fast path JSC_DEFINE_HOST_FUNCTION(jsBufferConstructorFunction_isAscii, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) From 2f7de0c21a205f4fdbdbf2d01607fa3ac9e6b33b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:52:04 +0000 Subject: [PATCH 3/3] node:buffer: move the isUtf8/isAscii contract notes next to the branches they explain --- src/jsc/modules/NodeBufferModule.h | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/jsc/modules/NodeBufferModule.h b/src/jsc/modules/NodeBufferModule.h index 8e9a6f4d6d70..06af499bfffe 100644 --- a/src/jsc/modules/NodeBufferModule.h +++ b/src/jsc/modules/NodeBufferModule.h @@ -14,10 +14,7 @@ namespace Zig { using namespace WebCore; using namespace JSC; -// Shared by buffer.isUtf8() and buffer.isAscii(). Node accepts a TypedArray (so not a -// DataView), an ArrayBuffer or a SharedArrayBuffer and throws ERR_INVALID_ARG_TYPE for -// anything else; only a detached ArrayBuffer is ERR_INVALID_STATE, a view whose buffer -// was detached has a byteLength of 0 and validates like any other empty input. +// Port of Node's buffer.isUtf8() / buffer.isAscii() argument handling: // https://github.com/nodejs/node/blob/v26.3.0/lib/buffer.js#L1415-L1429 // https://github.com/nodejs/node/blob/v26.3.0/src/node_buffer.cc#L1305-L1333 template @@ -26,6 +23,8 @@ static JSC::EncodedJSValue validateBytesOf(JSC::JSGlobalObject* globalObject, JS auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); std::span bytes; + // isTypedArrayType() excludes DataView, which Node rejects. A view whose buffer was detached + // has a byteLength of 0 and validates as empty; Node only throws for the ArrayBuffer itself. if (auto* view = dynamicDowncast(input); view && isTypedArrayType(view->type())) { bytes = view->span(); } else if (auto* arrayBuffer = dynamicDowncast(input)) {