Skip to content

jsc: mark ArrayBuffer no-copy constructors unsafe - #31981

Merged
alii merged 4 commits into
mainfrom
farm/0848f87e/arraybuffer-no-copy-unsafe
Jun 9, 2026
Merged

jsc: mark ArrayBuffer no-copy constructors unsafe#31981
alii merged 4 commits into
mainfrom
farm/0848f87e/arraybuffer-no-copy-unsafe

Conversation

@robobun

@robobun robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #31970

Problem

make_array_buffer_with_bytes_no_copy and make_typed_array_with_bytes_no_copy in src/jsc/array_buffer.rs were safe pub 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 invokes deallocator(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:

let backing = core::ptr::NonNull::<c_void>::dangling().as_ptr();
let _ = make_array_buffer_with_bytes_no_copy(global, backing, 1, None, ptr::null_mut());

The FFI imports were declared safe fn with 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 an unsafe boundary.

Fix

  • Mark both free functions pub unsafe fn with a documented # Safety contract (ptr valid for reads and writes of len bytes until the deallocator runs; deallocator/ctx a pair sound to invoke exactly once on the JS thread at GC; ptr null only when len == 0).
  • Mark ArrayBuffer::to_js_with_context pub unsafe fn as well. It forwards a caller-supplied deallocator/ctx pair straight to the functions above (same class), and clippy's deny-level not_unsafe_ptr_arg_deref flags it once the callees are unsafe. to_js and to_js_unchecked take no raw arguments and stay safe; the soundness of the ArrayBuffer descriptor struct itself (from_bytes accepting arbitrary &mut [u8]) is tracked separately per the issue and is being fixed in Make ArrayBuffer::from_bytes unsafe and add owning constructors #31174.
  • Drop the safe marker from the Bun__makeArrayBufferWithBytesNoCopy / Bun__makeTypedArrayWithBytesNoCopy externs so the obligation lives in the wrappers' contracts instead of being asserted away in the extern block.
  • Add 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), Blob Share/Transfer lifetimes (Blob.rs), bun:ffi toArrayBuffer (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-level not_unsafe_ptr_arg_deref re-fires on to_js_with_context. A source-lint test under test/internal/ was originally included and removed at maintainer request.

Verification

  • cargo clippy -p bun_jsc -p bun_runtime clean (was failing on to_js_with_context mid-fix, which is what pulled it into scope)
  • bun run rust:check-all: 10 ok, 0 failed
  • bun bd test on 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)

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

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This 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 pub unsafe fn with detailed Safety docs, and all call sites are updated to use explicit unsafe blocks with ownership/deallocator comments.

Changes

ArrayBuffer No-Copy FFI Safety

Layer / File(s) Summary
FFI extern declaration and public wrapper signatures
src/jsc/array_buffer.rs
Externs and comments updated; Bun__makeArrayBufferWithBytesNoCopy declared as non-safe extern, and make_array_buffer_with_bytes_no_copy, make_typed_array_with_bytes_no_copy, and ArrayBuffer::to_js_with_context converted to pub unsafe fn with expanded # Safety docs and implementations that call Bun__make* via host-call inside unsafe blocks.
ArrayBuffer instance methods using unsafe wrappers
src/jsc/array_buffer.rs
ArrayBuffer::to_js_unchecked and ArrayBuffer::to_js updated to call no-copy wrappers within explicit unsafe blocks where ownership semantics require it; SAFETY comments added.
MarkedArrayBuffer adaptation
src/jsc/array_buffer.rs
MarkedArrayBuffer::to_js updated to use explicit unsafe blocks for zero-length and mimalloc-owned cases and to install the appropriate deallocator in the mimalloc-owned path.
Runtime/FFI/Image/Blob call-site updates
src/runtime/api/BunObject.rs, src/runtime/ffi/FFIObject.rs, src/runtime/image/Image.rs, src/runtime/webcore/Blob.rs
All call sites that previously invoked safe no-copy constructors or to_js_with_context are now wrapped in scoped unsafe { ... } blocks and annotated with safety comments describing ownership transfer and single-finalizer invocation (covers mmap_file, zlib/libdeflate compression paths, FFI conversions, image codec delivery, and Blob view creation).
Soundness contract lint test
test/internal/arraybuffer-no-copy-unsafe.test.ts
New internal test asserts via source-text checks that make_array_buffer_with_bytes_no_copy, make_typed_array_with_bytes_no_copy, and to_js_with_context are declared pub unsafe fn and that Bun__make* externs are not safe fn, encoding the #31970 invariant as a lint.

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: marking ArrayBuffer no-copy constructors as unsafe for memory safety.
Linked Issues check ✅ Passed The PR fully addresses issue #31970 by marking unsafe functions with proper safety contracts, adding unsafe blocks to all call sites, and including a source-lint test.
Out of Scope Changes check ✅ Passed All changes are directly related to the core objective of marking no-copy constructors unsafe and enforcing safety contracts at all call sites.
Description check ✅ Passed The pull request description comprehensively addresses the problem, fix, testing, and verification with clear code examples and verification steps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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)
src/jsc/array_buffer.rs (1)

466-545: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Unsafe no-copy adoption is still reachable from safe Rust.

ArrayBuffer::from_bytes and MarkedArrayBuffer::from_bytes earlier 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 and MarkedArrayBuffer_deallocator release 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

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and 25137fc.

📒 Files selected for processing (6)
  • src/jsc/array_buffer.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/ffi/FFIObject.rs
  • src/runtime/image/Image.rs
  • src/runtime/webcore/Blob.rs
  • test/internal/arraybuffer-no-copy-unsafe.test.ts

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Make ArrayBuffer::from_bytes unsafe and add owning constructors #31174 - Also marks ArrayBuffer no-copy constructors in src/jsc/array_buffer.rs as unsafe fn to fix the same soundness hole; goes further by adding safe owning constructors (from_owned_vec, from_owned_bytes) and migrating callers

🤖 Generated with Claude Code

Comment thread src/jsc/array_buffer.rs Outdated
@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

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 (from_bytes still safe): correct observation, and it is exactly what #31174 fixes. Folding it in here would duplicate that open PR.

The two PRs overlap textually in src/jsc/array_buffer.rs and the zlib call sites in BunObject.rs (#31174 replaces the Vec::leak + from_bytes pattern with from_owned_vec), so whichever lands second needs a small rebase; happy to rebase this one if #31174 goes first.

@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 — 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 # Safety doc on to_js_with_context didn't cover callback = 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.

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

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 # Safety doc on to_js_with_context didn't cover callback = 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.

@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the only failures on the rerun (#61373) are test/cli/install/bunx.test.ts exiting 1 on 15 platforms. The same bunx failures are on every concurrent PR build regardless of diff (#61370 15 of 16 failures, #61375 9 of 11, #61359 16 of 18), so it is a repo-wide registry/ecosystem breakage, not this change. The duckdb segfault and ASAN fetch-stream flake from the first run (#61360) did not recur.

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.

@alii

alii commented Jun 9, 2026

Copy link
Copy Markdown
Member

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

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@alii
alii merged commit 717542f into main Jun 9, 2026
4 of 5 checks passed
@alii
alii deleted the farm/0848f87e/arraybuffer-no-copy-unsafe branch June 9, 2026 22:26
Jarred-Sumner pushed a commit that referenced this pull request Aug 11, 2026
)

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

[Unsoundness] ArrayBuffer no-copy constructors trust raw backing pointers

2 participants