Compare commits

..

3 Commits

Author SHA1 Message Date
Dylan Conway
c8b15f6d60 fix(shell): prevent PATH_MAX panic in rm and add test
Add length check before path.join in rm's root-check loop to prevent
index out of bounds panic when path exceeds the fixed-size buffer.
A path exceeding PATH_MAX can't be root, so skip the check and let
the actual operation return ENAMETOOLONG.

Also add rm test and make all PATH_MAX tests concurrent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 03:07:32 +00:00
Dylan Conway
68a542b4b3 fix(shell): move PATH_MAX check before joinZ to prevent panic
The previous fix checked the path length after joinZ(), but joinZ()
itself panics with "index out of bounds" when the combined path exceeds
its fixed-size buffer. Move the check before the joinZ() call.

Add regression tests for touch and mkdir with paths exceeding PATH_MAX.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 02:24:14 +00:00
Dylan Conway
ad429b1a39 fix(shell): return ENAMETOOLONG instead of panicking on paths > PATH_MAX
When `mkdir` or `touch` builtins receive a path longer than 4096 bytes
(MAX_PATH_BYTES), `PathLike.sliceZ` panics with "index out of bounds"
because it uses a fixed-size buffer. Add a length check before calling
into node_fs, returning a proper ENAMETOOLONG error instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:18:19 +00:00
7 changed files with 116 additions and 45 deletions

View File

