bun_url: make freeing a whatwg::URL an unsafe raw-pointer operation - #37577
bun_url: make freeing a whatwg::URL an unsafe raw-pointer operation#37577robobun wants to merge 5 commits into
Conversation
`whatwg::URL::deinit(&mut self)` was a safe method that `delete`d the C++ WTF::URL behind an opaque ZST handle. A `&mut URL` to a ZST proves neither ownership nor liveness and is not consumed by the call, so safe code could free the same URL twice or keep using it after freeing it. Declare `URL__deinit` as an unsafe fn taking `*mut URL` and replace `deinit` with `unsafe fn destroy(this: *mut Self)`, matching `bun_jsc::URL`. The only caller (`OwnedJscUrl::drop` in hosted_git_info) now carries the obligation. Add a source lint that flags any safe `pub fn (&self | &mut self)` whose body forwards `self` to a `*_deinit` / `*_destroy` / `*_free` / `*_deref` / `*_unref` / `*_release` shim; the three pre-existing instances in bun_jsc are allowlisted with a ratchet until their own fixes land.
|
Warning Review limit reached
Next review available in: 8 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 (3)
Comment |
|
Updated 6:24 PM PT - Aug 11th, 2026
✅ @robobun, your commit f699dde92eb3257dbae2f5dbb8347939222fd22f passed in 🧪 To try this PR locally: bunx bun-pr 37577That installs a local version of the PR into your bun-37577 --bun |
|
Status: ready for review at f699dde. Reproduced how: the new lint in Since the first push: the lint also matches the |
There was a problem hiding this comment.
LGTM — tightens an FFI free from a safe &mut self method to unsafe fn destroy(*mut Self), matching the existing bun_jsc::URL shape; no runtime behavior change.
What was reviewed
- Confirmed
OwnedJscUrl::dropis the only caller of the removeddeinit;src/jsc/URL.rsalready usesdestroy(*mut Self)and is unaffected. - Verified the three
ALLOWentries (AbortSignal::unref, FetchHeaders::deref, SourceProvider::deref) exist and match the lint regex, so the ratchet test won't spuriously fail. - New lint follows the sibling source-lints pattern (
globAllSources, tracked-files guard, realpath dedup, non-empty-scan vacuity check) and self-tests its regex against positive/negative fixtures.
Extended reasoning...
Overview
This PR converts bun_url::whatwg::URL::deinit(&mut self) (a safe method that deletes a C++ heap allocation) into pub unsafe fn destroy(this: *mut Self), and re-declares the URL__deinit extern as unsafe fn(*mut URL) instead of safe fn(&mut URL). The single caller, OwnedJscUrl::drop in src/install/hosted_git_info.rs, is updated to call JscUrl::destroy(self.0.as_ptr()). A new source-lint test scans for the unsound pattern (safe &self/&mut self method forwarding to a *_deinit/*_free/*_deref/etc. FFI shim) with a ratcheted allowlist for three pre-existing instances tracked in other PRs.
Security risks
None. This is a type-system soundness tightening on an internal FFI binding — the same URL__deinit C++ function is called with the same pointer value; only the Rust signature moves from safe to unsafe. No user input, no auth, no data exposure.
Level of scrutiny
Low-to-medium. The Rust change is mechanical (signature swap plus one call-site update) and aligns byte-for-byte with the existing src/jsc/URL.rs::destroy shape. I grepped for other users of whatwg::URL / JscUrl and confirmed hosted_git_info.rs is the only consumer of the changed method. The new lint test is the larger piece; it copies the harness scaffolding (git ls-tree filter, realpath symlink dedup, vacuity guard) verbatim from unsound-erased-box.test.ts / dead-code-escapes.test.ts, and I confirmed each of the three allowlisted entries exists in src/jsc/{AbortSignal,FetchHeaders,SourceProvider}.rs with a shape the regex matches, so the ratchet assertion holds.
Other factors
The PR description states cargo check/clippy/fmt pass and the 655 hosted-git-info tests (which exercise OwnedJscUrl::drop under ASAN) pass. The regex has an inline positive/negative self-test guarding against silent breakage. The stripLineComments helper uses [ \t]* (not \s*) so line numbers are preserved for the reported location. No prior reviewer comments to address.
|
Heads up: #37609 removes |
Once the shim is declared the recommended way (unsafe fn taking a raw
pointer), the wrapper body necessarily reads `unsafe { shim(self..) }`, which
the regex did not accept, so the lint only ever saw shims still declared
`safe fn` and would not have caught the URL method being re-added on top of
the fix. Accepting the unsafe block surfaces the three libarchive free
methods, which go into the ratchet list; the fixtures now use the real shapes.
There was a problem hiding this comment.
LGTM — tightens an unsound safe FFI wrapper to pub unsafe fn destroy(*mut Self) with no runtime behavior change, and adds a source lint to keep it that way.
What was reviewed:
- Confirmed
OwnedJscUrl::dropis the sole caller of the olddeinit; the newJscUrl::destroy(self.0.as_ptr())reaches the sameURL__deiniton the same pointer, and the shape now matchessrc/jsc/URL.rs. - Verified all six
ALLOWentries in the lint exist and match the regex against currentsrc/; the otherpub fn deref/unrefmethods in the tree do not match (path-qualified or non-shim first statements). - Checked the lint follows the
dead-code-escapes.test.tsharness pattern (tracked-file guard, realpath dedup, non-empty-scan guard) and thatstripLineCommentspreserves line numbers.
Extended reasoning...
Overview
This PR changes bun_url::whatwg::URL::deinit(&mut self) (a safe method that deletes a C++ heap allocation behind a ZST handle) into pub unsafe fn destroy(this: *mut Self), and re-declares the extern URL__deinit as an unsafe fn over *mut URL. The single caller, OwnedJscUrl::drop in src/install/hosted_git_info.rs, is updated to call JscUrl::destroy(self.0.as_ptr()) inside an unsafe block. A new source-lint test (test/internal/source-lints/safe-ffi-release-method.test.ts) enforces that no safe &self/&mut self method forwards self to a *_deinit/*_free/*_deref/etc. FFI shim, with a ratcheted allowlist for six pre-existing instances tracked by other PRs.
Security risks
None. This is a type-system-level soundness fix (making an unsound safe API require unsafe). The generated machine code is identical: URL__deinit is still called with the same pointer from the same Drop impl. No user input, auth, or data-exposure surface is touched.
Level of scrutiny
Low-to-medium. The Rust change is mechanical (~15 lines net in two files) and matches the existing src/jsc/URL.rs binding shape line-for-line. I grepped for other callers of deinit/URL__deinit on this type — there are none beyond the updated OwnedJscUrl::drop. The lint test is the bulk of the diff; it copies the harness pattern from the sibling dead-code-escapes.test.ts (globAllSources().rust, git-tracked filter, realpath symlink dedup), includes a fixture self-test in both directions, guards against vacuous pass with expect(scanned).toBeGreaterThan(0), and ratchets the allowlist so stale entries fail. I spot-checked the regex against all six allowlisted sites and against the other pub fn deref/unref methods in the tree that do not forward to a bare shim — the pattern's \w+_verb(self requirement correctly excludes them.
Other factors
The comment-cop bot flagged verbose comments several times; the author trimmed them across a2812f5 → 9d783ef → f699dde and all threads are resolved. The final destroy doc is a two-line # Safety contract matching other unsafe fn destroy(*mut Self) handles in the tree. The PR description documents ASAN-verified test coverage via test/cli/install/hosted-git-info/ (655 tests) which exercises OwnedJscUrl on every parse. The known merge-order interaction with #37609 (which removes one allowlisted entry) is called out in the thread and handled by the ratchet.
What
bun_url::whatwg::URL(src/url/lib.rs) is an opaque zero-sized handle to a C++ heapWTF::URL(URL__fromStringdoesnew,URL__deinitdoesdelete). The extern was declaredsafe fn URL__deinit(url: &mut URL)and wrapped as a safepub fn deinit(&mut self), so this compiles with nounsafeanywhere and is a double delete, and the commented-out line is a use after free:Nothing in tree does this today. The one caller,
OwnedJscUrl::dropin src/install/hosted_git_info.rs, is correct, but theunsafeblock it did have only coveredas_mut(), not the free. Surfaced by review on #32023, which re-exports this type asbun_jsc::URLand deliberately left the signature alone (zero behavior change PR).Cause
The comment above the extern block argued "
deinittakes&mut URL(consumes)".&mut selfdoes not consume, and for a zero-sized handle a&mutis obtainable from any non-null pointer, so it also does not prove the allocation is owned or still alive. Thesafe fndeclaration was claiming a contract the signature cannot enforce. Thebun_jsc::URLcopy of the same binding (src/jsc/URL.rs) already had the right shape:fn URL__deinit(*mut URL)pluspub unsafe fn destroy(this: *mut Self).Fix
URL__deinitis declared as an unsafe fn taking*mut URL.deinit(&mut self)is replaced bypub unsafe fn destroy(this: *mut Self)with a Safety contract;from_string/from_utf8say the result is owned and freed with it. Same name and signature asbun_jsc::URL::destroy.OwnedJscUrl::dropcallsJscUrl::destroyand says why it is the single free.Why this layer and not a
DroponURL: the type is a ZST token, not the allocation, so Rust never holds aURLby value andDropcannot apply. Ownership lives in whoever holds the pointer (OwnedJscUrlhere), andunsafe fn destroy(*mut Self)is the shape the other 18 FFI-handle destructors in the workspace use for this situation. No runtime behavior changes.Interaction with #32023, checked with a three-way merge of src/url/lib.rs: three small conflicts (struct docs, the
URL__deinitline, the constructor docs), the merge dropsdeinitby itself, and #32023 then has to delete its owndestroy(whose body callsdeinit) and two doc references to it. Itsopaque_ffi!conversion and by-valuefrom_stringare its own dedup scope and are intentionally not pulled in here.Test
test/internal/source-lints/safe-ffi-release-method.test.tsflags any safepub fntaking&self/&mut selfwhose first statement, optionally insideunsafe { .. }, forwardsselfto a*_deinit/*_destroy/*_delete/*_free/*_dealloc/*_deref/*_unref/*_releaseshim. Theunsafe { .. }form matters: once a shim is declared the recommended way (unsafe fn over a raw pointer), that is the only form the unsound wrapper can take, so without it the lint would only ever see shims still declaredsafe fnand would not catchdeinitbeing re-added on top of this fix. The regex is checked against inline fixtures in both directions (pub unsafe fn destroy(*mut Self), Drop-only private shims,opaque_deref,releaseWeakRefs, by-value receivers and non-release calls insideunsafeblocks must not match). Against main'ssrc/it reports exactlysrc/url/lib.rs:148: pub fn deinit(&mut self, ..) forwards self to URL__deinit; with this branch it passes.The same scan finds six pre-existing instances of the shape, which are allowlisted with a ratchet (a stale entry fails the test) rather than fixed here:
FetchHeaders::derefandSourceProvider::derefare removed by #33820, which turns both into Drop-owned handles;AbortSignal::unref/detachand libarchive'sArchive::read_free/write_free/Entry::free(each has a Drop-owning wrapper that derefs to the handle, soowner.read_free()plus the owner's drop is a double free; their callers sit in files #33820 and #37549 are changing) are tracked as their own fixes.Verification
cargo check,cargo clippyandcargo fmt --checkonbun_urlandbun_install; debug (ASAN) build links.bun bd test test/cli/install/hosted-git-info/(655 pass) creates and drops aWTF::URLthroughOwnedJscUrlon every parse, so the newdestroypath runs under ASAN.