Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ac79cee
install: honor process umask when creating directories
robobun Apr 25, 2026
30d66e8
install(bin): prime umask in createSymlink so isolated mode honors it
robobun Apr 25, 2026
0e951a9
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 25, 2026
40678de
install: use dangling-symlink-safe makePath in MakePath.makeOpenPath
robobun Apr 25, 2026
6f95263
install: stat dir-relative in makePath's dangling-symlink recovery
robobun Apr 25, 2026
78ee204
install(isolated): prime umask on main thread, not inside createSymlink
robobun Apr 26, 2026
16ef9e7
ci: retry WebKit fetch after transient 502
robobun Apr 26, 2026
9849776
install: override BUN_INSTALL_CACHE_DIR in umask test, drop redundant…
robobun Apr 26, 2026
98b768c
install(bin): assert umask was primed before bin chmod
robobun Apr 26, 2026
2132f34
install(link/unlink): umask-aware global node_modules creation
robobun Apr 26, 2026
1d66d93
install(link): drop dead PathAlreadyExists check after bun.makePath swap
robobun Apr 26, 2026
03ff162
install: reword self-referential comments
robobun Apr 26, 2026
a5552cf
ci: retrigger build after agents expired before running
robobun May 4, 2026
fac270f
install(rust port): honor umask in directory creation
robobun May 16, 2026
7136d14
[autofix.ci] apply automated fixes
autofix-ci[bot] May 16, 2026
569d602
install(bin): port debug assert to chmod_on_ok, drop stale doc wording
robobun May 16, 2026
c866e5d
install(test): point umask regression comment at .rs paths
robobun May 17, 2026
0955a41
install(isolated): gate bin import on #[cfg(unix)] for Windows build
robobun May 25, 2026
b7cdcb0
install(test): don't pipe unread stdout in umask test
robobun Jun 7, 2026
7a7fc50
bunx: pin cache root mode to 0o755 so permissive umasks don't trip th…
robobun Jun 7, 2026
255a035
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions src/bun.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2283,20 +2283,33 @@ pub inline fn serializableInto(comptime T: type, init: anytype) T {
return result.*;
}

/// Mode passed to `mkdirat(2)` when creating directories.
///
/// POSIX applies the process umask on top of this, so the final permission is
/// `0o777 & ~umask`. With the default umask of `0o022` this yields `0o755`
/// (same as before), but `umask 0o002` yields `0o775` — letting multi-user
/// setups work like they do with Node, npm, pnpm, and `/bin/mkdir`.
pub const umask_mkdir_mode: sys.Mode = 0o777;

