Compare commits

...

1 Commits

Author SHA1 Message Date
Dylan Conway
9c55a5e00c fix(shell): handle junctions and directory symlinks in rm -rf on Windows
On Windows, junctions and directory symlinks are directories with reparse
points. Previously, `rm -rf` would follow junctions and delete the target
directory's contents, or crash with "panic: invalid enum value" when the
junction target didn't exist.

Fix by detecting reparse points before recursing into directories, and
removing them directly with REMOVEDIR instead of iterating their contents.
For symlink entries found during directory iteration, try deleting as a
file first (for file symlinks), then fall back to REMOVEDIR (for
directory symlinks and junctions).

Fixes #27233.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-19 20:59:19 -08:00
3 changed files with 177 additions and 0 deletions

View File

@@ -795,6 +795,28 @@ pub const ShellRmTask = struct {
const dirfd = this.cwd;
debug("removeEntryDir({s})", .{path});
// On Windows, junctions and directory symlinks are directories with reparse
// points. We must not recurse into them — just remove the reparse point
// itself using REMOVEDIR. Check file attributes to detect reparse points
// without following them.
if (bun.Environment.isWindows) {
const cwd_path = this.getcwd();
const attr_result = ShellSyscall.getAttributes(cwd_path, path);
switch (attr_result) {
.result => |attrs| {
if (attrs.is_reparse_point) {
return switch (ShellSyscall.unlinkatWithFlags(cwd_path, path, std.posix.AT.REMOVEDIR)) {
.result => this.verboseDeleted(dir_task, path),
.err => |e| .{ .err = this.errorWithPath(e, path) },
};
}
},
.err => {
// If getting attributes fails, continue with normal directory removal
},
}
}
// If `-d` is specified without `-r` then we can just use `rmdirat`
if (this.opts.remove_empty_dirs and !this.opts.recursive) out_to_iter: {
var delete_state = RemoveFileParent{
@@ -884,6 +906,52 @@ pub const ShellRmTask = struct {
.directory => {
this.enqueue(dir_task, current.name.sliceAssumeZ(), is_absolute, .dir);
},
.sym_link => {
const name = current.name.sliceAssumeZ();
const file_path = switch (this.bufJoin(
buf,
&[_][]const u8{
path[0..path.len],
name[0..name.len],
},
.unlink,
)) {
.err => |e| return .{ .err = e },
.result => |p| p,
};
if (bun.Environment.isWindows) {
// On Windows, symlinks and junctions that point to directories
// are themselves directories and must be removed with REMOVEDIR.
// First try as a file (works for file symlinks), then fall back
// to REMOVEDIR (works for directory symlinks and junctions).
switch (ShellSyscall.unlinkatWithFlags(this.getcwd(), file_path, 0)) {
.result => {
switch (this.verboseDeleted(dir_task, file_path)) {
.err => |e| return .{ .err = e },
else => {},
}
},
.err => {
switch (ShellSyscall.unlinkatWithFlags(this.getcwd(), file_path, std.posix.AT.REMOVEDIR)) {
.result => {
switch (this.verboseDeleted(dir_task, file_path)) {
.err => |e| return .{ .err = e },
else => {},
}
},
.err => |e2| return .{ .err = this.errorWithPath(e2, name) },
}
},
}
} else {
// On POSIX, unlink works on all symlinks regardless of target type.
switch (this.removeEntryFile(dir_task, file_path, is_absolute, buf, &remove_child_vtable)) {
.err => |e| return .{ .err = this.errorWithPath(e, name) },
.result => {},
}
}
},
else => {
const name = current.name.sliceAssumeZ();
const file_path = switch (this.bufJoin(

View File

@@ -1767,6 +1767,28 @@ pub const ShellSyscall = struct {
return .{ .result = joined };
}
/// Get file attributes for a path, resolving it relative to the given directory.
/// On Windows, this uses GetFileAttributesW which does NOT follow reparse points,
/// making it useful for detecting junctions and directory symlinks.
pub fn getAttributes(dir: anytype, path_: [:0]const u8) Maybe(Syscall.WindowsFileAttributes) {
if (bun.Environment.isWindows) {
const buf: *bun.PathBuffer = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(buf);
const path = switch (getPath(dir, path_, buf)) {
.err => |e| return .{ .err = e },
.result => |p| p,
};
if (Syscall.getFileAttributes(path)) |attrs| {
return .{ .result = attrs };
}
return .{ .err = .{
.errno = @intFromEnum(bun.sys.E.NOENT),
.syscall = .lstat,
} };
}
@compileError("getAttributes is only available on Windows");
}
pub fn statat(dir: bun.FileDescriptor, path_: [:0]const u8) Maybe(bun.Stat) {
if (bun.Environment.isWindows) {
const buf: *bun.PathBuffer = bun.path_buffer_pool.get();

View File

@@ -0,0 +1,87 @@
import { $ } from "bun";
import { expect, test } from "bun:test";
import { isWindows, tempDirWithFiles } from "harness";
import { existsSync, mkdirSync, symlinkSync } from "node:fs";
import path from "path";
// https://github.com/oven-sh/bun/issues/27233
// bun rm -rf crashes with "panic: invalid enum value" when a folder contains a JUNCTION on Windows.
// Junctions (and directory symlinks) should be removed without recursing into them.
test.if(isWindows)("rm -rf folder containing a junction does not crash", async () => {
$.nothrow();
const dir = tempDirWithFiles("rm-junction", {
"target/file.txt": "do not delete me",
});
const junctionPath = path.join(dir, "folder");
mkdirSync(junctionPath);
const targetPath = path.join(dir, "target");
const junctionLink = path.join(junctionPath, "J");
// Create a junction: junctionLink -> targetPath
symlinkSync(targetPath, junctionLink, "junction");
expect(existsSync(junctionLink)).toBeTrue();
// This should not crash
const { exitCode, stderr } = await $`rm -rf ${junctionPath}`;
expect(stderr.toString()).toBe("");
expect(exitCode).toBe(0);
// The folder containing the junction should be removed
expect(existsSync(junctionPath)).toBeFalse();
// The junction target and its contents should still exist (not recursed into)
expect(existsSync(targetPath)).toBeTrue();
expect(existsSync(path.join(targetPath, "file.txt"))).toBeTrue();
});
test.if(isWindows)("rm -rf directly on a junction does not crash", async () => {
$.nothrow();
const dir = tempDirWithFiles("rm-junction-direct", {
"target/file.txt": "do not delete me",
});
const targetPath = path.join(dir, "target");
const junctionPath = path.join(dir, "J");
// Create a junction: junctionPath -> targetPath
symlinkSync(targetPath, junctionPath, "junction");
expect(existsSync(junctionPath)).toBeTrue();
// This should not crash
const { exitCode, stderr } = await $`rm -rf ${junctionPath}`;
expect(stderr.toString()).toBe("");
expect(exitCode).toBe(0);
// The junction should be removed
expect(existsSync(junctionPath)).toBeFalse();
// The junction target and its contents should still exist
expect(existsSync(targetPath)).toBeTrue();
expect(existsSync(path.join(targetPath, "file.txt"))).toBeTrue();
});
test.if(isWindows)("rm -rf folder containing a junction with non-existent target", async () => {
$.nothrow();
const dir = tempDirWithFiles("rm-junction-broken", {});
const folderPath = path.join(dir, "folder");
mkdirSync(folderPath);
const junctionLink = path.join(folderPath, "J");
const nonExistentTarget = path.join(dir, "does-not-exist");
// Create a junction pointing to a non-existent target
symlinkSync(nonExistentTarget, junctionLink, "junction");
// This should not crash
const { exitCode, stderr } = await $`rm -rf ${folderPath}`;
expect(stderr.toString()).toBe("");
expect(exitCode).toBe(0);
expect(existsSync(folderPath)).toBeFalse();
});