-
Notifications
You must be signed in to change notification settings - Fork 5k
Fix crash in Bun.file().writer() with invalid path/fd options
#30299
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -268,3 +268,17 @@ it.skipIf(!isPosix)("does not leak native FileSink when a pending write fails (E | |||||||||||||||||||||||||||||||||||
| // more than that indicates a native leak. | ||||||||||||||||||||||||||||||||||||
| expect(fileSinkInternals.liveCount()).toBeLessThanOrEqual(baseline + 1); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| describe("Bun.file().writer() with invalid options", () => { | ||||||||||||||||||||||||||||||||||||
| it.each([ | ||||||||||||||||||||||||||||||||||||
| ["non-string path", { path: 123 }], | ||||||||||||||||||||||||||||||||||||
| ["non-integer fd", { fd: "notanint" }], | ||||||||||||||||||||||||||||||||||||
| ["arbitrary object", Bun], | ||||||||||||||||||||||||||||||||||||
| ])("does not crash with %s", async (_, options) => { | ||||||||||||||||||||||||||||||||||||
| const path = join(tmpdirSync(), "filesink-invalid-options.txt"); | ||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Use Please switch this new temp-path setup to Suggested patch-import { fileDescriptorLeakChecker, isPosix, isWindows, tmpdirSync } from "harness";
+import { fileDescriptorLeakChecker, isPosix, isWindows, tempDir, tmpdirSync } from "harness";
describe("Bun.file().writer() with invalid options", () => {
it.each([
["non-string path", { path: 123 }],
["non-integer fd", { fd: "notanint" }],
["arbitrary object", Bun],
])("does not crash with %s", async (_, options) => {
- const path = join(tmpdirSync(), "filesink-invalid-options.txt");
+ using dir = tempDir("filesink-invalid-options");
+ const path = join(dir, "filesink-invalid-options.txt");
const writer = Bun.file(path).writer(options as any);
writer.write("hello");
await writer.end();
expect(await Bun.file(path).text()).toBe("hello");
});
});As per coding guidelines, “Use 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| const writer = Bun.file(path).writer(options as any); | ||||||||||||||||||||||||||||||||||||
| writer.write("hello"); | ||||||||||||||||||||||||||||||||||||
| await writer.end(); | ||||||||||||||||||||||||||||||||||||
| expect(await Bun.file(path).text()).toBe("hello"); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 Pre-existing minor leak (not introduced by this PR, but you're touching the exact line): when
fromJSWithTagreturns.FileSinkwith a heap-allocatedinput_path(i.e. the user passed{ path: "…" }andtoSliceallocated for UTF-8 conversion), line 2979 overwritesstream_start.FileSink.input_pathwithout calling.deinit()on the previous value, leaking theZigString.Slice. Addingstream_start.FileSink.input_path.deinit();before the assignment would close it.Extended reasoning...
What the bug is
Start.fromJSWithTag(.FileSink)can return a.FileSinkvariant whoseinput_path.pathis a heap-owningZigString.Slice. Atstreams.zig:139, when the options object has a stringpathproperty, it does:JSValue.toSlice→bun.String.toUTF8(allocator)returns aZigString.Slicewith a non-null allocator whenever conversion is required (e.g. the JS string contains non-Latin1 code points, or is a rope that needs flattening). That slice must be freed viaZigString.Slice.deinit()/PathOrFileDescriptor.deinit().Back in
Blob.getWriter:The plain struct assignment on line 2979 drops the previous
input_pathon the floor without freeing it. Thedefer input_path.deinit();at line 2968 only frees the Blob-derivedinput_path, not the one that came back fromfromJSWithTag.Why existing code doesn't prevent it
There is no
deinitanywhere on thefromJSWithTag-returnedinput_path.sink.start()borrows the path (it dupes / opens it), it does not take ownership. And thedeferat 2968 covers a different value.Step-by-step proof
Bun.file("/tmp/x").writer({ path: "unused-😀" }).arguments[0].isObject()→ true, callfromJSWithTag(.FileSink).value.fastGet(.path)returns the JS string"unused-😀";path.isString()→ true.path.toSlice(globalThis, allocator)must transcode UTF-16 → UTF-8 because of the emoji, so it heap-allocates and returns aZigString.Slicewithallocator != null.fromJSWithTagreturns.{ .FileSink = .{ .input_path = .{ .path = <owned slice> } } }.getWriter,stream_start == .FileSink→ take the newifbranch.stream_start.FileSink.input_path = input_path;overwrites the owned slice with the Blob's own duped path. The emoji-string allocation is now unreachable.defer input_path.deinit()later frees only the Blob's path. ThefromJSWithTagallocation leaks.Impact
A small per-call heap leak, only reachable via the undocumented
{ path: string }option toBun.file().writer()(which is ignored in favor of the Blob's own path anyway). Low severity, but real.Pre-existing
Before this PR the line was the unconditional
stream_start.FileSink.input_path = input_path;, so the leak already existed. The PR neither introduces nor worsens it; flagging only because the PR rewrites this exact block and the fix is a one-liner.Fix
(The
elsebranch —.err/.empty/.ready— carries no owned resources, so nothing to free there.)