/// Like std.fs.Dir.makePath except instead of infinite looping on dangling
/// symlink, it deletes the symlink and tries again.
/// symlink, it deletes the symlink and tries again. Uses `umask_mkdir_mode`
/// so the kernel honors the process umask.
pub fn makePath(dir: std.fs.Dir, sub_path: []const u8) !void {
var it = try std.fs.path.componentIterator(sub_path);
var component = it.last() orelse return;
while (true) {
dir.makeDir(component.path) catch |err| switch (err) {
makeDirUmask(dir, component.path) catch |err| switch (err) {
error.PathAlreadyExists => {
var path_buf2: [MAX_PATH_BYTES * 2]u8 = undefined;
copy(u8, &path_buf2, component.path);

path_buf2[component.path.len] = 0;
const path_to_use = path_buf2[0..component.path.len :0];
const result = try sys.lstat(path_to_use).unwrap();
// The mkdirat above and the deleteTree below are both
// relative to `dir`. Stat the same dir-fd so the decision
// lines up — `sys.lstat` would resolve against cwd, which
// is wrong whenever `dir` isn't cwd.
const result = try sys.lstatat(.fromStdDir(dir), path_to_use).unwrap();
const is_dir = S.ISDIR(@intCast(result.mode));
// dangling symlink
if (!is_dir) {
Expand All @@ -2314,6 +2327,13 @@ pub fn makePath(dir: std.fs.Dir, sub_path: []const u8) !void {
}
}

/// Like std.fs.Dir.makeDir but uses `umask_mkdir_mode` so the kernel
/// applies the process umask to the final permissions.
fn makeDirUmask(dir: std.fs.Dir, sub_path: []const u8) std.posix.MakeDirError!void {
if (Environment.isWindows) return dir.makeDir(sub_path);
return std.posix.mkdirat(dir.fd, sub_path, umask_mkdir_mode);
Comment thread
robobun marked this conversation as resolved.
}

/// Like std.fs.Dir.makePath except instead of infinite looping on dangling
/// symlink, it deletes the symlink and tries again.
pub fn makePathW(dir: std.fs.Dir, sub_path: []const u16) !void {
Expand Down Expand Up @@ -2457,6 +2477,8 @@ pub const MakePath = struct {
}
}

/// On POSIX, uses `bun.umask_mkdir_mode` when creating directories so the
/// kernel honors the process umask (matches `mkdir(1)` / Node / npm).
pub fn makeOpenPath(self: std.fs.Dir, sub_path: anytype, opts: std.fs.Dir.OpenOptions) !std.fs.Dir {
if (comptime Environment.isWindows) {
return makeOpenPathAccessMaskW(
Expand All @@ -2472,12 +2494,27 @@ pub const MakePath = struct {
);
}

return self.makeOpenPath(sub_path, opts);
// POSIX: avoid std's makeOpenPath which hardcodes mode 0o755. Try
// openDir first, then fall back to the file-level `bun.makePath`
// (umask-honoring mkdirat plus dangling-symlink handling) and retry.
// The qualified `bun.makePath` is unambiguous — only the bare
// `makePath` identifier is shadowed by `MakePath.makePath` below,
// and `MakePath.makePath` infinite-loops on dangling intermediate
// symlinks.
return self.openDir(sub_path, opts) catch |err| switch (err) {
error.FileNotFound => {
try bun.makePath(self, sub_path);
return self.openDir(sub_path, opts);
},
Comment thread
robobun marked this conversation as resolved.
else => |e| return e,
};
}

/// copy/paste of `std.fs.Dir.makePath` and related functions and modified to support u16 slices.
/// inside `MakePath` scope to make deleting later easier.
/// TODO(dylan-conway) delete `MakePath`
///
/// On POSIX, uses `bun.umask_mkdir_mode` so the kernel honors the process umask.
pub fn makePath(comptime T: type, self: std.fs.Dir, sub_path: []const T) !void {
if (Environment.isWindows) {
var dir = try makeOpenPath(self, sub_path, .{});
Expand All @@ -2488,7 +2525,7 @@ pub const MakePath = struct {
var it = try componentIterator(T, sub_path);
var component = it.last() orelse return;
while (true) {
std.fs.Dir.makeDir(self, component.path) catch |err| switch (err) {
std.posix.mkdirat(self.fd, component.path, bun.umask_mkdir_mode) catch |err| switch (err) {
error.PathAlreadyExists => {
// TODO stat the file and return an error if it's not a directory
// this is important because otherwise a dangling symlink
Expand Down
4 changes: 2 additions & 2 deletions src/install/PackageInstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,7 @@ impl<'a> PackageInstall<'a> {
while let Some(entry) = walker.next()? {
match entry.kind {
EntryKind::Directory => {
let _ = sys::mkdirat(destination_dir_, entry.path, 0o755);
let _ = sys::mkdirat(destination_dir_, entry.path, sys::UMASK_MKDIR_MODE);
}
EntryKind::File => {
let path_len = entry.path.len();
Expand Down Expand Up @@ -1132,7 +1132,7 @@ impl<'a> PackageInstall<'a> {
self.destination_dir_subpath_buf[slash] = 0;
// SAFETY: NUL written above.
let subdir = ZStr::from_buf(self.destination_dir_subpath_buf, slash);
let _ = sys::mkdirat(destination_dir, subdir, 0o755);
let _ = sys::mkdirat(destination_dir, subdir, sys::UMASK_MKDIR_MODE);
self.destination_dir_subpath_buf[slash] = SEP;
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/install/PackageInstall.zig
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ pub const PackageInstall = struct {
while (try walker.next().unwrap()) |entry| {
switch (entry.kind) {
.directory => {
_ = bun.sys.mkdirat(.fromStdDir(destination_dir_), entry.path, 0o755);
_ = bun.sys.mkdirat(.fromStdDir(destination_dir_), entry.path, bun.umask_mkdir_mode);
},
.file => {
bun.copy(u8, &stackpath, entry.path);
Expand Down Expand Up @@ -432,7 +432,7 @@ pub const PackageInstall = struct {
}
};

var subdir = destination_dir.makeOpenPath(bun.span(this.destination_dir_subpath), .{}) catch |err| return Result.fail(err, .opening_dest_dir, @errorReturnTrace());
var subdir = bun.MakePath.makeOpenPath(destination_dir, bun.span(this.destination_dir_subpath), .{}) catch |err| return Result.fail(err, .opening_dest_dir, @errorReturnTrace());
defer subdir.close();

this.file_count = FileCopier.copy(
Expand All @@ -451,7 +451,7 @@ pub const PackageInstall = struct {
if (strings.indexOfCharZ(this.destination_dir_subpath, std.fs.path.sep)) |slash| {
this.destination_dir_subpath_buf[slash] = 0;
const subdir = this.destination_dir_subpath_buf[0..slash :0];
destination_dir.makeDirZ(subdir) catch {};
_ = bun.sys.mkdiratZ(.fromStdDir(destination_dir), subdir, bun.umask_mkdir_mode);
this.destination_dir_subpath_buf[slash] = std.fs.path.sep;
}
}
Expand Down Expand Up @@ -527,7 +527,7 @@ pub const PackageInstall = struct {
state.walker.resolve_unknown_entry_types = true;

if (!Environment.isWindows) {
state.subdir = destbase.makeOpenPath(bun.span(destpath), .{
state.subdir = bun.MakePath.makeOpenPath(destbase, bun.span(destpath), .{
.iterate = true,
.access_sub_paths = true,
}) catch |err| {
Expand Down
2 changes: 1 addition & 1 deletion src/install/PackageInstaller.zig
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ pub const PackageInstaller = struct {
pub fn makeAndOpenDir(this: *NodeModulesFolder, root: std.fs.Dir) !std.fs.Dir {
const out = brk: {
if (comptime Environment.isPosix) {
break :brk try root.makeOpenPath(this.path.items, .{ .iterate = true, .access_sub_paths = true });
break :brk try bun.MakePath.makeOpenPath(root, this.path.items, .{ .iterate = true, .access_sub_paths = true });
}

break :brk (try bun.sys.openDirAtWindowsA(.fromStdDir(root), this.path.items, .{
Expand Down
2 changes: 1 addition & 1 deletion src/install/PackageManager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ var ensureTempNodeGypScriptOnce = bun.once(struct {
// used later for adding to path for scripts
manager.node_gyp_tempdir_name = try manager.allocator.dupe(u8, node_gyp_tempdir_name);

var node_gyp_tempdir = tempdir.handle.makeOpenPath(manager.node_gyp_tempdir_name, .{}) catch |err| {
var node_gyp_tempdir = bun.MakePath.makeOpenPath(tempdir.handle, manager.node_gyp_tempdir_name, .{}) catch |err| {
if (err == error.EEXIST) {
// it should not exist
Output.prettyErrorln("<r><red>error<r>: node-gyp tempdir already exists", .{});
Expand Down
10 changes: 5 additions & 5 deletions src/install/PackageManager/PackageManagerDirectories.zig
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ var getTemporaryDirectoryOnce = bun.once(struct {
std.posix.renameatZ(tempdir.fd, tmpname, cache_directory.fd, tmpname) catch |err| {
if (!tried_dot_tmp) {
tried_dot_tmp = true;
tempdir = cache_directory.makeOpenPath(".tmp", .{}) catch |err2| {
tempdir = bun.MakePath.makeOpenPath(cache_directory, ".tmp", .{}) catch |err2| {
Output.prettyErrorln("<r><red>error<r>: bun is unable to write files to tempdir: {s}", .{@errorName(err2)});
Global.crash();
};
Expand Down Expand Up @@ -124,7 +124,7 @@ noinline fn ensureCacheDirectory(this: *PackageManager) std.fs.Dir {
const cache_dir = fetchCacheDirectoryPath(this.env, &this.options);
this.cache_directory_path = bun.handleOom(this.allocator.dupeZ(u8, cache_dir.path));

return std.fs.cwd().makeOpenPath(cache_dir.path, .{}) catch {
return bun.MakePath.makeOpenPath(std.fs.cwd(), cache_dir.path, .{}) catch {
Comment thread
robobun marked this conversation as resolved.
this.options.enable.cache = false;
this.allocator.free(this.cache_directory_path);
continue :loop;
Expand All @@ -140,7 +140,7 @@ noinline fn ensureCacheDirectory(this: *PackageManager) std.fs.Dir {
.auto,
)) catch |err| bun.handleOom(err);

return std.fs.cwd().makeOpenPath("node_modules/.cache", .{}) catch |err| {
return bun.MakePath.makeOpenPath(std.fs.cwd(), "node_modules/.cache", .{}) catch |err| {
Output.prettyErrorln("<r><red>error<r>: bun is unable to write files: {s}", .{@errorName(err)});
Global.crash();
};
Expand Down Expand Up @@ -375,7 +375,7 @@ pub fn setupGlobalDir(manager: *PackageManager, ctx: Command.Context) !void {

pub fn globalLinkDir(this: *PackageManager) std.fs.Dir {
return this.global_link_dir orelse brk: {
var global_dir = Options.openGlobalDir(this.options.explicit_global_directory) catch |err| switch (err) {
const global_dir = Options.openGlobalDir(this.options.explicit_global_directory) catch |err| switch (err) {
error.@"No global directory found" => {
Output.errGeneric("failed to find a global directory for package caching and global link directories", .{});
Global.exit(1);
Expand All @@ -386,7 +386,7 @@ pub fn globalLinkDir(this: *PackageManager) std.fs.Dir {
},
};
this.global_dir = global_dir;
this.global_link_dir = global_dir.makeOpenPath("node_modules", .{}) catch |err| {
this.global_link_dir = bun.MakePath.makeOpenPath(global_dir, "node_modules", .{}) catch |err| {
Comment thread
claude[bot] marked this conversation as resolved.
Output.err(err, "failed to open global link dir node_modules at '{f}'", .{FD.fromStdDir(global_dir)});
Global.exit(1);
};
Expand Down
16 changes: 8 additions & 8 deletions src/install/PackageManager/PackageManagerOptions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -156,39 +156,39 @@ pub const Update = struct {

pub fn openGlobalDir(explicit_global_dir: string) !std.fs.Dir {
if (bun.env_var.BUN_INSTALL_GLOBAL_DIR.get()) |home_dir| {
return try std.fs.cwd().makeOpenPath(home_dir, .{});
return try bun.MakePath.makeOpenPath(std.fs.cwd(), home_dir, .{});
}

if (explicit_global_dir.len > 0) {
return try std.fs.cwd().makeOpenPath(explicit_global_dir, .{});
return try bun.MakePath.makeOpenPath(std.fs.cwd(), explicit_global_dir, .{});
}

if (bun.env_var.BUN_INSTALL.get()) |home_dir| {
var buf: bun.PathBuffer = undefined;
var parts = [_]string{ "install", "global" };
const path = Path.joinAbsStringBuf(home_dir, &buf, &parts, .auto);
return try std.fs.cwd().makeOpenPath(path, .{});
return try bun.MakePath.makeOpenPath(std.fs.cwd(), path, .{});
}

if (bun.env_var.XDG_CACHE_HOME.get() orelse bun.env_var.HOME.get()) |home_dir| {
var buf: bun.PathBuffer = undefined;
var parts = [_]string{ ".bun", "install", "global" };
const path = Path.joinAbsStringBuf(home_dir, &buf, &parts, .auto);
return try std.fs.cwd().makeOpenPath(path, .{});
return try bun.MakePath.makeOpenPath(std.fs.cwd(), path, .{});
Comment thread
robobun marked this conversation as resolved.
}

return error.@"No global directory found";
}

pub fn openGlobalBinDir(opts_: ?*const Api.BunInstall) !std.fs.Dir {
if (bun.env_var.BUN_INSTALL_BIN.get()) |home_dir| {
return try std.fs.cwd().makeOpenPath(home_dir, .{});
return try bun.MakePath.makeOpenPath(std.fs.cwd(), home_dir, .{});
}

if (opts_) |opts| {
if (opts.global_bin_dir) |home_dir| {
if (home_dir.len > 0) {
return try std.fs.cwd().makeOpenPath(home_dir, .{});
return try bun.MakePath.makeOpenPath(std.fs.cwd(), home_dir, .{});
}
}
}
Expand All @@ -199,7 +199,7 @@ pub fn openGlobalBinDir(opts_: ?*const Api.BunInstall) !std.fs.Dir {
"bin",
};
const path = Path.joinAbsStringBuf(home_dir, &buf, &parts, .auto);
return try std.fs.cwd().makeOpenPath(path, .{});
return try bun.MakePath.makeOpenPath(std.fs.cwd(), path, .{});
}

if (bun.env_var.XDG_CACHE_HOME.get() orelse bun.env_var.HOME.get()) |home_dir| {
Expand All @@ -209,7 +209,7 @@ pub fn openGlobalBinDir(opts_: ?*const Api.BunInstall) !std.fs.Dir {
"bin",
};
const path = Path.joinAbsStringBuf(home_dir, &buf, &parts, .auto);
return try std.fs.cwd().makeOpenPath(path, .{});
return try bun.MakePath.makeOpenPath(std.fs.cwd(), path, .{});
}

return error.@"Missing global bin directory: try setting $BUN_INSTALL";
Expand Down
10 changes: 10 additions & 0 deletions src/install/bin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1317,6 +1317,16 @@ impl<'a> Linker<'a> {
fn chmod_on_ok(err: Option<Error>, abs_target: &ZStr) {
// hoisted from `defer` block in create_symlink
if err.is_none() {
// `UMASK` is populated by `ensure_umask()`, which the hoisted
// installer, isolated installer, and `bun link` / `bun unlink`
// all call on the main thread before scheduling bin-link work.
// If a future caller forgets to prime, `UMASK` stays at `0` and
// `0o777 & !0` silently widens bin perms to `0o777`. Assert in
// debug builds so a miss is loud.
debug_assert!(
HAS_SET_UMASK.load(Ordering::Acquire),
"Bin::Linker::ensure_umask() not primed before bin chmod — would widen to 0o777",
);
let mode = 0o777 & !(UMASK.load(Ordering::Acquire) as Mode);
let _ = sys::lchmod(abs_target, mode);
}
Expand Down
31 changes: 29 additions & 2 deletions src/install/bin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -537,10 +537,23 @@ pub const Bin = extern struct {

var has_set_umask = false;

/// Capture the process umask once so it can be consulted later,
/// but do NOT zero it out. Historically we called `umask(0)` here
/// to avoid losing tarball-stored bits (the executable bit on
/// `node_modules/.bin` entries etc.). In practice the code that
/// needed exact modes already calls `fchmod` after the fact, and
/// zeroing umask prevented users' `umask 0o002` from producing
/// group-writable install trees — which is what this function was
/// blocking in issue #29723. Leaving umask alone lets the kernel
/// apply it to directory/file creation like Node, npm, and pnpm.
pub fn ensureUmask() void {
if (!has_set_umask) {
has_set_umask = true;
umask = bun.sys.umask(0);
// `umask(mask)` returns the previous mask. Read-and-restore
// so we capture the caller's umask without changing it.
const previous = bun.sys.umask(0);
_ = bun.sys.umask(previous);
umask = previous;
Comment thread
robobun marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -797,7 +810,21 @@ pub const Bin = extern struct {
fn createSymlink(this: *Linker, abs_target: [:0]const u8, abs_dest: [:0]const u8, global: bool) void {
defer {
if (this.err == null) {
_ = bun.sys.chmod(abs_target, umask | 0o777);
// `umask` is populated by `ensureUmask()`, which the
// hoisted installer, isolated installer, and
// `bun link`/`bun unlink` all call on the main thread
// before scheduling bin-link work. Calling it here
// would race — `Bin.Linker.link()` runs on thread-pool
// workers in isolated mode and `ensureUmask()`'s
// read-and-restore of the process umask is not atomic.
//
// Assert the invariant so a future caller that forgets
// to prime umask fails loudly in debug builds instead
// of silently widening bin perms to 0o777.
bun.assertWithLocation(has_set_umask, @src());
// Mark the bin executable. Honor the process umask the
// same way npm and pnpm do: final mode = 0o777 & ~umask.
_ = bun.sys.chmod(abs_target, 0o777 & ~umask);
Comment thread
robobun marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
}
}

Expand Down
6 changes: 4 additions & 2 deletions src/install/hoisted_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,10 @@ pub(crate) fn install_hoisted_packages(

new_node_modules = true;

// Attempt to create a new node_modules folder
if let Err(err) = sys::mkdir(bun_core::zstr!("node_modules"), 0o755) {
// Attempt to create a new node_modules folder. Pass 0o777 so the
// kernel's umask application decides the final permission
// (matches Node/npm/pnpm; issue #29723).
if let Err(err) = sys::mkdir(bun_core::zstr!("node_modules"), sys::UMASK_MKDIR_MODE) {
if err.errno != sys::E::EEXIST as _ {
Output::err(
err,
Expand Down
6 changes: 4 additions & 2 deletions src/install/hoisted_install.zig
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ pub fn installHoistedPackages(

new_node_modules = true;

// Attempt to create a new node_modules folder
if (bun.sys.mkdir("node_modules", 0o755).asErr()) |err| {
// Attempt to create a new node_modules folder. Pass 0o777 so the
// kernel's umask application decides the final permission
// (matches Node/npm/pnpm).
if (bun.sys.mkdir("node_modules", bun.umask_mkdir_mode).asErr()) |err| {
if (err.errno != @intFromEnum(bun.sys.E.EXIST)) {
Output.err(err, "could not create the <b>\"node_modules\"<r> directory", .{});
Global.crash();
Expand Down
Loading
Loading