Skip to content

StringOrBuffer: snapshot resizable ArrayBuffer inputs so a later arg cannot resize(0) through the borrow - #35821

Open
robobun wants to merge 5 commits into
mainfrom
farm/8d3ccbd3/stringorbuffer-resizable-snapshot
Open

StringOrBuffer: snapshot resizable ArrayBuffer inputs so a later arg cannot resize(0) through the borrow#35821
robobun wants to merge 5 commits into
mainfrom
farm/8d3ccbd3/stringorbuffer-resizable-snapshot

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Repro

const crypto = require("node:crypto");
const pw = new Uint8Array(new ArrayBuffer(1 << 16, { maxByteLength: 1 << 16 })).fill(0x41);
crypto.scryptSync(pw, Buffer.alloc(16), 16, { get N() { pw.buffer.resize(0); return 1024; } });
panic(main thread): Segmentation fault at address 0x760078000000

Any StringOrBuffer::from_js* caller that captures a buffer and then evaluates a later argument (options getter, toString() on a boxed string, valueOf() on a numeric) is affected, on both the sync and async paths: crypto.scryptSync/scrypt, crypto.pbkdf2Sync/pbkdf2, Bun.password.*, Bun.zstdCompress/Decompress, Bun.Transpiler.transform, Bun.Markdown.*, fs.writeFile, and the Bun.* CryptoHasher input paths.

Cause

StringOrBuffer::from_js_maybe_async_into stores (ptr, len) into the caller's backing store. On the async path it pins; on the sync path it does not. Either way, pin() only clears isDetachable() (so transfer()/structuredClone/postMessage copy instead of detaching). It does not guard ArrayBuffer.prototype.resize(): JSC answers a shrink on a resizable buffer by mprotecting the trimmed pages PROT_NONE. The captured slice still spans those pages, so the next slice() read faults. The codebase already documents this interaction at the NodeHTTPResponse large-write path.

Node.js copies the password/salt bytes before handing them to the threadpool and does not crash.

Fix

When the input is backed by a resizable non-shared ArrayBuffer, snapshot the bytes into an owned MarkedArrayBuffer at capture time (sync and async). The result stays a StringOrBuffer::Buffer so callers that dispatch on the variant (fs.write(fd, buffer, offset, length), CryptoHasher digest-into-buffer) keep working; buffer.value keeps pointing at the caller's JS value so protect()/unprotect() and return-the-input paths are unchanged. Growable SharedArrayBuffer is left borrowed: it can only grow in-place within the reserved max, so reading the captured extent stays valid.

Supporting changes:

  • Drop for StringOrBuffer now releases an owned buffer. destroy() is idempotent and StringOrBuffer::to_js clears owns_buffer after transferring the allocation to JSC, so the existing explicit .destroy() callers (fetch.rs, Blob.rs) and the readFile/mkdtemp result paths are unaffected.
  • MarkedArrayBuffer::live_array_buffer re-reads the caller's live view from value when self is an owned snapshot; the five CryptoHasher digest-into-buffer sites use it so a resizable output buffer still gets written into (not the private copy).
  • args::Write::from_js skips the async re-pin when the input is already an owned snapshot, and MarkdownObject::PinnedView skips re-borrowing the original when the StringOrBuffer already owns its bytes.

Fixed-length buffers (the common case) stay on the existing zero-copy / pin path.

Verification

test/js/node/crypto/scrypt.test.ts spawns a subprocess that exercises sync password, sync salt, async password, zero-length resizable, and growable SharedArrayBuffer inputs, plus a regression guard that Bun.SHA256.hash(input, resizableOut) still writes into the caller's buffer. The subprocess segfaults on stock bun and passes with this change. test/js/node/crypto/, test/js/bun/util/bun-cryptohasher.test.ts, test/js/bun/util/password.test.ts, and the fs.write dispatch tests pass.

PathLike::from_js_with_allocator has the same hole for buffer-typed paths and is intentionally left out here: its Drop lives in bun_jsc::node_path and does not currently destroy(), so the snapshot needs that crate updated too; #32189 covers the async side. VectorArrayBuffer::from_js (fs.writev/fs.readv) has it too and is likewise left out: its iovec capture lives in Bun__JSArray__collectBufferSpans (C++) and the type has no owned-buffer tracking; #34758 covers it. Related: #35757 and #34966 pin on the sync path for transfer(); #34751 and #31645 snapshot on the async path only. This change covers the resize() hole on both paths without changing the variant the funnel returns.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/fs.test.ts

