jsc: mark ArrayBuffer no-copy constructors unsafe - #31981
Conversation
make_array_buffer_with_bytes_no_copy and make_typed_array_with_bytes_no_copy were safe pub fns that forward a caller-provided raw pointer, length, and deallocator/ctx pair to JSC, which adopts the memory as the backing store of a JS-visible ArrayBuffer/TypedArray and invokes the deallocator at GC. Safe Rust could therefore mint a JS object backed by a dangling pointer. Mark both functions unsafe with a documented Safety contract, and ArrayBuffer::to_js_with_context as well (it forwards a caller-supplied deallocator/ctx pair; clippy's not_unsafe_ptr_arg_deref flags it once the callees are unsafe). Drop the safe marker from the two FFI imports so the obligation is discharged by the wrappers instead of asserted by the extern block, and add SAFETY comments at all call sites. No behavior change. Fixes #31970
WalkthroughThis PR tightens the Rust safety boundary for no-copy ArrayBuffer/TypedArray creation: extern FFI functions are made non-safe, public wrappers and ArrayBuffer::to_js_with_context are converted to ChangesArrayBuffer No-Copy FFI Safety
Possibly related issues
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/jsc/array_buffer.rs (1)
466-545:⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy liftUnsafe no-copy adoption is still reachable from safe Rust.
ArrayBuffer::from_bytesandMarkedArrayBuffer::from_bytesearlier in this file still accept arbitrary&mut [u8], so Line 466 and Line 1051 remain safe entrypoints that hand caller-owned backing memory to JSC without a copy. That means safe Rust can still manufacture a JS view over stack/borrowed storage (dangling when you install no deallocator) or over a Rust-owned mimalloc allocation that later gets freed twice when both Rust andMarkedArrayBuffer_deallocatorrelease it. The unsafe boundary needs to move to these remaining entrypoints too, or the type needs to distinguish borrowed storage from transferrable ownership.As per coding guidelines, "Fix the whole class in the same PR."
Also applies to: 1037-1064
🤖 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 `@src/jsc/array_buffer.rs` around lines 466 - 545, The safe API still allows callers to pass arbitrary &mut [u8] into no-copy adoption paths (e.g. ArrayBuffer::from_bytes and MarkedArrayBuffer::from_bytes) which lets safe code hand JSC caller-owned or stack memory; fix by moving the unsafe boundary into those entrypoints: change ArrayBuffer::from_bytes and MarkedArrayBuffer::from_bytes to either be unsafe fn (documenting the ownership/transfer requirements) or accept a distinct OwnedBuffer/OwnedBytes wrapper type (or an enum Borrowed|Owned) so that only transferable, mimalloc-owned buffers get the deallocator set; update callers and ensure to_js/to_js_unchecked logic (methods like to_js, to_js_unchecked, make_array_buffer_with_bytes_no_copy, make_typed_array_with_bytes_no_copy, and MarkedArrayBuffer_deallocator) only install the deallocator when ownership is proven by the new unsafe/Owned API.Source: Coding guidelines
🤖 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 `@src/jsc/array_buffer.rs`:
- Around line 466-545: The safe API still allows callers to pass arbitrary &mut
[u8] into no-copy adoption paths (e.g. ArrayBuffer::from_bytes and
MarkedArrayBuffer::from_bytes) which lets safe code hand JSC caller-owned or
stack memory; fix by moving the unsafe boundary into those entrypoints: change
ArrayBuffer::from_bytes and MarkedArrayBuffer::from_bytes to either be unsafe fn
(documenting the ownership/transfer requirements) or accept a distinct
OwnedBuffer/OwnedBytes wrapper type (or an enum Borrowed|Owned) so that only
transferable, mimalloc-owned buffers get the deallocator set; update callers and
ensure to_js/to_js_unchecked logic (methods like to_js, to_js_unchecked,
make_array_buffer_with_bytes_no_copy, make_typed_array_with_bytes_no_copy, and
MarkedArrayBuffer_deallocator) only install the deallocator when ownership is
proven by the new unsafe/Owned API.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: dee46d12-b340-43d0-82f9-349cca9f51c0
📒 Files selected for processing (6)
src/jsc/array_buffer.rssrc/runtime/api/BunObject.rssrc/runtime/ffi/FFIObject.rssrc/runtime/image/Image.rssrc/runtime/webcore/Blob.rstest/internal/arraybuffer-no-copy-unsafe.test.ts
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Checked #31174 against this PR: not a duplicate, they fix sibling holes from the same audit batch and are complementary.
This also answers CodeRabbit's critical finding above ( The two PRs overlap textually in |
There was a problem hiding this comment.
LGTM — the doc gap on to_js_with_context is fixed in d086ef8, and grep confirms every in-tree caller of the now-unsafe functions is covered by this diff.
Extended reasoning...
Overview
This PR converts make_array_buffer_with_bytes_no_copy, make_typed_array_with_bytes_no_copy, and ArrayBuffer::to_js_with_context from safe pub fn to pub unsafe fn with documented # Safety contracts, drops the safe marker from the Bun__make*WithBytesNoCopy extern declarations, and wraps every call site in unsafe {} with a per-site SAFETY justification. Touches src/jsc/array_buffer.rs plus four runtime call-site files (BunObject, FFIObject, Image, Blob) and adds a source-lint test under test/internal/. No runtime behavior change — purely an API-contract/soundness annotation fix for #31970.
Security risks
None introduced; this tightens the safety boundary so safe Rust can no longer hand JSC a dangling backing pointer or arbitrary deallocator/ctx pair without an unsafe block. No new code paths, no new FFI surface.
Level of scrutiny
Moderate-low. The change is mechanical and compiler-enforced: reverting any signature trips unused_unsafe (denied workspace-wide) at every call site, and not_unsafe_ptr_arg_deref re-fires on to_js_with_context. I grepped for all three function names across the repo and confirmed the only callers are in the five files this PR edits, so there are no missed sites. The PR description reports clippy clean and passing test suites covering every touched call path.
Other factors
- My prior inline comment (the
# Safetydoc onto_js_with_contextdidn't covercallback = None) was addressed in d086ef8; the contract now mirrors the free-function wording, and the FFIObject SAFETY comment was tightened to match. - No CODEOWNERS match on the touched paths.
- The musl LTO build failures in CI ("Linking two modules of different data layouts … regular-lto-flag-stub.bc") are an infra/toolchain issue unrelated to these source edits.
- The duplicate-PR bot flagged #31174; the author explained the two PRs fix sibling holes (descriptor constructors vs. no-copy free functions) and will need a small textual rebase whichever lands second — a coordination note, not a blocker for this change.
There was a problem hiding this comment.
Looks correct to me — d086ef8 addresses the doc gap I flagged. Deferring to a human since this redraws the unsafe boundary for the ArrayBuffer FFI layer across several subsystems and overlaps with #31174 (landing order needs a call).
Extended reasoning...
Overview
This PR marks make_array_buffer_with_bytes_no_copy, make_typed_array_with_bytes_no_copy, and ArrayBuffer::to_js_with_context as pub unsafe fn (with documented # Safety contracts), drops the safe marker from the two Bun__make*WithBytesNoCopy extern declarations, and wraps every in-tree caller in an unsafe {} block with a per-site SAFETY justification. Touched call sites span src/jsc/array_buffer.rs (to_js_unchecked/to_js/to_js_with_context, MarkedArrayBuffer::to_js), BunObject.rs (mmap + four zlib/libdeflate paths), FFIObject.rs, Image.rs, and Blob.rs. A new test/internal/arraybuffer-no-copy-unsafe.test.ts pins the signatures via source-text regex.
The change is contract-only: every call site's argument list is byte-identical to before, just wrapped in unsafe { ... }. No runtime behavior change.
Security risks
None introduced — this tightens the safety surface by moving an existing soundness obligation from comments into the type system. The risk to review is whether each per-site SAFETY justification is actually true (e.g. that the leaked Vec / mmap region / codec buffer / blob store really does outlive the JS object until the deallocator runs). I read through each one and they match the surrounding code, but these are exactly the kind of ownership claims a maintainer should double-check.
Level of scrutiny
Moderate-to-high. The diff is mechanical, but src/jsc/array_buffer.rs is the FFI boundary where raw pointers become JS-visible backing stores — the canonical place memory-safety bugs hide. The SAFETY comments encode non-trivial lifetime reasoning across five subsystems.
Other factors
- My one prior inline comment (the
# Safetydoc onto_js_with_contextdidn't covercallback = None) was addressed in d086ef8; the contract now mirrors the free-function wording. Nothing else outstanding from me. - The bug-hunting system found no issues.
- The author ran clippy,
rust:check-all, and the relevant runtime test suites (mmap, zlib, blob, ffi, image) — all pass. - This overlaps textually with open PR #31174 (which makes the descriptor constructors unsafe and adds owning constructors). The author has already analyzed the relationship in-thread; whichever lands second needs a small rebase, so a human should pick the order.
- No CODEOWNERS coverage on the touched paths.
|
CI status: the only failures on the rerun (#61373) are Everything this diff touches is green: all build lanes, clippy, and the touched-path suites. One retrigger was already spent; leaving CI as is rather than spamming reruns. |
|
@robobun remove the reward hack test/internal/arraybuffer-no-copy-unsafe.test.ts test file then we can merge. ignore the gate failure |
The unsafe-boundary invariant is enforced by the compiler: reverting any of the signatures trips unused_unsafe (denied workspace-wide) at every call site, and clippy's not_unsafe_ptr_arg_deref re-fires on to_js_with_context.
|
Done, removed in 47290b7. The invariant stays compiler-enforced: reverting any of the signatures to safe trips unused_unsafe (denied workspace-wide) at every call site, and clippy's not_unsafe_ptr_arg_deref re-fires on to_js_with_context. PR body updated to match. |
) ### Problem `Blob__ref` and `Blob__deref` in `src/jsc/webcore_types.rs` are the intrusive refcount entry points behind `BlobImplRefDerefTraits` (`src/jsc/bindings/blob.h`) and the `ExternalSharedDescriptor for Blob` impl. They were declared as safe functions: ```rust #[unsafe(no_mangle)] pub extern "C" fn Blob__ref(self_: &mut Blob) #[unsafe(no_mangle)] pub extern "C" fn Blob__deref(self_: &mut Blob) ``` and `bun_runtime::webcore` re-exported them. `Blob` is an ordinary struct that also lives by value (`AnyBlob` payloads, stack locals, `Blob::dupe()` results), so a `&mut Blob` is easy to come by in safe code, and both functions have preconditions that nothing in the signature proves: - `Blob__deref` releases a count the caller must own. On a heap `Blob` it double frees against the `ExternalShared<Blob>` or JS wrapper that actually owns the count (`deref` runs `deinit()`, which `heap::take`s the allocation). On a by-value `Blob` it underflows the count (`debug_assert!(is_heap_allocated())` in debug builds). - `Blob__ref` requires a heap `Blob`. `is_heap_allocated()` is encoded as `ref_count != 0`, so bumping a by-value `Blob` from 0 to 1 makes its ordinary `deinit()` run `heap::take` on an address that was never boxed. This is why the retain side needs the contract too, unlike e.g. `Bun__VmHandle__retain(&VirtualMachine)`, where the reference already proves everything the function needs. No in-tree Rust caller misuses them today; this is a contract fix. Same class as the other "safe fn that releases something it cannot prove the caller owns" fixes (#31981, #30812). ### Fix - Both are now private `unsafe extern "C" fn`s over `*mut Blob` with the ownership and threading contract in a `# Safety` section. This is the shape of `ExternalSharedDescriptor::ext_ref/ext_deref` (so the impl forwards the pointer as is) and of the other teardown exports in the crate (`AbortSignal__Timeout__deinit`, `Bun__VmHandle__release`). Taking a raw pointer rather than materializing `&mut Blob` at the FFI boundary also follows the provenance guidance in `src/CLAUDE.md` for entry points that may free their argument; the bodies are unchanged otherwise. `#[unsafe(no_mangle)]` keeps the symbols exported, so `blob.h` and the C++ callers are unaffected; per the workspace `pub` convention the items no longer need to be `pub` since no other crate imports them. - The two Rust callers (`ExternalSharedDescriptor` impl, `Blob::finalize`) now acknowledge the contract with `unsafe` blocks. `finalize` hands its `Box` over with `heap::into_raw`, which is what `Blob__deref` -> `deinit` -> `heap::take` pairs with. - The dead re-export of both names from `src/runtime/webcore/Blob.rs` is removed. - `blob.h` declared both as returning `void*`; the definitions return nothing. C++ ignores the value, so this was harmless, but the declarations now match. Intentionally not touched here, same area, different fixes: `Blob::deinit(&mut self)` still frees the allocation itself when `ref_count != 0` (it is the teardown `Blob__deref` and the structured-clone error path rely on, so changing it means auditing its callers), and the `safe fn X__deref(..)` declarations of C++-implemented refcount functions in `unsafe extern "C"` blocks are a separate population. ### Test `test/internal/source-lints/unsafe-refcount-exports.test.ts` scans the tracked Rust sources for `#[unsafe(no_mangle)]` exports named `*__ref`, `*__deref`, `*__unref` or `*__release` and requires them to be declared `unsafe`. It also asserts the pattern still finds the tree's refcount exports (currently `Blob__ref`, `Blob__deref`, `Bun__VmHandle__release`), so it cannot pass vacuously after a rename. On `main` it fails with: ``` + "src/jsc/webcore_types.rs:483: Blob__ref", + "src/jsc/webcore_types.rs:492: Blob__deref", ``` The compiler enforces the rest: reverting either signature to safe trips `unused_unsafe` (warnings are denied workspace-wide) at the two Rust call sites. ### Verification - `bun bd` builds clean; `bun bd test test/internal/source-lints/unsafe-refcount-exports.test.ts` passes, and fails with the output above with `src/` stashed. - `bun test test/internal/source-lints/` (all 17 lints) passes; `cargo fmt -p bun_jsc -p bun_runtime -- --check` clean. - Suites that drive the C++ side of the protocol (`RefPtr<Blob>` in FormData, File and WebSocket) on the debug build: `test/js/web/html/FormData.test.ts`, `FormData-file-error-leak.test.ts`, `test/js/web/fetch/blob.test.ts`, `test/js/web/websocket/websocket-blob.test.ts` pass. `test/js/web/fetch/blob-file-name-ownership.test.ts` (4000 structuredClone round-trips, each finalized through `Blob__deref`) takes about 4s under the debug build on this machine and so sits at its default 5s timeout; it passes with a longer timeout, and its subprocess prints the expected output with no ASAN report.
Fixes #31970
Problem
make_array_buffer_with_bytes_no_copyandmake_typed_array_with_bytes_no_copyinsrc/jsc/array_buffer.rswere safepub fns accepting an arbitrary raw pointer, length, deallocator fn pointer, and deallocator context. They forward those values to JSC, which adopts the memory as the backing store of a JS-visible ArrayBuffer/TypedArray: every JS read/write dereferences the pointer, and GC later invokesdeallocator(ptr, ctx).Safe Rust could therefore mint a JS object backed by a dangling pointer, and get an arbitrary fn-pointer/context pair invoked at GC time, without writing
unsafe:The FFI imports were declared
safe fnwith a comment deferring the validity obligation to "the wrapper layer", but the wrappers were themselves safe and public, so the obligation was never discharged at anunsafeboundary.Fix
pub unsafe fnwith a documented# Safetycontract (ptrvalid for reads and writes oflenbytes until the deallocator runs; deallocator/ctx a pair sound to invoke exactly once on the JS thread at GC;ptrnull only whenlen == 0).ArrayBuffer::to_js_with_contextpub unsafe fnas well. It forwards a caller-supplied deallocator/ctx pair straight to the functions above (same class), and clippy's deny-levelnot_unsafe_ptr_arg_derefflags it once the callees are unsafe.to_jsandto_js_uncheckedtake no raw arguments and stay safe; the soundness of theArrayBufferdescriptor struct itself (from_bytesaccepting arbitrary&mut [u8]) is tracked separately per the issue and is being fixed in Make ArrayBuffer::from_bytes unsafe and add owning constructors #31174.safemarker from theBun__makeArrayBufferWithBytesNoCopy/Bun__makeTypedArrayWithBytesNoCopyexterns so the obligation lives in the wrappers' contracts instead of being asserted away in the extern block.unsafe {}blocks with per-site SAFETY justifications at every caller:ArrayBuffer::to_js_unchecked/to_js/to_js_with_context,MarkedArrayBuffer::to_js,Bun.mmapFile(BunObject.rs), the zlib/libdeflate sync paths (BunObject.rs), BlobShare/Transferlifetimes (Blob.rs),bun:ffitoArrayBuffer(FFIObject.rs), and the image codec delivery path (Image.rs). All in-tree callers already passed valid pointers; this change is API contract only, no behavior change.Test
No test file: the change is contract-only with zero behavior delta, and the enforcement is the compiler itself. Reverting any signature to safe trips
unused_unsafe(denied workspace-wide) at every call site, and clippy's deny-levelnot_unsafe_ptr_arg_derefre-fires onto_js_with_context. A source-lint test undertest/internal/was originally included and removed at maintainer request.Verification
cargo clippy -p bun_jsc -p bun_runtimeclean (was failing onto_js_with_contextmid-fix, which is what pulled it into scope)bun run rust:check-all: 10 ok, 0 failedbun bd teston suites covering every touched call site:test/js/bun/util/mmap.test.js(8 pass),test/regression/issue/18413-all-compressions.test.ts+17793.test.ts(zlib paths),test/js/web/fetch/blob.test.ts+blob-cow.test.ts(27 pass),test/js/bun/ffi/ffi.test.js,test/js/bun/image/image.test.ts(93 pass)