blob: make the Blob__ref/Blob__deref refcount exports unsafe fns - #37597
Conversation
Blob__ref and Blob__deref were safe pub extern "C" fns taking &mut Blob, and bun_runtime::webcore re-exported them. Blob is an ordinary struct that also lives by value (AnyBlob payloads, stack locals), so safe code could release a count it did not own (double free against the ExternalShared or JS wrapper that does own it) or bump a by-value Blob's count and make its normal deinit() free a non-heap address. Declare both as private unsafe extern "C" fns over *mut Blob with the ownership contract documented, matching ExternalSharedDescriptor and the other teardown exports in this crate. The exported symbols are unchanged; the only callers are BlobImplRefDerefTraits in blob.h, the ExternalSharedDescriptor impl and Blob::finalize. Drop the dead re-export and fix blob.h, which declared both as returning void* while the definitions return nothing. Add a source lint requiring no_mangle *__ref/__deref/__unref/__release exports to be declared unsafe.
|
Warning Review limit reached
Next review available in: 5 minutes 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 (4)
Comment |
|
Status: merged as 2d6bc9b. Reproduced on The two related items noted in the description ( |
There was a problem hiding this comment.
LGTM — pure contract tightening with no runtime behavior change.
What was reviewed:
Blob__ref/Blob__derefbodies are byte-identical moduloself_.→(*this).;&mut Blob→*mut BlobmatchesExternalSharedDescriptor::ext_ref/ext_derefand theBun__VmHandle__releaseprecedent.heap::release→heap::into_rawinfinalize: both hand off theBoxwithout dropping; only the return type differs (&mut Tvs*mut T) to match the new parameter.void*→voidinblob.h: definitions return nothing and the three C++ call sites discard the value.- Dropping
puband thebun_runtime::webcorere-export: grepped, no other Rust callers;#[unsafe(no_mangle)]keeps the symbol linkable for C++. - The new source-lint follows
frozen-nonnull-reborrow.test.tsexactly (same tracked-file guard, realpath dedup, non-vacuousfoundassertion) and its regex matches the existingpub unsafe extern "C" fn Bun__VmHandle__release.
Extended reasoning...
Overview
This PR converts Blob__ref and Blob__deref in src/jsc/webcore_types.rs from safe pub extern "C" fn(&mut Blob) to private unsafe extern "C" fn(*mut Blob), adds # Safety doc contracts, updates the two Rust callers (ExternalSharedDescriptor impl and Blob::finalize) to use unsafe blocks, removes a dead re-export from src/runtime/webcore/Blob.rs, corrects the void* → void return type in blob.h, and adds a source-lint test enforcing the pattern tree-wide.
Security risks
None. This is a compile-time contract change only. The function bodies are unchanged except for the mechanical self_. → (*this). rewrite required by the parameter-type change. No new unsafe operations are introduced; existing operations that were already unsafe-in-practice are now correctly labeled. The *mut parameter is arguably safer than &mut here since Blob__deref may free its argument, and materializing a &mut to memory that the function body then frees is questionable provenance-wise.
Level of scrutiny
Low-to-medium. While this touches refcounting FFI (memory-safety-critical territory per REVIEW.md), the change is purely a signature/contract adjustment with zero behavioral delta:
heap::release(self)(=Box::leak→&mut T) vsheap::into_raw(self)(=Box::into_raw→*mut T) both relinquish the Box without dropping and yield a pointer to the same allocation; only the return type changed to match the callee's new parameter type.ext_ref/ext_derefnow forwardthisdirectly instead of&mut *this— same pointer value, one fewer reborrow.- The
blob.hreturn-type fix is trivially verifiable: the Rust definitions return(), and all three C++ call sites inBlobImplRefDerefTraitsdiscard the return. - Dropping
pub:#[unsafe(no_mangle)]exports the symbol regardless of Rust visibility, so C++ linkage is unaffected; grep confirms no other Rust importers of the removed re-export.
Other factors
- The PR follows the exact shape of prior contract fixes it cites (#31981, #30812) and matches the in-tree
Bun__VmHandle__releaseprecedent (src/jsc/VmHandle.rs:511). - The new source-lint test is a near-verbatim copy of
frozen-nonnull-reborrow.test.ts(same imports, samegit ls-treetracked-file guard, same realpath symlink dedup) with a well-commented regex. It guards against vacuous passes by assertingfoundis non-empty andscanned > 0. - The PR description documents extensive verification: debug build, the new lint failing on stashed
src/, and the FormData/WebSocket/structuredClone suites that exercise the C++RefPtr<Blob>path. - The bug hunting system found no issues.
Problem
Blob__refandBlob__derefinsrc/jsc/webcore_types.rsare the intrusive refcount entry points behindBlobImplRefDerefTraits(src/jsc/bindings/blob.h) and theExternalSharedDescriptor for Blobimpl. They were declared as safe functions:and
bun_runtime::webcorere-exported them.Blobis an ordinary struct that also lives by value (AnyBlobpayloads, stack locals,Blob::dupe()results), so a&mut Blobis easy to come by in safe code, and both functions have preconditions that nothing in the signature proves:Blob__derefreleases a count the caller must own. On a heapBlobit double frees against theExternalShared<Blob>or JS wrapper that actually owns the count (derefrunsdeinit(), whichheap::takes the allocation). On a by-valueBlobit underflows the count (debug_assert!(is_heap_allocated())in debug builds).Blob__refrequires a heapBlob.is_heap_allocated()is encoded asref_count != 0, so bumping a by-valueBlobfrom 0 to 1 makes its ordinarydeinit()runheap::takeon 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
unsafe extern "C" fns over*mut Blobwith the ownership and threading contract in a# Safetysection. This is the shape ofExternalSharedDescriptor::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 Blobat the FFI boundary also follows the provenance guidance insrc/CLAUDE.mdfor entry points that may free their argument; the bodies are unchanged otherwise.#[unsafe(no_mangle)]keeps the symbols exported, soblob.hand the C++ callers are unaffected; per the workspacepubconvention the items no longer need to bepubsince no other crate imports them.ExternalSharedDescriptorimpl,Blob::finalize) now acknowledge the contract withunsafeblocks.finalizehands itsBoxover withheap::into_raw, which is whatBlob__deref->deinit->heap::takepairs with.src/runtime/webcore/Blob.rsis removed.blob.hdeclared both as returningvoid*; 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 whenref_count != 0(it is the teardownBlob__derefand the structured-clone error path rely on, so changing it means auditing its callers), and thesafe fn X__deref(..)declarations of C++-implemented refcount functions inunsafe extern "C"blocks are a separate population.Test
test/internal/source-lints/unsafe-refcount-exports.test.tsscans the tracked Rust sources for#[unsafe(no_mangle)]exports named*__ref,*__deref,*__unrefor*__releaseand requires them to be declaredunsafe. It also asserts the pattern still finds the tree's refcount exports (currentlyBlob__ref,Blob__deref,Bun__VmHandle__release), so it cannot pass vacuously after a rename. Onmainit fails with: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 bdbuilds clean;bun bd test test/internal/source-lints/unsafe-refcount-exports.test.tspasses, and fails with the output above withsrc/stashed.bun test test/internal/source-lints/(all 17 lints) passes;cargo fmt -p bun_jsc -p bun_runtime -- --checkclean.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.tspass.test/js/web/fetch/blob-file-name-ownership.test.ts(4000 structuredClone round-trips, each finalized throughBlob__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.