…cannot resize(0) through the borrow

ArrayBuffer.prototype.resize(0) mprotects trimmed pages PROT_NONE while a
borrowed (ptr, len) still spans them; pin() only guards transfer(). A later
argument's getter/toString (sync) or the JS thread (async) can therefore
SIGSEGV any StringOrBuffer::from_js caller that reads the bytes after more
user JS runs. scryptSync/pbkdf2Sync/etc. all hit this.

Snapshot resizable non-shared inputs into an owned Buffer at capture time
so variant dispatch (CryptoHasher output, fs.write) stays intact. Growable
SharedArrayBuffer is left borrowed: it only grows in-place within the
reserved max so reading the captured extent stays valid.

- StringOrBuffer::Drop now destroys owned buffers (idempotent; to_js
  clears owns_buffer after transferring to JSC)
- MarkedArrayBuffer::live_array_buffer re-reads the caller's live buffer
  for the five CryptoHasher digest-into-buffer sites
- skip the fs.write async re-pin and the Markdown PinnedView re-borrow
  when the input is already an owned snapshot
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR updates StringOrBuffer ownership and ArrayBuffer decoding for resizable buffers, adds live output-buffer access for crypto operations, removes redundant cleanup in file consumers, and adds regression tests for Markdown, crypto, and filesystem behavior.

Resizable ArrayBuffer lifetime handling

Layer / File(s) Summary
Ownership and live ArrayBuffer views
src/jsc/array_buffer.rs, src/runtime/node/types.rs
ArrayBuffer lengths remain usize; StringOrBuffer now transfers and releases ownership explicitly, and MarkedArrayBuffer can refresh its current view.
Buffer decoding and pinning
src/runtime/node/types.rs, src/runtime/api/MarkdownObject.rs, src/runtime/node/node_fs.rs
ArrayBuffer-like inputs use centralized snapshotting, pinning, and protection logic with explicit volatile-snapshot control.
Consumer lifetime integration
src/runtime/crypto/CryptoHasher.rs, src/runtime/webcore/Blob.rs, src/runtime/webcore/fetch.rs
Crypto output paths use live buffers, while file and multipart consumers remove redundant manual destruction.
Resizable buffer regression coverage
test/js/bun/md/md-render-callback.test.ts, test/js/node/crypto/scrypt.test.ts, test/js/node/fs/fs.test.ts
Tests cover resizing during Markdown rendering, crypto capture and execution, output buffers, and filesystem writes.

Possibly related PRs

  • oven-sh/bun#34751: Both modify async StringOrBuffer ArrayBuffer pinning and snapshot behavior.
  • oven-sh/bun#34964: Both update crypto handling of StringOrBuffer and ArrayBuffer values.
  • oven-sh/bun#34966: Both change StringOrBuffer lifetime, pinning, and cleanup behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main fix: snapshotting resizable ArrayBuffer inputs to prevent later resize-induced crashes.
Description check ✅ Passed The description covers the bug, cause, fix, and verification, though it uses different headings than the template.

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:34 PM PT - Jul 25th, 2026

@robobun, your commit 7548050 has 1 failures in Build #81845 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35821

That installs a local version of the PR into your bun-35821 executable, so you can run:

