From 35be164fcf7bdc8ca4467ac41d4c420c2cc198b3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:48:16 +0000 Subject: [PATCH] node:assert: compare a proxied array's length through its get trap assert.deepStrictEqual and util.isDeepStrictEqual compare a Proxy of an array with the own-property walk. That walk never reads `length`, so a Proxy whose get trap reports another length equals the array. node compares `val1.length !== val2.length` with [[Get]] and reports them unequal. In the node entry point, when both values are arrays and one of them is a Proxy, read `length` from each side with [[Get]] and compare the values with ===. Arrays that are not proxies keep the direct length comparison. --- src/jsc/bindings/bindings.cpp | 15 +++++++++ test/js/node/assert/deep-equal.test.ts | 45 ++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index bb17cb537ced..167c75e61d1d 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -971,6 +971,21 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (v1Array != v2Array) return false; + if constexpr (checkPrototypes) { + // node compares array lengths with [[Get]] and ===. A Proxy's get trap can report a length + // its target does not have, and the own-property walk that compares proxies never reads it. + if (v1Array && (o1->isProxy() || o2->isProxy())) { + JSValue length1 = o1->get(globalObject, vm.propertyNames->length); + RETURN_IF_EXCEPTION(scope, false); + JSValue length2 = o2->get(globalObject, vm.propertyNames->length); + RETURN_IF_EXCEPTION(scope, false); + bool sameLength = JSC::JSValue::strictEqual(globalObject, length1, length2); + RETURN_IF_EXCEPTION(scope, false); + if (!sameLength) + return false; + } + } + if (v1Array && v2Array && !(o1->isProxy() || o2->isProxy())) { JSC::JSArray* array1 = uncheckedDowncast(v1); JSC::JSArray* array2 = uncheckedDowncast(v2); diff --git a/test/js/node/assert/deep-equal.test.ts b/test/js/node/assert/deep-equal.test.ts index ada9e1ae48b2..6a97bec9bcbd 100644 --- a/test/js/node/assert/deep-equal.test.ts +++ b/test/js/node/assert/deep-equal.test.ts @@ -70,6 +70,12 @@ function withOwnConstructor(prototype: object | null, constructor: unknown) { return Object.create(prototype, { constructor: { value: constructor } }); } +function withReportedLength(array: unknown[], length: unknown) { + return new Proxy(array, { + get: (target, key, receiver) => (key === "length" ? length : Reflect.get(target, key, receiver)), + }); +} + function seventyProperties>(object: T): T { for (let i = 0; i < 70; i++) (object as Record)["k" + i] = i; return object; @@ -406,6 +412,45 @@ const cases: Case[] = [ loose: true, looseBug: "reports not equal", }, + { + name: "a Proxy of [1, 2, 3] and [1, 2, 3]", + a: () => new Proxy([1, 2, 3], {}), + b: () => [1, 2, 3], + strict: true, + loose: true, + }, + // node reads an array's length with [[Get]], so a Proxy's get trap decides it. + { + name: "a Proxy of [1, 2, 3] whose get trap reports length 7 and [1, 2, 3]", + a: () => withReportedLength([1, 2, 3], 7), + b: () => [1, 2, 3], + strict: false, + loose: false, + looseBug: "reports equal", + }, + { + name: "[1, 2, 3] and a Proxy of [1, 2, 3] whose get trap reports length 7", + a: () => [1, 2, 3], + b: () => withReportedLength([1, 2, 3], 7), + strict: false, + loose: false, + looseBug: "reports equal", + }, + { + name: "a Proxy of [1, 2, 3] whose get trap reports length '3' and [1, 2, 3]", + a: () => withReportedLength([1, 2, 3], "3"), + b: () => [1, 2, 3], + strict: false, + loose: false, + looseBug: "reports equal", + }, + { + name: "two Proxies of [1, 2, 3] whose get traps report length 7", + a: () => withReportedLength([1, 2, 3], 7), + b: () => withReportedLength([1, 2, 3], 7), + strict: true, + loose: true, + }, { name: "own constructor: Uint8Array on objects with different prototypes", a: () => withOwnConstructor({ q: 1 }, Uint8Array),