node:buffer: treat a detached view as empty in compare, equals, swapNN and as a fill value - #39406
node:buffer: treat a detached view as empty in compare, equals, swapNN and as a fill value#39406robobun wants to merge 1 commit into
Conversation
…N and as a fill value Buffer.compare(), buf.compare(), buf.equals(), buf.swap16/32/64(), Buffer.alloc(n, view) and buf.fill(view) each threw a bare TypeError when the Buffer or Uint8Array involved had been detached. Node has no detached special case in any of these: a detached view has length 0, so it compares as empty, swapping it is a no-op, and as a fill value it is rejected with ERR_INVALID_ARG_VALUE like any empty one. JSC gives a detached view byteLength() 0 as well, and every one of these bodies already handles a zero-length view the way Node does (the memcmp, the swap loops and the fill memmove are all skipped or pre-empted at length 0), so the guards were the only source of the divergence. Remove them.
|
Warning Review limit reached
Next review available in: 4 minutes Limit details: You’ve used all 5 included reviews currently available under your plan. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 9:42 PM PT - Aug 16th, 2026
✅ @robobun, your commit e7bd77d59348c8387f2469f9554c84234ca57374 passed in 🧪 To try this PR locally: bunx bun-pr 39406That installs a local version of the PR into your bun-39406 --bun |
|
Status: reproduced and fixed in this PR.
|
There was a problem hiding this comment.
LGTM — the removed isDetached() guards were the only divergence from Node, and each body already handles the resulting zero-length view without touching the null vector.
What was reviewed:
- Traced each of the nine removed guards:
memcmpincompare/equalsis gated onactualLength > 0;buf.compare's early-return branches (sourceStart >= sourceEnd/targetStart >= targetEnd) fire beforetypedVector()is read;swapNNloops run zero iterations;alloc/fillthrowERR_INVALID_ARG_VALUEonlength == 0beforevector(). - Confirmed no TOCTOU window in
buf.compare—validateIntegerrejects non-numbers up front (NodeValidator.cpp:69) and its min/max arejsNumberliterals, so no user coercion runs between thebyteLength()read and the memcmp. - Checked that
nullptr + 0in the constructorcomparepath is well-defined; length-tracking resizable views are covered by the tests.
Extended reasoning...
Overview
Removes nine explicit isDetached() guards from src/jsc/bindings/JSBuffer.cpp in Buffer.compare, buf.compare, buf.equals, buf.swap16/32/64, and the view-fill branch of Buffer.alloc/buf.fill. Adds an 8-test describe block to test/js/node/buffer.test.js asserting Node v26's behavior for detached views in each of these paths, including length-tracking views on resizable ArrayBuffers.
Security risks
The concern with removing detached guards is null-pointer dereference (JSC's vector()/typedVector() return null once detached). I traced every removed guard to its downstream memory access: constructor compare and equals gate memcmp on actualLength > 0 / compare_length > 0; prototype compare returns from sourceStart >= sourceEnd or targetStart >= targetEnd before reaching typedVector(); the three swapNN bodies compute count = 0 and skip their loops; alloc and fill throw on length == 0 before vector() is read. No path dereferences null.
The other risk is a TOCTOU where user code detaches between the byteLength() read and the memory access. Verified in NodeValidator.cpp:48-66 that validateInteger rejects non-numbers before any coercion and its min/max args here are jsNumber(...) literals — no user JS runs in buf.compare's offset handling. fill reads viewValue->byteLength() after all argument coercion and no further side effects run before the write.
Level of scrutiny
Medium-high: this is native memory-touching code where removing a guard could produce a UAF/null-deref. However, the change is purely subtractive (deletes guards, adds comments), the safety argument is local to each function body and easy to verify by inspection, and the PR description states it passes on an ASAN+UBSan debug build. I read each function body end-to-end to confirm the null vector is never reached.
Other factors
Test coverage is thorough: both argument positions in compare/equals, explicit offsets triggering ERR_OUT_OF_RANGE against a detached side's length of 0, the detached(3).swap16() case proving the size check sees 0 not 3, length-tracking resizable views, and the empty-range fill no-op. Expected values are stated to be taken from Node v26.3.0. The PR also enumerates which isDetached() checks in the file are intentionally left alone (concat, slice/subarray, from(detachedArrayBuffer), indexOf, write) and why. No prior review comments; no CODEOWNERS on this path.
Problem
TypeErrorbyBuffer.compare()(Uint8Array (first argument) is detached),buf.compare()andbuf.equals()(Uint8Array is detached),buf.swap16()/swap32()/swap64()(Buffer is detached), and as the fill value ofBuffer.alloc(n, view)/buf.fill(view)(Uint8Array is detached).Buffer.compare(detached, detached)is0,Buffer.from("abc").compare(detached)is1),equals()is true only against another empty view,swapNN()returns the buffer unchanged, and a detached fill value gets the sameERR_INVALID_ARG_VALUEan empty one gets.isDetached()guards insrc/jsc/bindings/JSBuffer.cpp(on main at lines 718, 843, 853, 1115, 1324, 1453, 1965, 1994 and 2028). Pre-existing, reproduces on 1.3.14 and the current canary; not a regression.Fix
byteLength()0 (JSArrayBufferView::detachFromArrayBuffersets the length to 0 and clears the data pointer), and each body already handles a zero-length view exactly as Node does: bothcomparebodies andequalsskip thememcmpwhen the shorter length is 0 and order on lengths alone, theswapNNloops run zero iterations and returnthis, andalloc/fillhit their existinglength == 0branch, which throwsERR_INVALID_ARG_VALUE. The guards were the only source of the divergence.compare's offsets go throughvalidateInteger, which rejects anything that is not already a number;fillreads the value's length after all argument coercion). A length-tracking view on a resizable buffer takes JSC's otherbyteLength()path and also reads 0 once detached; the tests cover it.describe("detached buffers in compare, equals, swapNN and as a fill value")block intest/js/node/buffer.test.js: all 8 tests fail on the released build with the TypeErrors above and pass with this change on a debug (ASAN + UBSan) build. Expected values were taken from Node v26.3.0 running the same calls.buffer.test.js(640 tests),buffer-copy-fill-detach.test.ts,buffer-indexOf-detach.test.ts, and upstreamtest-buffer-compare.js,test-buffer-compare-offset.js,test-buffer-equals.js,test-buffer-swap.js,test-buffer-fill.js,test-buffer-alloc.js.isDetached()checks that remain in the file are places where Node throws too (Buffer.concat,slice/subarray,Buffer.from(detachedArrayBuffer)), theindexOfcheck that returns -1, and thewrite()re-checks that Buffer.write: Node-compatible errors when toString() detaches or shrinks the buffer #30138 replaces.detached.fill(1)returning the buffer where Node throws, and the wording ofERR_OUT_OF_RANGE/ fill'sERR_INVALID_ARG_VALUEmessages, are pre-existing and unrelated to the detached guards.Background
ArrayBuffer.prototype.transfer()andstructuredClone(ab, { transfer })move an ArrayBuffer's memory out from under its views. The engine then reports every view on it as length 0 with no data pointer; the views themselves stay usable objects.Buffer.compare,buf.compare,buf.equalsandswapNNare JS wrappers overthis.length/byteLengthplus a nativecomparethat uses V8's view length, andfillpasses the value view's length to native code. None of them look at the detached state, which is why a detached view simply behaves as an empty one there.JSUint8Array).byteLength()is the length JSC reports (0 once detached);vector()/typedVector()is the data pointer (null once detached).Node v26.3.0 vs Bun, before and after (`det` = 16-byte Buffer whose ArrayBuffer was transferred, `abc` = Buffer.from("abc"), `empty` = Buffer.alloc(0))
Unchanged, and the same kind of error in Node:
Buffer.concat([det]),det.slice(),det.subarray()andBuffer.from(detachedArrayBuffer)all throw a TypeError in both.