bun-35821 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Coerce later arguments before capturing input buffers in Bun.Transpiler, Bun.randomUUIDv5 and Bun.RedisClient #35757 - Direct predecessor pinning StringOrBuffer inputs so later args cannot free them mid-call; same files (types.rs, node_fs.rs) and same bug class
  2. StringOrBuffer: dupe FastTypedArray bytes instead of forcing slowDownAndWasteMemory() #35802 - Dupes FastTypedArray bytes in StringOrBuffer instead of slowDownAndWasteMemory(); same snapshotting mechanism in array_buffer.rs and types.rs
  3. crypto: pin StringOrBuffer inputs on the sync path so a later arg cannot detach them #34966 - Pins StringOrBuffer inputs on the sync path to prevent detach by later args; same files (types.rs, MarkdownObject.rs, node_fs.rs)
  4. crypto,util: coerce later arguments before capturing input buffers in Bun.* hash/UUID/indexOfLine/verifySync #34964 - Coerces later arguments before capturing input buffers in CryptoHasher.rs and types.rs to prevent detach
  5. crypto, zlib, zstd: copy resizable ArrayBuffer inputs before queuing to the threadpool #34751 - Copies resizable ArrayBuffer inputs in types.rs before queuing to threadpool; same resize(0) segfault fix
  6. node:fs: snapshot resizable ArrayBuffer inputs for async write/writev #34758 - Snapshots resizable ArrayBuffer inputs in node_fs.rs for async write/writev; subset of this PR's scope
  7. node: snapshot resizable async StringOrBuffer inputs before worker handoff #31645 - Snapshots resizable async StringOrBuffer inputs in types.rs before worker handoff; same core mechanism
  8. markdown: snapshot resizable ArrayBuffer input before option getters run #31730 - Snapshots resizable ArrayBuffer input in MarkdownObject.rs before option getters run; subset of this PR's scope
  9. bun: avoid stale sync compression input after option getters #31640 - Avoids stale sync compression input in BunObject.rs after option getters; same bug class

🤖 Generated with Claude Code

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

Beyond the inline nit, I traced the new Drop for StringOrBufferbuffer.destroy() through the other owns_buffer=true construction sites this could affect: the readFile/mkdtemp result paths (node_fs.rs:4727, node_fs.rs:6987) reach to_js with value == ZERO, so to_node_buffer adopts and the new owns_buffer = false prevents a double-free; the args::Write async re-pin at node_fs.rs:3894 correctly skips owned snapshots (without the guard, the assignment would drop the snapshot and re-borrow the resizable original); and the NodeHTTPResponse large-write path still hits its pre-existing resizable && !shared spill guard and reads from the owned snapshot before Drop frees it. live_array_buffer re-reads the caller's live view before any user JS runs between capture and the length check, so a shrunk output buffer is caught by the existing bytes_len < DIGEST guard rather than written past.

Extended reasoning...

The inline nit already covers the only concrete issue found. This note records the additional Drop-semantics audit so a later pass doesn't re-derive it.

Comment thread src/runtime/node/types.rs
…NodeHTTPResponse escape hatch, md/fs coverage

- snapshot_resizable is #[cold] and out-of-line
- ArrayBuffer::from_bytes / from_owned_bytes no longer round-trip through
  u32::try_from; len/byte_len are usize and a 4 GiB resizable input would
  otherwise panic before the caller's length validator ran
- array_buffer_into takes snapshot_volatile so from_js_with_encoding_into
  (used only by NodeHTTPResponse::write_or_end, which resolves encoding/
  callback before capturing and has its own resizable-tail spill) can
  opt out of the upfront copy
- Drop now frees owned Buffer payloads, so the explicit destroy() after
  readFile in fetch.rs / Blob.rs is redundant