@@ -1087,30 +1087,10 @@ pub const WindowsSpawnOptions = struct {
dup2: struct { out: bun.jsc.Subprocess.StdioKind, to: bun.jsc.Subprocess.StdioKind },
pub fn deinit(this: *const Stdio) void {
switch (this.*) {
.buffer => |pipe| closePipeAndDestroy(pipe),
.ipc => |pipe| closePipeAndDestroy(pipe),
else => {},
if (this.* == .buffer) {
bun.default_allocator.destroy(this.buffer);
}
}
/// Properly close a libuv pipe handle before freeing its memory.
/// After uv_pipe_init, the pipe is registered in the event loop's
/// handle_queue. Freeing it without uv_close corrupts the queue's
/// linked list, causing segfaults on subsequent handle insertions.
pub fn closePipeAndDestroy(pipe_handle: *bun.windows.libuv.Pipe) void {
if (pipe_handle.loop != null) {
// Pipe was initialized with uv_pipe_init - must close properly.
pipe_handle.close(&onSpawnPipeCleanupClose);
} else {
// Pipe was allocated but never initialized - safe to free directly.
bun.default_allocator.destroy(pipe_handle);
}
}
fn onSpawnPipeCleanupClose(pipe_handle: *bun.windows.libuv.Pipe) callconv(.c) void {
bun.default_allocator.destroy(pipe_handle);
}
};
pub fn deinit(this: *const WindowsSpawnOptions) void {
@@ -1651,7 +1631,7 @@ pub fn spawnProcessWindows(
},
.ipc => |my_pipe| {
// ipc option inside stdin, stderr or stdout are not supported
WindowsSpawnOptions.Stdio.closePipeAndDestroy(my_pipe);
bun.default_allocator.destroy(my_pipe);
stdio.flags = uv.UV_IGNORE;
},
.ignore => {

View File

@@ -196,16 +196,6 @@ pub const Stdio = union(enum) {
};
}
/// Allocate a zeroed Pipe so that `loop` is null before `uv_pipe_init`.
/// This allows `WindowsSpawnOptions.Stdio.deinit` to detect whether the
/// pipe was registered with the event loop and must be closed via
/// `uv_close` before the memory can be freed.
fn createZeroedPipe() *uv.Pipe {
const pipe = bun.default_allocator.create(uv.Pipe) catch |err| bun.handleOom(err);
pipe.* = std.mem.zeroes(uv.Pipe);
return pipe;
}
fn toWindows(
stdio: *@This(),
i: i32,
@@ -245,10 +235,10 @@ pub const Stdio = union(enum) {
return .{ .err = .blob_used_as_out };
}
break :brk .{ .buffer = createZeroedPipe() };
break :brk .{ .buffer = bun.handleOom(bun.default_allocator.create(uv.Pipe)) };
},
.ipc => .{ .ipc = createZeroedPipe() },
.capture, .pipe, .array_buffer, .readable_stream => .{ .buffer = createZeroedPipe() },
.ipc => .{ .ipc = bun.handleOom(bun.default_allocator.create(uv.Pipe)) },
.capture, .pipe, .array_buffer, .readable_stream => .{ .buffer = bun.handleOom(bun.default_allocator.create(uv.Pipe)) },
.fd => |fd| .{ .pipe = fd },
.dup2 => .{ .dup2 = .{ .out = stdio.dup2.out, .to = stdio.dup2.to } },
.path => |pathlike| .{ .path = pathlike.slice() },

View File

@@ -222,11 +222,7 @@ pub const Source = union(enum) {
switch (pipe.open(fd)) {
.err => |err| {
// The pipe was already registered in the event loop's handle_queue
// by uv_pipe_init above. We must call uv_close to properly remove
// it from the queue before freeing the memory, otherwise the
// handle_queue linked list becomes corrupted (dangling pointers).
pipe.close(&onPipeOpenFailClose);
bun.default_allocator.destroy(pipe);
return .{
.err = err,
};
@@ -237,10 +233,6 @@ pub const Source = union(enum) {
return .{ .result = pipe };
}
fn onPipeOpenFailClose(pipe: *Pipe) callconv(.c) void {
bun.default_allocator.destroy(pipe);
}
pub const StdinTTY = struct {
var data: uv.uv_tty_t = undefined;
var lock: bun.Mutex = .{};

View File

@@ -235,6 +235,16 @@ pub const ShellMkdirTask = struct {
// implementation for it to work with cwd
const filepath: [:0]const u8 = brk: {
if (ResolvePath.Platform.auto.isAbsolute(this.filepath)) break :brk this.filepath;
// Check combined length before joining to avoid panic in joinZ's fixed-size buffer
if (this.cwd_path.len + this.filepath.len + 1 >= bun.MAX_PATH_BYTES) {
this.err = bun.sys.Error.fromCode(.NAMETOOLONG, .mkdir).withPath(bun.handleOom(bun.default_allocator.dupe(u8, this.filepath))).toShellSystemError();
if (this.event_loop == .js) {
this.event_loop.js.enqueueTaskConcurrent(this.concurrent_task.js.from(this, .manual_deinit));
} else {
this.event_loop.mini.enqueueTaskConcurrent(this.concurrent_task.mini.from(this, "runFromMainThreadMini"));
}
return;
}
const parts: []const []const u8 = &.{
this.cwd_path[0..],
this.filepath[0..],

View File

@@ -185,6 +185,11 @@ pub noinline fn next(this: *Rm) Yield {
for (filepath_args) |filepath| {
const path = filepath[0..bun.len(filepath)];
// Skip root check if combined path exceeds buffer size —
// a path that long can't be root, and the join would panic.
// The actual rm operation will return ENAMETOOLONG.
if (!ResolvePath.Platform.auto.isAbsolute(path) and cwd.len + path.len + 1 >= bun.MAX_PATH_BYTES)
continue;
const resolved_path = if (ResolvePath.Platform.auto.isAbsolute(path)) path else bun.path.join(&[_][]const u8{ cwd, path }, .auto);
const is_root = brk: {
const normalized = bun.path.normalizeString(resolved_path, false, .auto);

View File

@@ -221,6 +221,16 @@ pub const ShellTouchTask = struct {
// We have to give an absolute path
const filepath: [:0]const u8 = brk: {
if (ResolvePath.Platform.auto.isAbsolute(this.filepath)) break :brk this.filepath;
// Check combined length before joining to avoid panic in joinZ's fixed-size buffer
if (this.cwd_path.len + this.filepath.len + 1 >= bun.MAX_PATH_BYTES) {
this.err = bun.sys.Error.fromCode(.NAMETOOLONG, .open).withPath(bun.handleOom(bun.default_allocator.dupe(u8, this.filepath))).toShellSystemError();
if (this.event_loop == .js) {
this.event_loop.js.enqueueTaskConcurrent(this.concurrent_task.js.from(this, .manual_deinit));
} else {
this.event_loop.mini.enqueueTaskConcurrent(this.concurrent_task.mini.from(this, "runFromMainThreadMini"));
}
return;
}
const parts: []const []const u8 = &.{
this.cwd_path[0..],
this.filepath[0..],

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isPosix } from "harness";
// Regression test: touch, mkdir, and rm with paths exceeding PATH_MAX (4096)
// used to panic with "index out of bounds" in resolve_path.zig.
// After the fix, they return ENAMETOOLONG error instead.
describe.if(isPosix)("builtins with paths exceeding PATH_MAX should not crash", () => {
const longPath = Buffer.alloc(5000, "A").toString();
test.concurrent("touch with path > PATH_MAX returns error", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
import { $ } from "bun";
$.throws(false);
const r = await $\`touch ${longPath}\`;
console.log("exitCode:" + r.exitCode);
`,
],
env: { ...bunEnv, longPath },
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should print exit code and not crash (exit 132=illegal instruction, 134=abort, 139=segfault)
expect(stdout).toContain("exitCode:1");
expect(stderr).toContain("File name too long");
expect(exitCode).toBe(0);
});
test.concurrent("mkdir with path > PATH_MAX returns error", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
import { $ } from "bun";
$.throws(false);
const r = await $\`mkdir ${longPath}\`;
console.log("exitCode:" + r.exitCode);
`,
],
env: { ...bunEnv, longPath },
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("exitCode:1");
expect(stderr).toContain("File name too long");
expect(exitCode).toBe(0);
});
test.concurrent("rm with path > PATH_MAX does not crash", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
import { $ } from "bun";
$.throws(false);
const r = await $\`rm ${longPath}\`;
console.log("exitCode:" + r.exitCode);
`,
],
env: { ...bunEnv, longPath },
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// rm should not crash (exit 132=illegal instruction). It should exit with an error.
expect(stdout).toContain("exitCode:");
expect(stdout).not.toContain("exitCode:0");
expect(exitCode).toBe(0);
});
});