Skip to content

node:assert: compare a proxied array's length through its get trap - #41221

Open
robobun wants to merge 1 commit into
mainfrom
robobun/0711edbd/assert-proxy-array-length
Open

robobun wants to merge 1 commit into
mainfrom
robobun/0711edbd/assert-proxy-array-length

Conversation

@robobun

@robobun robobun commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • assert.deepStrictEqual and util.isDeepStrictEqual report a Proxy of [1, 2, 3] whose get trap returns 7 for length as equal to [1, 2, 3]. Node and Bun 1.4.0 report them unequal.
  • A Proxy skips the array path in Bun__deepEquals (src/jsc/bindings/bindings.cpp:974). The own-property walk that compares it never reads length, because length is not enumerable.
  • Bun 1.4.0 was correct by accident. Its class-name check rejected every Proxy of an array, also new Proxy([1, 2, 3], {}), which node accepts. node:assert: compare constructors like node in deepStrictEqual, not [[Prototype]] identity #40131 removed that check for the node entry point.

Fix

  • In the node entry point, when both values are arrays and one is a Proxy, read length from each side with [[Get]] and compare with ===. This is node's val1.length !== val2.length in objectComparisonStart.
  • Arrays that are not proxies keep the direct length read. Bun.deepEquals and expect() do not change.
  • Verified: test/js/node/assert/deep-equal.test.ts (five new cases, stock bun fails three). Also all of test/js/node/assert/, the vendored node assert tests, and the Bun.deepEquals and expect() suites.

Background

  • Bun__deepEquals is one native function with template flags. The checkPrototypes instantiation serves only assert.deepStrictEqual and util.isDeepStrictEqual.
  • A Proxy sends each internal method to a trap. [[Get]] calls the get trap, so the trap decides the length that a script sees.
  • assert.deepEqual (loose) uses Bun.deepEquals. It already differed from node for these values in 1.4.0. The new cases mark this with looseBug.
Notes

Strict mode (util.isDeepStrictEqual), cross-checked against node v26.3.0:

case node bun 1.4.0 main this PR
new Proxy([1,2,3], {}) vs [1,2,3] equal not equal equal equal
length 7 Proxy of [1,2,3] vs [1,2,3] not equal not equal equal not equal
[1,2,3] vs length 7 Proxy of [1,2,3] not equal not equal equal not equal
length '3' Proxy of [1,2,3] vs [1,2,3] not equal not equal equal not equal
length 7 Proxy vs length 3 Proxy, both of [1,2,3] not equal equal equal not equal
two length 7 Proxies of [1,2,3] equal equal equal equal
{ x: <length 7 Proxy> } vs { x: [1,2,3] } not equal not equal equal not equal
length 2 Proxy of [] vs [] not equal not equal equal not equal
length 2 Proxy of [] vs [ , ] equal not equal equal equal
length 3 Proxy of [1,2,3,4,5] vs [1,2,3] equal not equal not equal not equal

The last row is a difference this PR does not change. Node compares only the indexes below the reported length. Bun compares every own enumerable key of the target. Bun 1.4.0 behaves the same way.

The comparison uses === and not ToLength, because node uses !==. So a trap that returns '3' is unequal to an array of length 3.

A length trap that throws now propagates its error, as in node. Before this change the trap did not run.

#32948 (open) adds a length check for Bun.deepEquals and expect(). It uses ToLength, and it does not touch the node entry point.