- add fs.write async and Bun.markdown.html resizable-resize(0) coverage
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/runtime/node/types.rs Outdated
Comment thread src/runtime/node/types.rs Outdated
Comment thread src/jsc/array_buffer.rs
Comment thread src/runtime/node/types.rs
Comment thread src/runtime/node/types.rs

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/crypto/scrypt.test.ts`:
- Around line 80-84: Remove the implementation-history comments from the
regression tests at test/js/node/crypto/scrypt.test.ts:80-84,
test/js/bun/md/md-render-callback.test.ts:432-435, and
test/js/node/fs/fs.test.ts:5644-5648, preserving only a confirmed issue URL
where applicable. Keep the existing test names and assertions unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 581e3a72-c57a-4e8d-b8f8-2659874b67cc

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and bacb52f.

📒 Files selected for processing (10)
  • src/jsc/array_buffer.rs
  • src/runtime/api/MarkdownObject.rs
  • src/runtime/crypto/CryptoHasher.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/node/types.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/fetch.rs
  • test/js/bun/md/md-render-callback.test.ts
  • test/js/node/crypto/scrypt.test.ts
  • test/js/node/fs/fs.test.ts

Comment thread test/js/node/crypto/scrypt.test.ts Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/node/crypto/scrypt.test.ts (1)

147-158: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Assert the subprocess result before parsing stdout.

If the child crashes before writing JSON, JSON.parse(stdout.trim()) masks the native crash with a generic parse error. Assert the combined subprocess result first, including stdout, stderr, and exitCode, then parse stdout only after confirming a successful exit.

As per coding guidelines, crash-prone subprocess tests should preserve failure diagnostics rather than parse potentially empty stdout first. Based on learnings, crash/abort tests should assert combined subprocess details before interpreting stdout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/crypto/scrypt.test.ts` around lines 147 - 158, Update the
subprocess assertions around proc.stdout, proc.stderr, and proc.exited to
validate the combined result first, including stdout, stderr, and exitCode, and
require a successful exit before parsing output. Keep the existing JSON
expectations for syncPw, syncSalt, asyncPw, syncEmpty, sab, and hashOutput, but
move JSON.parse(stdout.trim()) after the subprocess success assertion so crash
diagnostics are preserved.

Sources: Coding guidelines, Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/js/node/crypto/scrypt.test.ts`:
- Around line 147-158: Update the subprocess assertions around proc.stdout,
proc.stderr, and proc.exited to validate the combined result first, including
stdout, stderr, and exitCode, and require a successful exit before parsing
output. Keep the existing JSON expectations for syncPw, syncSalt, asyncPw,
syncEmpty, sab, and hashOutput, but move JSON.parse(stdout.trim()) after the
subprocess success assertion so crash diagnostics are preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1cad940-bdeb-4dc7-bdfa-3057aa7b9c60

📥 Commits

Reviewing files that changed from the base of the PR and between bacb52f and 0027ada.

📒 Files selected for processing (3)
  • test/js/bun/md/md-render-callback.test.ts
  • test/js/node/crypto/scrypt.test.ts
  • test/js/node/fs/fs.test.ts
💤 Files with no reviewable changes (2)
  • test/js/node/fs/fs.test.ts
  • test/js/bun/md/md-render-callback.test.ts

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

Beyond the inline note on PathLike, I also checked: the new Drop on StringOrBuffer::Buffer vs. to_jsto_node_bufferowns_buffer is cleared after the ownership hand-off to JSC, so no double-free; and *out = Self::Buffer(...) in array_buffer_into — the previous value's Drop runs on assignment, so no leak of a prior owned snapshot.

Extended reasoning...

The PathLike finding is pre-existing and non-blocking. The two additional concerns I traced were the ones a Drop-semantics change on a shared type most obviously invites: (1) to_node_buffer calls JSValue::create_buffer, which installs MarkedArrayBuffer_deallocator and transfers ownership to JSC; the PR sets owns_buffer = false immediately after, so the subsequent Drop → destroy() is a no-op. (2) array_buffer_into writes *out by assignment, which runs the old value's Drop, so an owned snapshot already in out is released before the new one lands. Not approving because the ownership model change (owned snapshot + live value, live_array_buffer contract, snapshot_volatile opt-out) touches enough callers that a human should look.

Comment thread src/runtime/node/types.rs
Comment thread src/runtime/node/types.rs
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green on every lane that ran the new tests. Build 81845 passed all debian/ubuntu/alpine/windows test-bun shards that got a runner; the three new tests (scrypt.test.ts, md-render-callback.test.ts, fs.test.ts resizable cases) do not appear in any failure set.

Remaining red is unrelated to this diff:

  • step-failed-outside-runner on three build-bun lanes (linux-x64, x64-musl, darwin-x64) plus darwin-aarch64 build-bun timeout: CI queue backlog (same pattern as 81744)
  • test/cli/install/bun-install-registry.test.ts (win aarch64, flaky), test/cli/install/migration/complex-workspace.test.ts (x64-asan, flaky), test-quic-session-initial-rtt.mjs (x64-asan, timing), test-http-server-connections-checking-leak.js (alpine aarch64, flaky): all flagged flaky by the annotator and none touch StringOrBuffer/crypto/markdown/fs.write.

Ready for review.

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.

1 participant