-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Fix incorrect/suspicious uses of ZigString.Slice.cloneIfNeeded
#23937
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 |
|---|---|---|
|
|
@@ -87,7 +87,7 @@ pub const FileSystemRouter = struct { | |
| return globalThis.throwInvalidArguments("Expected fileExtensions to be an Array of strings", .{}); | ||
| } | ||
| if (try val.getLength(globalThis) == 0) continue; | ||
| extensions.appendAssumeCapacity(((try val.toSlice(globalThis, allocator)).cloneIfNeeded(allocator) catch unreachable).slice()[1..]); | ||
| extensions.appendAssumeCapacity((try val.toUTF8Bytes(globalThis, allocator))[1..]); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -99,7 +99,7 @@ pub const FileSystemRouter = struct { | |
| return globalThis.throwInvalidArguments("Expected assetPrefix to be a string", .{}); | ||
| } | ||
|
|
||
| asset_prefix_slice = (try asset_prefix.toSlice(globalThis, allocator)).cloneIfNeeded(allocator) catch unreachable; | ||
| asset_prefix_slice = try (try asset_prefix.toSlice(globalThis, allocator)).cloneIfBorrowed(allocator); | ||
| } | ||
| const orig_log = vm.transpiler.resolver.log; | ||
| var log = Log.Log.init(allocator); | ||
|
|
@@ -165,6 +165,10 @@ pub const FileSystemRouter = struct { | |
| router.config.dir = fs_router.base_dir.?.slice(); | ||
| fs_router.base_dir.?.ref(); | ||
|
|
||
| // TODO: Memory leak? We haven't freed `asset_prefix_slice`, but we can't do so because the | ||
| // underlying string is borrowed in `fs_router.router.config.asset_prefix_path`. | ||
| // `FileSystemRouter.deinit` frees `fs_router.asset_prefix`, but that's a clone of | ||
| // `asset_prefix_slice`. The original is not freed. | ||
| return fs_router; | ||
| } | ||
|
|
||
|
|
@@ -271,7 +275,7 @@ pub const FileSystemRouter = struct { | |
|
|
||
| var path: ZigString.Slice = brk: { | ||
| if (argument.isString()) { | ||
| break :brk (try argument.toSlice(globalThis, globalThis.allocator())).cloneIfNeeded(globalThis.allocator()) catch unreachable; | ||
| break :brk try (try argument.toSlice(globalThis, globalThis.allocator())).cloneIfBorrowed(globalThis.allocator()); | ||
| } | ||
|
Comment on lines
+278
to
279
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. 🧩 Analysis chainOwn URL storage in MatchedRoute; stop leaking and avoid freeing interior pointers URLPath borrows from path. Keeping path alive by skipping deinit leaks; freeing route.pathname directly risks invalid frees on interior pointers. Store the owned ZigString.Slice inside MatchedRoute and free it in deinit; remove the mi_free block. @@
- var result = MatchedRoute.init(
+ var result = MatchedRoute.init(
globalThis.allocator(),
route,
this.origin,
this.asset_prefix,
this.base_dir.?,
) catch unreachable;
-
- // TODO: Memory leak? We haven't freed `path`, but we can't do so because the underlying
- // string is borrowed in `result.route_holder.pathname` and `result.route_holder.query_string`
- // (see `Routes.matchPageWithAllocator`, which does not clone these fields but rather
- // directly reuses parts of the `URLPath`, which itself borrows from `path`).
- // `MatchedRoute.deinit` doesn't free any fields of `route_holder`, so the string is not
- // freed.
+ // Tie lifetime of borrowed substrings to this instance.
+ result.owned_path = path; // move ownership; do not deinit `path` in this scopeAdd a field to MatchedRoute and update deinit: @@
pub const MatchedRoute = struct {
@@
base_dir: ?*jsc.RefString = null,
+ // Owns the allocation backing route_holder.pathname/query_string when derived from a parsed URL.
+ owned_path: ?ZigString.Slice = null,
@@
pub fn deinit(this: *MatchedRoute) void {
if (this.query_string_map) |*map| {
map.deinit();
}
if (this.needs_deinit) {
- if (this.route.pathname.len > 0 and bun.mimalloc.mi_is_in_heap_region(this.route.pathname.ptr)) {
- bun.mimalloc.mi_free(@constCast(this.route.pathname.ptr));
- }
+ if (this.owned_path) |*s| {
+ s.deinit();
+ this.owned_path = null;
+ }
this.params_list_holder.deinit(bun.default_allocator);
this.params_list_holder = .{};
}Run the failing asan target (🐧 12 x64-asan) to validate no new leaks/UAFs. If Router later clones pathname/query_string, you can omit owned_path. Also applies to: 328-333 I'll verify this review comment by examining the relevant code sections in the filesystem_router.zig file.Now let me search the repository for the actual filesystem_router.zig file: Let me search directly for the file content: Let me look at lines around 278-279: Let me get the full struct definition: Excellent! Now let me check the context where the Now let me search for URLPath definition to understand the borrowing pattern: Let me check if URLPath has a deinit method: Now let me check the Routes.Route type to see what toRoute does: Perfect! Now I understand the memory flow. Let me check if there are any other relevant details in the deinit function: Let me check what's happening more around line 328-333 carefully: Let me run ast-grep to find all deinit implementations in this file: Good! Now let me check how ZigString.Slice is used elsewhere in Bun: Let me search more broadly for ZigString types: Let me check what bun.JSC.ZigString looks like: Let me check a different approach - look at how needs_deinit is set: Let me search for where MatchedRoute objects are manipulated: Let me examine the whole filesystem_router.zig file structure to understand better: Perfect! Now I have all the information I need. Let me update my scratchpad and generate the final response: Based on my verification of the code, the review comment correctly identifies a real memory management issue in the codebase. Here's my final response: Fix memory leak in MatchedRoute by managing URLPath ownership The review comment accurately identifies a memory leak where the
The proposed refactoring correctly addresses this by:
The changes are necessary and correct. The suggestion to move ownership of 🏁 Script executed: #!/bin/bash
# Find and examine filesystem_router.zig file
fd -t f "filesystem_router.zig"Length of output: 36 🏁 Script executed: #!/bin/bash
# Look for the filesystem_router.zig file in the codebase
find . -name "*filesystem_router*" -type f 2>/dev/null | head -20Length of output: 129 🏁 Script executed: #!/bin/bash
# Search for the MatchedRoute struct and related code
rg -n "MatchedRoute" --type zig -A 5 -B 5 | head -100Length of output: 7185 🏁 Script executed: #!/bin/bash
# Search for mi_free usage related to route.pathname
rg -n "mi_free" --type zig -B 5 -A 5Length of output: 14249 🏁 Script executed: #!/bin/bash
# Get all .zig files under src/bun.js/api
fd "\.zig$" "src/bun.js/api/"Length of output: 2258 🏁 Script executed: #!/bin/bash
# Extract filesystem_router.zig and look at relevant lines
if [ -f "src/bun.js/api/filesystem_router.zig" ]; then
echo "=== File exists, extracting lines 270-340 ==="
sed -n '270,340p' "src/bun.js/api/filesystem_router.zig"
else
echo "File not found at expected location"
# Try to find it
find . -name "filesystem_router.zig" -type f
fiLength of output: 3015 🏁 Script executed: #!/bin/bash
# Find MatchedRoute struct definition
rg -n "pub const MatchedRoute" --type zig -A 30Length of output: 6416 🏁 Script executed: #!/bin/bash
# Get the MatchedRoute struct with all its methods
rg -n "pub const MatchedRoute" --type zig -A 80 | head -120Length of output: 8994 🏁 Script executed: #!/bin/bash
# Find where path variable is allocated and how URLPath relates to it
sed -n '250,340p' "src/bun.js/api/filesystem_router.zig"Length of output: 3772 🏁 Script executed: #!/bin/bash
# Find URLPath struct and its parse method
rg -n "pub const URLPath" --type zig -A 20Length of output: 1539 🏁 Script executed: #!/bin/bash
# Check URLPath deinit method
rg -n "pub fn.*deinit|const URLPath" --type zig -B 2 -A 15 | head -60Length of output: 3902 🏁 Script executed: #!/bin/bash
# Search for toRoute method implementation
rg -n "fn toRoute" --type zig -B 3 -A 30Length of output: 0 🏁 Script executed: #!/bin/bash
# Look at line 328-333 mentioned in the review
sed -n '320,340p' "src/bun.js/api/filesystem_router.zig"Length of output: 973 🏁 Script executed: #!/bin/bash
# Look at the actual line numbers from the review - find deinit calls or memory management
sed -n '1,60p' "src/bun.js/api/filesystem_router.zig" | grep -n "struct\|deinit\|mi_free"Length of output: 199 🏁 Script executed: #!/bin/bash
# Search for all pub fn deinit methods in the file
rg -n "pub fn deinit" --type zig -B 2 -A 15Length of output: 269887 🏁 Script executed: #!/bin/bash
# Search for ZigString.Slice usage and deinit
rg -n "ZigString\.Slice" --type zig | head -30Length of output: 2580 🏁 Script executed: #!/bin/bash
# Search across the repo for ZigString usage patterns
rg "ZigString" --type zig | head -30Length of output: 2440 🏁 Script executed: #!/bin/bash
# Look for ZigString definition and its Slice type
rg -n "pub const ZigString|pub const.*Slice" --type zig src/bun.js/bindings/ | head -20Length of output: 395 🏁 Script executed: #!/bin/bash
# Find where needs_deinit is set to true
rg -n "needs_deinit.*=.*true" --type zigLength of output: 71 🏁 Script executed: #!/bin/bash
# Search for MatchedRoute usage and needs_deinit assignments
rg -n "needs_deinit" --type zig -B 3 -A 3Length of output: 6285 🏁 Script executed: #!/bin/bash
# Get overview of the file
head -100 "src/bun.js/api/filesystem_router.zig"Length of output: 4532 🏁 Script executed: #!/bin/bash
# Search for where needs_deinit might be set to true in the entire codebase
rg -n "\.needs_deinit\s*=\s*true" --type zigLength of output: 0 Own URL storage in MatchedRoute — stop leaking and avoid freeing interior pointers URL slices returned by URLPath.parse(path.slice()) borrow from the local
Run ASAN (x64-asan) / the failing asan target to confirm no new leaks or UAFs. 🤖 Prompt for AI Agents
Contributor
Author
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. Just noting for anyone reading this that this PR does not change this behavior. It does seem incorrect, though; see the
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.
|
||
|
|
||
| if (argument.isCell()) { | ||
|
|
@@ -289,13 +293,14 @@ pub const FileSystemRouter = struct { | |
| }; | ||
|
|
||
| if (path.len == 0 or (path.len == 1 and path.ptr[0] == '/')) { | ||
| path.deinit(); | ||
| path = ZigString.Slice.fromUTF8NeverFree("/"); | ||
| } | ||
|
|
||
| if (strings.hasPrefixComptime(path.slice(), "http://") or strings.hasPrefixComptime(path.slice(), "https://") or strings.hasPrefixComptime(path.slice(), "file://")) { | ||
| const prev_path = path; | ||
| path = ZigString.init(URL.parse(path.slice()).pathname).toSliceFast(globalThis.allocator()).cloneIfNeeded(globalThis.allocator()) catch unreachable; | ||
| prev_path.deinit(); | ||
| defer prev_path.deinit(); | ||
| path = try .initDupe(globalThis.allocator(), URL.parse(path.slice()).pathname); | ||
| } | ||
|
taylordotfish marked this conversation as resolved.
|
||
|
|
||
| const url_path = URLPath.parse(path.slice()) catch |err| { | ||
|
|
@@ -319,6 +324,13 @@ pub const FileSystemRouter = struct { | |
| this.asset_prefix, | ||
| this.base_dir.?, | ||
| ) catch unreachable; | ||
|
|
||
| // TODO: Memory leak? We haven't freed `path`, but we can't do so because the underlying | ||
| // string is borrowed in `result.route_holder.pathname` and `result.route_holder.query_string` | ||
| // (see `Routes.matchPageWithAllocator`, which does not clone these fields but rather | ||
| // directly reuses parts of the `URLPath`, which itself borrows from `path`). | ||
| // `MatchedRoute.deinit` doesn't free any fields of `route_holder`, so the string is not | ||
| // freed. | ||
| return result.toJS(globalThis); | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
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.
Off‑by‑one: don’t always drop first byte of extension
[1..] removes the first character even when there is no leading dot. Strip '.' conditionally; otherwise you corrupt values like "tsx" → "sx".
📝 Committable suggestion
🤖 Prompt for AI Agents
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.
Just noting for anyone reading this that this PR does not change this behavior. If it's incorrect now, it was incorrect before.
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.
@taylordotfish, you're absolutely correct—the
[1..]slice was already present in the original code. Your PR only changes how the string is obtained (fromtoSlice().cloneIfNeeded().slice()totoUTF8Bytes()), not the slicing logic itself.Since you're already touching this line and the fix would be straightforward, you could optionally address it here to avoid a potential bug, but it's entirely reasonable to keep this PR focused on the
cloneIfNeededfixes and handle extension parsing separately if needed.