-
Notifications
You must be signed in to change notification settings - Fork 5k
bun_url: make freeing a whatwg::URL an unsafe raw-pointer operation #37577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
robobun
wants to merge
5
commits into
main
Choose a base branch
from
farm/71eda8b6/url-unsafe-destroy
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+214
−15
Open
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
c3b28c9
bun_url: make freeing a whatwg::URL an unsafe raw-pointer operation
robobun e29c71b
source-lints: also match release calls wrapped in an unsafe block
robobun a2812f5
bun_url: trim the whatwg URL binding comments
robobun 9d783ef
bun_url: fold the destroy rationale into its Safety section
robobun f699dde
bun_url: shorten the destroy Safety note
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
176 changes: 176 additions & 0 deletions
176
test/internal/source-lints/safe-ffi-release-method.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| import { file } from "bun"; | ||
| import { expect, test } from "bun:test"; | ||
| import { realpathSync } from "fs"; | ||
| import path from "path"; | ||
| import { globAllSources } from "../../../scripts/glob-sources.ts"; | ||
|
|
||
| // A C/C++ object that Rust only ever sees through an opaque handle (`opaque_ffi!`, | ||
| // or a hand-rolled `[u8; 0]` struct) must not expose the call that frees it, or | ||
| // gives back its refcount, as a safe method on the handle: | ||
| // | ||
| // pub fn deinit(&mut self) { | ||
| // URL__deinit(self) // C++: `delete url;` | ||
| // } | ||
| // | ||
| // The handle is a ZST, so a `&Handle` / `&mut Handle` reborrowed from any non-null | ||
| // pointer is a valid reference (`opaque_ffi!` even offers safe `opaque_ref` / | ||
| // `opaque_mut` for it) and proves neither that the caller owns the allocation nor | ||
| // that it is still alive. A `&self` / `&mut self` receiver is also not consumed by | ||
| // the call, so fully safe code can call the method twice (double free) or keep | ||
| // using the handle afterwards (use after free). `bun_url::whatwg::URL::deinit` had | ||
| // exactly this shape. | ||
| // | ||
| // Either of these shapes is fine: | ||
| // | ||
| // * `pub unsafe fn destroy(this: *mut Self)` with a `# Safety` contract | ||
| // (`bun_url::whatwg::URL`, `bun_jsc::URL`, `RegularExpression`, ...). | ||
| // * A private shim reached only from `Drop` of an owning wrapper | ||
| // (`CookieMapRef` in runtime/webcore/CookieMap.rs, `OwnedJscUrl` in | ||
| // install/hosted_git_info.rs). | ||
| // | ||
| // Sibling guards: unsound-erased-box.test.ts, frozen-nonnull-reborrow.test.ts. | ||
|
|
||
| // `pub` (any visibility) method, not `unsafe` (`pub unsafe fn` does not match because | ||
| // `unsafe` sits between `pub` and `fn`), taking `&self` / `&mut self`, whose body | ||
| // starts by handing `self` (or a projection of it) to a shim whose name ends in a | ||
| // free or refcount-release verb. The shim must be a bare identifier: extern shims | ||
| // are declared in the file that wraps them and called unqualified, whereas a | ||
| // path-qualified call (`bun_opaque::opaque_deref(self.ptr)`, `Async::actually_deinit(self, id)`) | ||
| // is a Rust helper that merely takes `self` as context. Sockets' `close` shims are a | ||
| // different lifecycle (the library frees the socket later, from its event loop) and | ||
| // are deliberately not matched either. | ||
| const RELEASE_FORWARDER = | ||
| /\bpub(?:\([^)]*\))?\s+fn\s+(\w+)\s*\(\s*(&(?:mut\s+)?self)\b[^)]*\)[^{;]*\{\s*(\w+_(?:deinit|destroy|delete|free|dealloc|deref|unref|release))\s*\(\s*self\b/g; | ||
|
|
||
| // Instances that predate this lint and are being removed separately (FetchHeaders | ||
| // and SourceProvider become Drop-owned handles in #33820; AbortSignal is tracked on | ||
| // its own). Ratchet: delete the entry together with the method (the test below | ||
| // fails on a stale entry). Prefer converting a new instance over adding one here. | ||
| const ALLOW = new Set([ | ||
| "src/jsc/AbortSignal.rs: unref -> WebCore__AbortSignal__unref", | ||
| "src/jsc/FetchHeaders.rs: deref -> WebCore__FetchHeaders__deref", | ||
| "src/jsc/SourceProvider.rs: deref -> JSC__SourceProvider__deref", | ||
| ]); | ||
|
|
||
| // Strip `//` comments without disturbing line numbers (`[ \t]*`, not `\s*`, so the | ||
| // preceding newlines survive), so prose like the example above does not count. | ||
| function stripLineComments(content: string): string { | ||
| return content.replace(/^[ \t]*\/\/.*$/gm, ""); | ||
| } | ||
|
|
||
| function scan(source: string, content: string): { key: string; display: string }[] { | ||
| const stripped = stripLineComments(content); | ||
| const found: { key: string; display: string }[] = []; | ||
| for (const m of stripped.matchAll(RELEASE_FORWARDER)) { | ||
| const line = stripped.slice(0, m.index).split("\n").length; | ||
| found.push({ | ||
| key: `${source}: ${m[1]} -> ${m[3]}`, | ||
| display: `${source}:${line}: pub fn ${m[1]}(${m[2]}, ..) forwards self to ${m[3]}`, | ||
| }); | ||
| } | ||
| return found; | ||
| } | ||
|
|
||
| test("matches the unsound shape and not the sound ones", () => { | ||
| const unsound = ` | ||
| impl URL { | ||
| pub fn deinit(&mut self) { | ||
| URL__deinit(self) | ||
| } | ||
| } | ||
| impl Headers { | ||
| pub(crate) fn deref(&self) -> u32 { WebCore__Headers__deref(self.0) } | ||
| } | ||
| impl Archive { | ||
| pub fn free(&self, mode: Mode) { | ||
| archive_read_free(self.as_mut_ptr(), mode) | ||
| } | ||
| } | ||
| `; | ||
| expect(scan("x.rs", unsound).map(o => o.key)).toEqual([ | ||
| "x.rs: deinit -> URL__deinit", | ||
| "x.rs: deref -> WebCore__Headers__deref", | ||
| "x.rs: free -> archive_read_free", | ||
| ]); | ||
|
|
||
| const sound = ` | ||
| impl URL { | ||
| /// pub fn deinit(&mut self) { URL__deinit(self) } | ||
| pub unsafe fn destroy(this: *mut Self) { | ||
| unsafe { URL__deinit(this) } | ||
| } | ||
| pub fn protocol(&self) -> String { | ||
| URL__protocol(self) | ||
| } | ||
| pub fn release_weak_refs(&self) { | ||
| JSC__VM__releaseWeakRefs(self) | ||
| } | ||
| pub fn into_raw(self) { | ||
| Foo__deinit(self) | ||
| } | ||
| pub fn global(&self) -> &JSGlobalObject { | ||
| bun_opaque::opaque_deref(self.global) | ||
| } | ||
| pub(crate) fn async_cmd_done(&self, id: NodeId) { | ||
| Async::actually_deinit(self, id); | ||
| } | ||
| } | ||
| impl Drop for CookieMapRef { | ||
| fn drop(&mut self) { | ||
| CookieMap__deref(self) | ||
| } | ||
| } | ||
| trait Release { | ||
| fn release(&self); | ||
| } | ||
| impl Owned { | ||
| pub fn unref(&self) { | ||
| self.0.take().map(|p| unsafe { Foo__unref(p) }); | ||
| } | ||
| } | ||
| `; | ||
| expect(scan("x.rs", sound)).toEqual([]); | ||
| }); | ||
|
|
||
| const root = path.resolve(import.meta.dir, "..", "..", ".."); | ||
| const rustSources = globAllSources().rust.filter(p => p.endsWith(".rs")); | ||
|
|
||
| // Only scan files tracked in HEAD (a `git stash` round-trip can leave stray | ||
| // `.rs` files in the working tree; CI runs on a clean checkout). Same guard as | ||
| // dead-code-escapes.test.ts. | ||
| const tracked: Set<string> | null = (() => { | ||
| const r = Bun.spawnSync({ | ||
| cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"], | ||
| stdout: "pipe", | ||
| stderr: "ignore", | ||
| }); | ||
| if (!r.success) return null; | ||
| return new Set(r.stdout.toString().split("\0").filter(Boolean)); | ||
| })(); | ||
|
|
||
| const offenders: { key: string; display: string }[] = []; | ||
| let scanned = 0; | ||
| for (const abs of rustSources) { | ||
| const source = path.relative(root, abs).replaceAll(path.sep, "/"); | ||
| // `src/cli` is a symlink into `src/runtime/cli`; count each file once under | ||
| // its canonical path. | ||
| if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; | ||
| if (tracked !== null && !tracked.has(source)) continue; | ||
| scanned++; | ||
| offenders.push(...scan(source, await file(abs).text())); | ||
| } | ||
|
|
||
| test("scans a non-empty set of tracked Rust sources", () => { | ||
| // Guards against the tracked/realpath filters above over-firing and leaving | ||
| // nothing to scan, which would make the bans below pass vacuously. | ||
| expect(scanned).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| test("no safe &self method frees or releases an FFI handle", () => { | ||
| expect(offenders.filter(o => !ALLOW.has(o.key)).map(o => o.display)).toEqual([]); | ||
| }); | ||
|
|
||
| test("every ALLOW entry is still present (delete the entry along with the method)", () => { | ||
| const seen = new Set(offenders.map(o => o.key)); | ||
| expect([...ALLOW].filter(key => !seen.has(key))).toEqual([]); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.