Suites run with the debug build: test/js/node/assert/ (all files), test/js/bun/bun-object/deep-equals.test.ts, test/js/bun/test/expect.test.js (1000 pass, 0 fail), and the vendored test-assert.js, test-assert-checktag.js, test-assert-deep-with-error.js, test-assert-typedarray-deepequal.js, test-assert-class.js, test-util-isDeepStrictEqual.js.


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/assert/deep-equal.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [79.86ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [4.33ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [55.54ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [10.88ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [5.47ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [4.49ms]
(pass) assert.deepStrictEqual > rejects '' and false [3.93ms]
(pass) assert.deepStrictEqual > rejects null and undefined [4.03ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [10.66ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [5.63ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [9.35ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [5.10ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [5.27ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [9.40ms]
(pass) assert.dee
... (truncated)

release without fix: 6 FAILED
bun test v1.4.1-canary.1 (a6c4cc276)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [1.28ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [0.06ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [1.02ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [0.24ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [0.10ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [0.04ms]
(pass) assert.deepStrictEqual > rejects '' and false [0.04ms]
(pass) assert.deepStrictEqual > rejects null and undefined [0.03ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [0.17ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [0.07ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [0.14ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [0.08ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [0.05ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [0.05ms]
(pass) assert.deepStrictEqual > accepts two boxed booleans [0.04ms]
(pass) assert.deepStrictEqual > accepts two boxed symbols [0.04ms]
(pass) assert.deepStrictEqual > accepts two boxe
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/assert/deep-equal.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [79.06ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [4.67ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [55.43ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [11.58ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [5.73ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [4.56ms]
(pass) assert.deepStrictEqual > rejects '' and false [3.88ms]
(pass) assert.deepStrictEqual > rejects null and undefined [4.08ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [10.71ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [5.11ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [9.48ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [4.89ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [4.36ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [9.53ms]
(pass) assert.dee
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     35be164fcf
  features     baseline

23 deps, 131 codegen, 1172 objects in 633ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (a6c4cc276)

Checked 25 installs across 62 packages (no changes) [6.00ms]
[2/1244] gen bindgenv2
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (a6c4cc276)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1244] gen ErrorCode+*.h
[5/1244] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[6/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (a6c4cc276)

Checked 111 installs across 104 packages (no changes) [6.00ms]
[7/1244] fetch zlib
[zlib] up to date
[8/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1217] gen .bind.ts → GeneratedBindings.cpp
[10/1217] gen node-fallbacks/react-refresh.js
Bundled 1 module in 3ms

  react-refresh.js  4.81 KB  (entry point)

[11/1217] fetch tinycc
[t
... (truncated)
diff hotspot
src/jsc/bindings/bindings.cpp          | 15 ++++++++++++
 test/js/node/assert/deep-equal.test.ts | 45 ++++++++++++++++++++++++++++++++++
 2 files changed, 60 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
src/jsc/bindings/bindings.cpp               5      1      6
test/js/node/assert/deep-equal.test.ts      3      2      6

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.
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 18 days. After that, they cost $0.25 per reviewed file.

Or wait 10 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e25ccdcc-f423-473a-b986-6384a5fd5cab

📥 Commits

Reviewing files that changed from the base of the PR and between fd10aef and 35be164.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/node/assert/deep-equal.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 3, 2026
@robobun

robobun commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the fix and the test are pushed. The Buildkite build runs now.

Reproduction, with a Proxy of [1, 2, 3] whose get trap returns 7 for length, compared to [1, 2, 3]:

  • node v26.3.0: util.isDeepStrictEqual returns false, and assert.deepStrictEqual throws ERR_ASSERTION.
  • Bun 1.4.0: the same result as node.
  • main (1.4.1 canary): util.isDeepStrictEqual returns true, and assert.deepStrictEqual passes.
  • This branch: the same result as node.

test/js/node/assert/deep-equal.test.ts has five new cases. The released canary fails three of them, in the assert.deepStrictEqual and util.isDeepStrictEqual suites. The debug build of this branch passes all 432 tests in the file.

The automated review found no issues. No review threads are open.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, well-scoped Node compat fix with correct exception hygiene.

What was reviewed:

  • The new checkPrototypes-gated block in Bun__deepEquals: RETURN_IF_EXCEPTION follows every get() and strictEqual() call, matching the surrounding pattern; v1Array implies v2Array here since the mismatch case returned at line 971.
  • Scope: gated by if constexpr (checkPrototypes) and isProxy(), so Bun.deepEquals/expect() and the non-proxy array fast path are untouched.
  • Tests: five new cases added to the existing deep-equal.test.ts table, covering both argument orders, === vs == on length (the "3" case), and the no-op-Proxy-stays-equal case.
Extended reasoning...

Overview

This PR fixes a Node.js compatibility gap in assert.deepStrictEqual / util.isDeepStrictEqual: when comparing a Proxy-wrapped array whose get trap overrides length, Bun was reporting it equal to a plain array with the target's real length. Node reads length via [[Get]] and compares with !==, so a trap-reported length participates in the comparison. The fix adds a ~12-line block in Bun__deepEquals (src/jsc/bindings/bindings.cpp) that, only in the checkPrototypes template instantiation and only when at least one side is a Proxy, reads length from both objects via [[Get]] and compares with strictEqual. Five new parameterized cases are added to the existing test/js/node/assert/deep-equal.test.ts cases table.

Security risks

None. Bun__deepEquals already invokes user-controllable traps and getters throughout (e.g., objectPrototypeToString, property enumeration on proxies). The new get() calls are guarded with RETURN_IF_EXCEPTION(scope, false) immediately after each, so a throwing trap propagates cleanly rather than being swallowed or leaving a pending exception. No untrusted-size arithmetic, no allocation, no state held across the trap calls.

Level of scrutiny

Low-to-moderate. The change is small, mechanical, and mirrors both Node's documented behavior (val1.length !== val2.length) and the surrounding code's exception-checking pattern line-for-line. It is gated behind if constexpr (checkPrototypes) — the template flag used only by the node:assert/util entry points — and further gated on isProxy(), so the existing non-proxy array fast path (lines 989+) and the Bun.deepEquals/expect() instantiations are unaffected. The v1Array != v2Array early return at line 971 guarantees both sides are arrays when the new block runs. No CODEOWNERS entries cover these paths.

Other factors

Tests were added to the existing module test file (not a new file), follow the file's established cases table pattern, and cover the variant matrix REVIEW.md asks for: both argument orders, the ===-not-== distinction (string "3" vs numeric 3), the identity case (two proxies with the same reported length stay equal), and the regression guard that a no-op Proxy still compares equal. The looseBug annotations honestly document that assert.deepEqual (loose) is unchanged and still diverges from Node — this PR does not claim to fix that. The bug-hunt exited on dry_streak with no findings and no ruled-out candidates worth noting.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants