From ac79ceed4f4adf9053dfb95b3f722e254da0ada5 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 25 Apr 2026 23:12:26 +0000 Subject: [PATCH 01/21] install: honor process umask when creating directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun install hard-coded 0o755 when creating cache/node_modules directories, and zeroed the process umask before installing packages. Under umask 0o002 users expected 0o775 (group-writable) directories like they get from npm and pnpm, but bun always produced 0o755 — blocking shared multi-user repos. Pass 0o777 to mkdirat instead and let the kernel subtract the caller's umask. With the default 0o022 umask the final mode is still 0o755, so behavior is unchanged for typical users. With umask 0o002 it becomes 0o775, matching Node, npm, pnpm, and /bin/mkdir. Also stop zeroing the process umask in Bin.Linker.ensureUmask. The places that relied on umask=0 already fchmod back to an explicit mode afterwards, so removing the umask(0) is safe, and keeping the user's umask means mkdir/openat see it. Fixes #29723 --- src/bun.zig | 37 ++++++++++++-- src/install/PackageInstall.zig | 8 ++-- src/install/PackageInstaller.zig | 2 +- src/install/PackageManager.zig | 2 +- .../PackageManagerDirectories.zig | 10 ++-- .../PackageManager/PackageManagerOptions.zig | 16 +++---- src/install/bin.zig | 19 +++++++- src/install/hoisted_install.zig | 6 ++- src/install/isolated_install.zig | 12 ++--- test/cli/install/bun-install.test.ts | 48 +++++++++++++++++++ 10 files changed, 127 insertions(+), 33 deletions(-) diff --git a/src/bun.zig b/src/bun.zig index a1cf3361bfa..f2ffcc1ae47 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -2283,13 +2283,22 @@ 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); @@ -2314,6 +2323,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); +} + /// 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 { @@ -2457,6 +2473,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( @@ -2472,12 +2490,23 @@ pub const MakePath = struct { ); } - return self.makeOpenPath(sub_path, opts); + // POSIX: avoid std's makeOpenPath which hardcodes mode 0o755. Try to + // openDir, falling back to our makePath (which uses umask_mkdir_mode) + // and retrying. + return self.openDir(sub_path, opts) catch |err| switch (err) { + error.FileNotFound => { + try MakePath.makePath(std.meta.Elem(@TypeOf(sub_path)), self, sub_path); + return self.openDir(sub_path, opts); + }, + 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, .{}); @@ -2488,7 +2517,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 diff --git a/src/install/PackageInstall.zig b/src/install/PackageInstall.zig index 21752443b7c..3a678880618 100644 --- a/src/install/PackageInstall.zig +++ b/src/install/PackageInstall.zig @@ -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); @@ -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( @@ -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; } } @@ -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| { diff --git a/src/install/PackageInstaller.zig b/src/install/PackageInstaller.zig index 60a9fee746c..111b5ed232f 100644 --- a/src/install/PackageInstaller.zig +++ b/src/install/PackageInstaller.zig @@ -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, .{ diff --git a/src/install/PackageManager.zig b/src/install/PackageManager.zig index d78c32759c9..e2397036966 100644 --- a/src/install/PackageManager.zig +++ b/src/install/PackageManager.zig @@ -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("error: node-gyp tempdir already exists", .{}); diff --git a/src/install/PackageManager/PackageManagerDirectories.zig b/src/install/PackageManager/PackageManagerDirectories.zig index d653a97e6e2..2d0ecf81d38 100644 --- a/src/install/PackageManager/PackageManagerDirectories.zig +++ b/src/install/PackageManager/PackageManagerDirectories.zig @@ -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("error: bun is unable to write files to tempdir: {s}", .{@errorName(err2)}); Global.crash(); }; @@ -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 { this.options.enable.cache = false; this.allocator.free(this.cache_directory_path); continue :loop; @@ -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("error: bun is unable to write files: {s}", .{@errorName(err)}); Global.crash(); }; @@ -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); @@ -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| { Output.err(err, "failed to open global link dir node_modules at '{f}'", .{FD.fromStdDir(global_dir)}); Global.exit(1); }; diff --git a/src/install/PackageManager/PackageManagerOptions.zig b/src/install/PackageManager/PackageManagerOptions.zig index df21858bf53..572daa92af7 100644 --- a/src/install/PackageManager/PackageManagerOptions.zig +++ b/src/install/PackageManager/PackageManagerOptions.zig @@ -156,25 +156,25 @@ 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, .{}); } return error.@"No global directory found"; @@ -182,13 +182,13 @@ pub fn openGlobalDir(explicit_global_dir: string) !std.fs.Dir { 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, .{}); } } } @@ -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| { @@ -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"; diff --git a/src/install/bin.zig b/src/install/bin.zig index 3a072fc24c4..e46a18bf1ab 100644 --- a/src/install/bin.zig +++ b/src/install/bin.zig @@ -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; } } @@ -797,7 +810,9 @@ 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); + // 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); } } diff --git a/src/install/hoisted_install.zig b/src/install/hoisted_install.zig index 87e27b42d5f..a01b453feaf 100644 --- a/src/install/hoisted_install.zig +++ b/src/install/hoisted_install.zig @@ -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 \"node_modules\" directory", .{}); Global.crash(); diff --git a/src/install/isolated_install.zig b/src/install/isolated_install.zig index 521e638b010..4f263674227 100644 --- a/src/install/isolated_install.zig +++ b/src/install/isolated_install.zig @@ -1267,8 +1267,8 @@ pub fn installIsolatedPackages( const node_modules_path = bun.OSPathLiteral("node_modules"); const bun_modules_path = bun.OSPathLiteral("node_modules/" ++ Store.modules_dir_name); - sys.mkdirat(FD.cwd(), node_modules_path, 0o755).unwrap() catch { - sys.mkdirat(FD.cwd(), bun_modules_path, 0o755).unwrap() catch { + sys.mkdirat(FD.cwd(), node_modules_path, bun.umask_mkdir_mode).unwrap() catch { + sys.mkdirat(FD.cwd(), bun_modules_path, bun.umask_mkdir_mode).unwrap() catch { break :is_new_bun_modules false; }; @@ -1291,7 +1291,7 @@ pub fn installIsolatedPackages( rename_path.append(mkdir_path.slice()); // 1 - sys.mkdirat(FD.cwd(), mkdir_path.sliceZ(), 0o755).unwrap() catch { + sys.mkdirat(FD.cwd(), mkdir_path.sliceZ(), bun.umask_mkdir_mode).unwrap() catch { break :is_new_bun_modules true; }; } @@ -1356,12 +1356,12 @@ pub fn installIsolatedPackages( }; // 2 - sys.mkdirat(FD.cwd(), node_modules_path, 0o755).unwrap() catch |err| { + sys.mkdirat(FD.cwd(), node_modules_path, bun.umask_mkdir_mode).unwrap() catch |err| { Output.err(err, "failed to create './node_modules'", .{}); Global.exit(1); }; - sys.mkdirat(FD.cwd(), bun_modules_path, 0o755).unwrap() catch |err| { + sys.mkdirat(FD.cwd(), bun_modules_path, bun.umask_mkdir_mode).unwrap() catch |err| { Output.err(err, "failed to create './node_modules/.bun'", .{}); Global.exit(1); }; @@ -1410,7 +1410,7 @@ pub fn installIsolatedPackages( break :is_new_bun_modules true; }; - sys.mkdirat(FD.cwd(), bun_modules_path, 0o755).unwrap() catch |err| { + sys.mkdirat(FD.cwd(), bun_modules_path, bun.umask_mkdir_mode).unwrap() catch |err| { Output.err(err, "failed to create './node_modules/.bun'", .{}); Global.exit(1); }; diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 818bea8e285..6601b1e2e62 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -8877,6 +8877,54 @@ describe.concurrent("bun-install", () => { }); }); + // Regression for https://github.com/oven-sh/bun/issues/29723: + // `bun install` hard-coded 0o755 when creating directories, which meant the + // caller's umask could never loosen the perms beyond 0o755. With + // `umask 0o002` a user would still get 0o755 cache/node_modules dirs — + // blocking shared multi-user repos. + // + // POSIX-only. Windows has no umask, and directory creation goes through a + // different code path there. + it.skipIf(isWindows)("respects process umask when creating install directories (#29723)", async () => { + const pkgDir = tempDirWithFiles("bun-install-umask-29723", { + "baz-0.0.3.tgz": await file(join(import.meta.dir, "baz-0.0.3.tgz")).arrayBuffer(), + "package.json": JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + baz: "file:baz-0.0.3.tgz", + }, + }), + // Cache enabled so the cache directory gets created too — this is + // what BUN_INSTALL_CACHE_DIR points at in the bug report. + "bunfig.toml": `[install]\ncache = "./.umask-cache"\n`, + }); + const cacheDir = join(pkgDir, ".umask-cache"); + + // Drive umask through a shell wrapper — calling process.umask() in the + // test runner would leak into unrelated concurrent tests. + await using proc = Bun.spawn({ + cmd: ["sh", "-c", `umask 0002 && exec "$@"`, "sh", bunExe(), "install"], + cwd: pkgDir, + env, + stderr: "pipe", + stdout: "pipe", + stdin: "ignore", + }); + const [errText, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(errText).not.toContain("panic:"); + expect(errText).not.toContain("error:"); + expect(exitCode).toBe(0); + + const statMode = (p: string) => stat(p).then(s => s.mode & 0o777); + + // Directories we create: final mode = 0o777 & ~0o002 = 0o775. + // These are the paths the reporter specifically called out. + expect(await statMode(join(pkgDir, "node_modules"))).toBe(0o775); + expect(await statMode(join(pkgDir, "node_modules", "baz"))).toBe(0o775); + expect(await statMode(cacheDir)).toBe(0o775); + }); + it("should handle @scoped name that contains tilde, issue#7045", async () => { await withContext(defaultOpts, async ctx => { await writeFile( From 30d66e811149f3154998af1d0b054712cef9cc10 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 25 Apr 2026 23:29:49 +0000 Subject: [PATCH 02/21] install(bin): prime umask in createSymlink so isolated mode honors it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bin.Linker.ensureUmask() was only called from hoisted_install.zig, link_command.zig, and unlink_command.zig. The isolated installer went straight to Bin.Linker.link() without priming, so Bin.Linker.umask stayed at its default 0 and the new chmod(target, 0o777 & ~umask) degenerated to 0o777 — bin targets ignored umask under --linker=isolated. Call ensureUmask() from createSymlink itself. The guard inside ensureUmask() makes redundant calls a no-op. Test expanded to cover both linker modes. --- src/install/bin.zig | 6 ++ test/cli/install/bun-install.test.ts | 97 +++++++++++++++++----------- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/src/install/bin.zig b/src/install/bin.zig index e46a18bf1ab..5f8a4aee64a 100644 --- a/src/install/bin.zig +++ b/src/install/bin.zig @@ -810,6 +810,12 @@ 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) { + // Make sure `umask` is populated. The hoisted installer + // and `bun link`/`bun unlink` prime it, but the isolated + // installer goes straight to `Bin.Linker.link()` — without + // this call `umask` would stay 0 and the chmod below + // would unconditionally widen to 0o777. + ensureUmask(); // 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); diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 6601b1e2e62..23e00a5616c 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -8885,45 +8885,66 @@ describe.concurrent("bun-install", () => { // // POSIX-only. Windows has no umask, and directory creation goes through a // different code path there. - it.skipIf(isWindows)("respects process umask when creating install directories (#29723)", async () => { - const pkgDir = tempDirWithFiles("bun-install-umask-29723", { - "baz-0.0.3.tgz": await file(join(import.meta.dir, "baz-0.0.3.tgz")).arrayBuffer(), - "package.json": JSON.stringify({ - name: "foo", - version: "0.0.1", - dependencies: { - baz: "file:baz-0.0.3.tgz", - }, - }), - // Cache enabled so the cache directory gets created too — this is - // what BUN_INSTALL_CACHE_DIR points at in the bug report. - "bunfig.toml": `[install]\ncache = "./.umask-cache"\n`, - }); - const cacheDir = join(pkgDir, ".umask-cache"); - - // Drive umask through a shell wrapper — calling process.umask() in the - // test runner would leak into unrelated concurrent tests. - await using proc = Bun.spawn({ - cmd: ["sh", "-c", `umask 0002 && exec "$@"`, "sh", bunExe(), "install"], - cwd: pkgDir, - env, - stderr: "pipe", - stdout: "pipe", - stdin: "ignore", - }); - const [errText, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(errText).not.toContain("panic:"); - expect(errText).not.toContain("error:"); - expect(exitCode).toBe(0); - - const statMode = (p: string) => stat(p).then(s => s.mode & 0o777); + // + // Parametrized over the two linkers because they share almost no directory- + // creation code — isolated goes through `isolated_install.zig` / + // `isolated_install/Installer.zig`, hoisted through `hoisted_install.zig` + // and `PackageInstall.installWithHardlink`. Bin linking in particular + // relied on `Bin.Linker.ensureUmask()` being primed by the hoisted entry + // point; the isolated path never called it, so bin targets would chmod to + // 0o777 regardless of umask. + for (const linker of ["hoisted", "isolated"] as const) { + it.skipIf(isWindows)( + `${linker} install respects process umask for directories and bin targets (#29723)`, + async () => { + const pkgDir = tempDirWithFiles(`bun-install-umask-29723-${linker}`, { + // Local tarball that ships executable bins — exercises both the + // package-dir mkdir path and `Bin.Linker.createSymlink`'s chmod. + "multi-tool-pkg-1.0.0.tgz": await file( + join(import.meta.dir, "multi-tool-pkg-1.0.0.tgz"), + ).arrayBuffer(), + "package.json": JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + "multi-tool-pkg": "file:multi-tool-pkg-1.0.0.tgz", + }, + }), + // Cache enabled so the cache directory gets created too — this is + // what BUN_INSTALL_CACHE_DIR points at in the bug report. + "bunfig.toml": `[install]\ncache = "./.umask-cache"\nlinker = "${linker}"\n`, + }); + const cacheDir = join(pkgDir, ".umask-cache"); - // Directories we create: final mode = 0o777 & ~0o002 = 0o775. - // These are the paths the reporter specifically called out. - expect(await statMode(join(pkgDir, "node_modules"))).toBe(0o775); - expect(await statMode(join(pkgDir, "node_modules", "baz"))).toBe(0o775); - expect(await statMode(cacheDir)).toBe(0o775); - }); + // Drive umask through a shell wrapper — calling process.umask() in + // the test runner would leak into unrelated concurrent tests. + await using proc = Bun.spawn({ + cmd: ["sh", "-c", `umask 0002 && exec "$@"`, "sh", bunExe(), "install"], + cwd: pkgDir, + env, + stderr: "pipe", + stdout: "pipe", + stdin: "ignore", + }); + const [errText, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(errText).not.toContain("panic:"); + expect(errText).not.toContain("error:"); + expect(exitCode).toBe(0); + + const statMode = (p: string) => stat(p).then(s => s.mode & 0o777); + + // Directories we create: final mode = 0o777 & ~0o002 = 0o775. + expect(await statMode(join(pkgDir, "node_modules"))).toBe(0o775); + expect(await statMode(cacheDir)).toBe(0o775); + + // Bin target — `stat` follows the symlink so this is the mode of + // the executable file that `Bin.Linker.createSymlink` chmod'd. + // Pre-fix isolated installs left this at 0o777. + const binLink = join(pkgDir, "node_modules", ".bin", "multi-tool"); + expect((await stat(binLink)).mode & 0o777).toBe(0o775); + }, + ); + } it("should handle @scoped name that contains tilde, issue#7045", async () => { await withContext(defaultOpts, async ctx => { From 0e951a94a1d4211fa96bb292d7c7a8b89340c683 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:31:34 +0000 Subject: [PATCH 03/21] [autofix.ci] apply automated fixes --- test/cli/install/bun-install.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 23e00a5616c..308e45886cf 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -8900,9 +8900,7 @@ describe.concurrent("bun-install", () => { const pkgDir = tempDirWithFiles(`bun-install-umask-29723-${linker}`, { // Local tarball that ships executable bins — exercises both the // package-dir mkdir path and `Bin.Linker.createSymlink`'s chmod. - "multi-tool-pkg-1.0.0.tgz": await file( - join(import.meta.dir, "multi-tool-pkg-1.0.0.tgz"), - ).arrayBuffer(), + "multi-tool-pkg-1.0.0.tgz": await file(join(import.meta.dir, "multi-tool-pkg-1.0.0.tgz")).arrayBuffer(), "package.json": JSON.stringify({ name: "foo", version: "0.0.1", From 40678deeaea1d8d3a19ece5428ced952875f483a Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 25 Apr 2026 23:35:02 +0000 Subject: [PATCH 04/21] install: use dangling-symlink-safe makePath in MakePath.makeOpenPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit correctly flagged that MakePath.makePath still has the old PathAlreadyExists TODO — a dangling intermediate symlink can make it loop forever between parent and child components. Route the POSIX makeOpenPath fallback through the file-level bun.makePath instead, which lstats and rm-rf's the dangling entry. Also drop the disallowed 'panic:' check in the test per test guidelines. --- src/bun.zig | 16 ++++++++++++---- test/cli/install/bun-install.test.ts | 1 - 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/bun.zig b/src/bun.zig index f2ffcc1ae47..79df1a783e3 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -2376,6 +2376,11 @@ pub inline fn OSPathLiteral(comptime literal: anytype) *const [literal.len:0]OSP pub const MakePath = struct { const w = std.os.windows; + // Alias for the file-level `makePath`. The local `makePath` below + // shadows it, so `MakePath.makeOpenPath` needs this to reach the + // dangling-symlink-safe version. + const makePathSafe = @import("bun").makePath; + // TODO(@paperclover): upstream making this public into zig std // there is zero reason this must be copied // @@ -2490,12 +2495,15 @@ pub const MakePath = struct { ); } - // POSIX: avoid std's makeOpenPath which hardcodes mode 0o755. Try to - // openDir, falling back to our makePath (which uses umask_mkdir_mode) - // and retrying. + // 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. + // Using the local `MakePath.makePath` here would infinite-loop on a + // dangling intermediate symlink — the file-level one lstats and + // rm-rf's it. return self.openDir(sub_path, opts) catch |err| switch (err) { error.FileNotFound => { - try MakePath.makePath(std.meta.Elem(@TypeOf(sub_path)), self, sub_path); + try makePathSafe(self, sub_path); return self.openDir(sub_path, opts); }, else => |e| return e, diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 308e45886cf..f15ab7ced6b 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -8925,7 +8925,6 @@ describe.concurrent("bun-install", () => { stdin: "ignore", }); const [errText, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(errText).not.toContain("panic:"); expect(errText).not.toContain("error:"); expect(exitCode).toBe(0); From 6f95263602ffc7197c360faace29e5f681e766bc Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 25 Apr 2026 23:58:10 +0000 Subject: [PATCH 05/21] install: stat dir-relative in makePath's dangling-symlink recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] flagged that `bun.makePath`'s `PathAlreadyExists` branch calls `sys.lstat(component.path)`, which resolves against the process CWD — but the surrounding `mkdirat` and `deleteTree` both operate relative to the `dir` fd. Pre-fix to this PR the only caller was cwd so it happened to work; after e40726b rerouted `MakePath.makeOpenPath` through here, non-cwd callers (node_modules fds from PackageInstall, cache dir fds from PackageManagerDirectories, etc.) now risk statting the wrong location. Swap `sys.lstat` for `sys.lstatat(.fromStdDir(dir), path)` so the stat lines up with the mkdirat/deleteTree it's classifying. --- src/bun.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/bun.zig b/src/bun.zig index 79df1a783e3..f1da9278778 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -2305,7 +2305,13 @@ pub fn makePath(dir: std.fs.Dir, sub_path: []const u8) !void { 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 when `dir` isn't cwd (issue noticed on #29726 + // after MakePath.makeOpenPath started routing non-cwd + // callers through here). + const result = try sys.lstatat(.fromStdDir(dir), path_to_use).unwrap(); const is_dir = S.ISDIR(@intCast(result.mode)); // dangling symlink if (!is_dir) { From 78ee2049bb08660c7f8e84542196025d9ed94778 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Apr 2026 00:00:45 +0000 Subject: [PATCH 06/21] install(isolated): prime umask on main thread, not inside createSymlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] flagged that calling ensureUmask() from createSymlink runs it on thread-pool workers under --linker=isolated. ensureUmask() is not thread-safe — plain bool + two non-atomic umask(2) syscalls — so concurrent workers can: - both see has_set_umask==false before either writes true - one calls umask(0) → reads previous 0o022, then umask(0o022) → restores - other calls umask(0) → reads 0 (just set by first), then umask(0) → process umask permanently 0 - subsequent mkdirat(..., 0o777) produces 0o777 dirs, defeating this PR Also, even without that worst-case interleaving, there's a window where umask is 0 while other workers mkdirat concurrently — those dirs come out 0o777. Prime ensureUmask() from installIsolatedPackages (main thread) the same way hoisted_install.zig:91 does, and drop the lazy call inside createSymlink. --- src/install/bin.zig | 14 ++++++++------ src/install/isolated_install.zig | 11 +++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/install/bin.zig b/src/install/bin.zig index 5f8a4aee64a..6f04f22791a 100644 --- a/src/install/bin.zig +++ b/src/install/bin.zig @@ -810,14 +810,16 @@ 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) { - // Make sure `umask` is populated. The hoisted installer - // and `bun link`/`bun unlink` prime it, but the isolated - // installer goes straight to `Bin.Linker.link()` — without - // this call `umask` would stay 0 and the chmod below - // would unconditionally widen to 0o777. - ensureUmask(); // Mark the bin executable. Honor the process umask the // same way npm and pnpm do: final mode = 0o777 & ~umask. + // + // `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. _ = bun.sys.chmod(abs_target, 0o777 & ~umask); } } diff --git a/src/install/isolated_install.zig b/src/install/isolated_install.zig index 4f263674227..1cff2d10e4e 100644 --- a/src/install/isolated_install.zig +++ b/src/install/isolated_install.zig @@ -10,6 +10,16 @@ pub fn installIsolatedPackages( ) OOM!PackageInstall.Summary { bun.analytics.Features.isolated_bun_install += 1; + // Prime Bin.Linker.umask on the main thread before any bin-linking + // tasks are scheduled. The isolated installer runs `Bin.Linker.link()` + // on thread-pool workers; `ensureUmask()` isn't thread-safe (plain + // bool + two non-atomic `umask(2)` syscalls) so calling it from + // concurrent workers races and can leave the process umask at 0. + // Hoisted install primes it the same way on its main-thread entry. + if (comptime Environment.isPosix) { + Bin.Linker.ensureUmask(); + } + const lockfile = manager.lockfile; const store: Store = store: { @@ -1940,6 +1950,7 @@ const sys = bun.sys; const Command = bun.cli.Command; const install = bun.install; +const Bin = install.Bin; const DependencyID = install.DependencyID; const PackageID = install.PackageID; const PackageInstall = install.PackageInstall; From 16ef9e756daa325c60d56a43a86fbf3a237b87a7 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Apr 2026 00:04:58 +0000 Subject: [PATCH 07/21] ci: retry WebKit fetch after transient 502 Previous build hit HTTP 502 downloading the prebuilt WebKit tarball. No code change. From 984977680f6a12b9fbb31af266602a43ac59ab8b Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Apr 2026 00:25:42 +0000 Subject: [PATCH 08/21] install: override BUN_INSTALL_CACHE_DIR in umask test, drop redundant self-import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] caught two issues on af45fc1/caf49d9: 1. The umask test set the cache dir via bunfig (`cache = "./.umask-cache"`), but CI's runner.node.mjs:1171 sets BUN_INSTALL_CACHE_DIR, and the env var wins over bunfig in fetchCacheDirectoryPath. So in CI the bunfig setting was ignored, the expected cache dir never got created, and stat(cacheDir) would throw ENOENT. Pass the cacheDir explicitly through env so bunfig and env agree. 2. The `makePathSafe = @import("bun").makePath` alias in MakePath was redundant — `const bun = @This();` at the top of bun.zig is already in scope here (other code inside the same struct uses bun.* already). Only the bare `makePath` identifier is shadowed; `bun.makePath` is a qualified lookup that resolves to the file-level function. Drop the alias and call bun.makePath directly. --- src/bun.zig | 14 +++++--------- test/cli/install/bun-install.test.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/bun.zig b/src/bun.zig index f1da9278778..309648f9ff0 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -2382,11 +2382,6 @@ pub inline fn OSPathLiteral(comptime literal: anytype) *const [literal.len:0]OSP pub const MakePath = struct { const w = std.os.windows; - // Alias for the file-level `makePath`. The local `makePath` below - // shadows it, so `MakePath.makeOpenPath` needs this to reach the - // dangling-symlink-safe version. - const makePathSafe = @import("bun").makePath; - // TODO(@paperclover): upstream making this public into zig std // there is zero reason this must be copied // @@ -2504,12 +2499,13 @@ pub const MakePath = struct { // 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. - // Using the local `MakePath.makePath` here would infinite-loop on a - // dangling intermediate symlink — the file-level one lstats and - // rm-rf's it. + // 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 makePathSafe(self, sub_path); + try bun.makePath(self, sub_path); return self.openDir(sub_path, opts); }, else => |e| return e, diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index f15ab7ced6b..c2b09c56a88 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -8908,18 +8908,20 @@ describe.concurrent("bun-install", () => { "multi-tool-pkg": "file:multi-tool-pkg-1.0.0.tgz", }, }), - // Cache enabled so the cache directory gets created too — this is - // what BUN_INSTALL_CACHE_DIR points at in the bug report. - "bunfig.toml": `[install]\ncache = "./.umask-cache"\nlinker = "${linker}"\n`, + "bunfig.toml": `[install]\nlinker = "${linker}"\n`, }); const cacheDir = join(pkgDir, ".umask-cache"); // Drive umask through a shell wrapper — calling process.umask() in // the test runner would leak into unrelated concurrent tests. + // + // BUN_INSTALL_CACHE_DIR wins over bunfig's `cache = "..."`, and the + // CI runner sets it to a per-test tmpdir. Point it at our own dir + // so the assertion below checks the cache we actually created. await using proc = Bun.spawn({ cmd: ["sh", "-c", `umask 0002 && exec "$@"`, "sh", bunExe(), "install"], cwd: pkgDir, - env, + env: { ...env, BUN_INSTALL_CACHE_DIR: cacheDir }, stderr: "pipe", stdout: "pipe", stdin: "ignore", From 98b768cad9098f743b5e1bd77d33f487711bcbf3 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Apr 2026 00:28:23 +0000 Subject: [PATCH 09/21] install(bin): assert umask was primed before bin chmod coderabbit flagged this as a defensive nit. Add a debug-only bun.assertWithLocation(has_set_umask, @src()) guard before the chmod so a future caller that forgets to call ensureUmask() on the main thread trips immediately instead of silently producing 0o777 bins (what the pre-PR code always did, and what a miss would regress back to). No behavior change in release builds. --- src/install/bin.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/install/bin.zig b/src/install/bin.zig index 6f04f22791a..fb2cfeeadef 100644 --- a/src/install/bin.zig +++ b/src/install/bin.zig @@ -810,9 +810,6 @@ 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) { - // Mark the bin executable. Honor the process umask the - // same way npm and pnpm do: final mode = 0o777 & ~umask. - // // `umask` is populated by `ensureUmask()`, which the // hoisted installer, isolated installer, and // `bun link`/`bun unlink` all call on the main thread @@ -820,6 +817,13 @@ pub const Bin = extern struct { // 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); } } From 2132f34d927a60d47b0f2a774bea3b3fe005635b Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Apr 2026 00:55:58 +0000 Subject: [PATCH 10/21] install(link/unlink): umask-aware global node_modules creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] caught two parallel call sites this PR missed: link_command.zig:69 and unlink_command.zig:82 both created $BUN_INSTALL/install/global/node_modules via std's Dir.makeOpenPath (hardcoded 0o755) instead of going through the converted globalLinkDir() in PackageManagerDirectories.zig:389. link_command.zig also creates the `@scope` subdir via std's Dir.makeDir. Route them through bun.MakePath.makeOpenPath / bun.makePath so they honor umask like the rest of the install-path dirs fixed in this PR. Verified with `umask 002 && bun link` on both unscoped and @scope packages — 0o775 everywhere. Default `umask 022` still gives 0o755. --- src/runtime/cli/link_command.zig | 7 +++++-- src/runtime/cli/unlink_command.zig | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/runtime/cli/link_command.zig b/src/runtime/cli/link_command.zig index 97fe5213d0a..357bdafa32d 100644 --- a/src/runtime/cli/link_command.zig +++ b/src/runtime/cli/link_command.zig @@ -66,7 +66,7 @@ fn link(ctx: Command.Context) !void { try manager.setupGlobalDir(ctx); - break :brk manager.global_dir.?.makeOpenPath("node_modules", .{}) catch |err| { + break :brk bun.MakePath.makeOpenPath(manager.global_dir.?, "node_modules", .{}) catch |err| { if (manager.options.log_level != .silent) Output.prettyErrorln("error: failed to create node_modules in global dir due to error {s}", .{@errorName(err)}); Global.crash(); @@ -81,7 +81,10 @@ fn link(ctx: Command.Context) !void { // create scope if specified if (name[0] == '@') { if (strings.indexOfChar(name, '/')) |i| { - node_modules.makeDir(name[0..i]) catch |err| brk: { + // Use bun.makePath so the mkdir honors the process umask + // the same way the surrounding dirs this PR fixed do + // (0o777 & ~umask instead of std's hardcoded 0o755). + bun.makePath(node_modules, name[0..i]) catch |err| brk: { if (err == error.PathAlreadyExists) break :brk; if (manager.options.log_level != .silent) Output.prettyErrorln("error: failed to create scope in global dir due to error {s}", .{@errorName(err)}); diff --git a/src/runtime/cli/unlink_command.zig b/src/runtime/cli/unlink_command.zig index 86d1a935546..b157f86054e 100644 --- a/src/runtime/cli/unlink_command.zig +++ b/src/runtime/cli/unlink_command.zig @@ -79,7 +79,7 @@ fn unlink(ctx: Command.Context) !void { try manager.setupGlobalDir(ctx); - break :brk manager.global_dir.?.makeOpenPath("node_modules", .{}) catch |err| { + break :brk bun.MakePath.makeOpenPath(manager.global_dir.?, "node_modules", .{}) catch |err| { if (manager.options.log_level != .silent) Output.prettyErrorln("error: failed to create node_modules in global dir due to error {s}", .{@errorName(err)}); Global.crash(); From 1d66d9347be617972ade81168fe6ed24c921af9f Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Apr 2026 01:13:54 +0000 Subject: [PATCH 11/21] install(link): drop dead PathAlreadyExists check after bun.makePath swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] caught this: when 39810b6 swapped node_modules.makeDir() for bun.makePath(), the surrounding 'if (err == error.PathAlreadyExists) break :brk;' became unreachable. bun.makePath handles EEXIST internally (lstatat + deleteTree-or-continue, src/bun.zig:2209-2227) and never propagates error.PathAlreadyExists to callers. No behavior change — verified scoped link still works on both fresh global dir and one where @scope already exists. --- src/runtime/cli/link_command.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/runtime/cli/link_command.zig b/src/runtime/cli/link_command.zig index 357bdafa32d..3ebf2c0e61a 100644 --- a/src/runtime/cli/link_command.zig +++ b/src/runtime/cli/link_command.zig @@ -78,14 +78,14 @@ fn link(ctx: Command.Context) !void { // delete it if it exists node_modules.deleteTree(name) catch {}; - // create scope if specified + // create scope if specified. Use bun.makePath so the mkdir + // honors the process umask the same way the surrounding dirs + // this PR fixed do (0o777 & ~umask vs std's hardcoded 0o755). + // bun.makePath handles PathAlreadyExists internally, so no + // special case is needed here. if (name[0] == '@') { if (strings.indexOfChar(name, '/')) |i| { - // Use bun.makePath so the mkdir honors the process umask - // the same way the surrounding dirs this PR fixed do - // (0o777 & ~umask instead of std's hardcoded 0o755). - bun.makePath(node_modules, name[0..i]) catch |err| brk: { - if (err == error.PathAlreadyExists) break :brk; + bun.makePath(node_modules, name[0..i]) catch |err| { if (manager.options.log_level != .silent) Output.prettyErrorln("error: failed to create scope in global dir due to error {s}", .{@errorName(err)}); Global.crash(); From 03ff162d593e58db4a775b660e6f3121cd4f479b Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Apr 2026 01:26:32 +0000 Subject: [PATCH 12/21] install: reword self-referential comments claude[bot] noted that 'this PR fixed' in the link_command comment and the '#29726' reference in bun.zig's makePath both lose meaning after merge. Reword them to describe the invariant directly instead. --- src/bun.zig | 4 +--- src/runtime/cli/link_command.zig | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/bun.zig b/src/bun.zig index 309648f9ff0..2c40e7f64e0 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -2308,9 +2308,7 @@ pub fn makePath(dir: std.fs.Dir, sub_path: []const u8) !void { // 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 when `dir` isn't cwd (issue noticed on #29726 - // after MakePath.makeOpenPath started routing non-cwd - // callers through here). + // 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 diff --git a/src/runtime/cli/link_command.zig b/src/runtime/cli/link_command.zig index 3ebf2c0e61a..64a2f9aa0cf 100644 --- a/src/runtime/cli/link_command.zig +++ b/src/runtime/cli/link_command.zig @@ -79,10 +79,10 @@ fn link(ctx: Command.Context) !void { node_modules.deleteTree(name) catch {}; // create scope if specified. Use bun.makePath so the mkdir - // honors the process umask the same way the surrounding dirs - // this PR fixed do (0o777 & ~umask vs std's hardcoded 0o755). - // bun.makePath handles PathAlreadyExists internally, so no - // special case is needed here. + // honors the process umask (final mode = 0o777 & ~umask) like + // every other install-created directory, instead of + // std.fs.Dir.makeDir's hardcoded 0o755. bun.makePath also + // handles PathAlreadyExists internally, so no catch is needed. if (name[0] == '@') { if (strings.indexOfChar(name, '/')) |i| { bun.makePath(node_modules, name[0..i]) catch |err| { From a5552cf672c56f8c3e15a9e71739b283afb2c656 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 4 May 2026 13:16:41 +0000 Subject: [PATCH 13/21] ci: retrigger build after agents expired before running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build #51083 expired all 42+ jobs in the queue before they could pick up agents — no actual test or build failure, just queue pressure. Empty commit to schedule a fresh run. From fac270f708f1f2680d5d90631dd3604a0830d1f0 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 16 May 2026 23:33:00 +0000 Subject: [PATCH 14/21] install(rust port): honor umask in directory creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto main brought in the Zig→Rust port of install/. The Zig-side umask fix in this PR was untouched by the rebase, but the binary now runs the Rust port where: - src/sys/lib.rs mkdir_recursive_at defaults to mode 0o755, which stops the kernel's umask application from ever loosening perms beyond 0o755. - src/install/{hoisted,isolated}_install.rs and PackageInstall.rs pass hard-coded 0o755 to sys::mkdir / sys::mkdirat. - isolated_install.rs never primes Bin::Linker::ensure_umask on the main thread — same gap claude[bot] flagged on the Zig side. Port the fixes: - New bun_sys::UMASK_MKDIR_MODE = 0o777 constant. - mkdir_recursive_at and Dir::make_dir use it as default. - hoisted_install.rs, isolated_install.rs, PackageInstall.rs pass it where they had 0o755. - install_isolated_packages primes bin::Linker::ensure_umask on the main thread before scheduling work (ensure_umask is already atomic via compare_exchange in the Rust port). Verified: under umask 0o002 hoisted + isolated now produce 0o775 dirs and 0o775 bin targets; default umask 0o022 still yields 0o755. Regression test (test/cli/install/bun-install.test.ts) fails without this commit and passes with it. --- src/install/PackageInstall.rs | 4 ++-- src/install/hoisted_install.rs | 6 ++++-- src/install/isolated_install.rs | 22 ++++++++++++++++------ src/sys/dir.rs | 8 ++++++-- src/sys/lib.rs | 17 ++++++++++++++++- 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/install/PackageInstall.rs b/src/install/PackageInstall.rs index abea7ad087c..3123d6f5a15 100644 --- a/src/install/PackageInstall.rs +++ b/src/install/PackageInstall.rs @@ -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(); @@ -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; } } diff --git a/src/install/hoisted_install.rs b/src/install/hoisted_install.rs index 86206cbe894..05ad33465f0 100644 --- a/src/install/hoisted_install.rs +++ b/src/install/hoisted_install.rs @@ -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, diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 40d22bd7e0b..832cc85577f 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -40,6 +40,7 @@ use bun_sys::{self as sys, Fd}; use bun_wyhash::{Wyhash, Wyhash11}; use crate::analytics; +use crate::bin_real as bin; use crate::bun_bunfig::Arguments as Command; use crate::bun_progress::{Node as ProgressNode, Progress}; use crate::lockfile::tree::is_filtered_dependency_or_workspace; @@ -223,6 +224,15 @@ pub(crate) fn install_isolated_packages( ) -> Result { analytics::features::isolated_bun_install.fetch_add(1, Ordering::Relaxed); + // Prime `Bin::Linker::UMASK` on the main thread before any bin-linking + // tasks are scheduled. `ensure_umask()` uses a compare-exchange gate + // plus umask(0)/umask(prev) probe on the process-global umask — calling + // it later from worker threads would add a window where umask is + // temporarily 0 while concurrent mkdirat calls run (issue #29723). + // Hoisted install primes it the same way from its main-thread entry. + #[cfg(unix)] + bin::Linker::ensure_umask(); + // Take a raw pointer so column borrows below don't tie up `&mut manager` // (which owns the lockfile). let lockfile: *mut Lockfile = &raw mut *manager.lockfile; @@ -1675,12 +1685,12 @@ pub(crate) fn install_isolated_packages( // matches `Installer::NODE_MODULES_BUN`. let bun_modules_path = paths::path_literal!("node_modules/.bun"); - match sys::mkdirat(Fd::cwd(), node_modules_path, 0o755) { + match sys::mkdirat(Fd::cwd(), node_modules_path, sys::UMASK_MKDIR_MODE) { Ok(()) => { // fallthrough to creating bun_modules below } Err(_) => { - match sys::mkdirat(Fd::cwd(), bun_modules_path, 0o755) { + match sys::mkdirat(Fd::cwd(), bun_modules_path, sys::UMASK_MKDIR_MODE) { Err(_) => break 'is_new_bun_modules false, Ok(()) => {} } @@ -1707,7 +1717,7 @@ pub(crate) fn install_isolated_packages( .assume_ok(); // 1 - if sys::mkdirat(Fd::cwd(), rename_path.slice_z(), 0o755).is_err() { + if sys::mkdirat(Fd::cwd(), rename_path.slice_z(), sys::UMASK_MKDIR_MODE).is_err() { break 'is_new_bun_modules true; } @@ -1817,12 +1827,12 @@ pub(crate) fn install_isolated_packages( } // 2 - if let Err(err) = sys::mkdirat(Fd::cwd(), node_modules_path, 0o755) { + if let Err(err) = sys::mkdirat(Fd::cwd(), node_modules_path, sys::UMASK_MKDIR_MODE) { Output::err(err, "failed to create './node_modules'", format_args!("")); Global::exit(1); } - if let Err(err) = sys::mkdirat(Fd::cwd(), bun_modules_path, 0o755) { + if let Err(err) = sys::mkdirat(Fd::cwd(), bun_modules_path, sys::UMASK_MKDIR_MODE) { Output::err( err, "failed to create './node_modules/.bun'", @@ -1904,7 +1914,7 @@ pub(crate) fn install_isolated_packages( } } - if let Err(err) = sys::mkdirat(Fd::cwd(), bun_modules_path, 0o755) { + if let Err(err) = sys::mkdirat(Fd::cwd(), bun_modules_path, sys::UMASK_MKDIR_MODE) { Output::err( err, "failed to create './node_modules/.bun'", diff --git a/src/sys/dir.rs b/src/sys/dir.rs index 1b63424ae09..df673245c1f 100644 --- a/src/sys/dir.rs +++ b/src/sys/dir.rs @@ -329,9 +329,13 @@ pub struct CreateFlags { } impl Dir { - /// Single-level `mkdirat` (mode 0o755) relative to + /// Single-level `mkdirat` relative to /// this dir. Unlike `make_path`, does NOT create intermediate directories /// and surfaces `error.PathAlreadyExists` for callers to branch on. + /// + /// Passes `UMASK_MKDIR_MODE` (0o777) so the kernel applies the process + /// umask — default umask `0o022` still yields `0o755`, `umask 0o002` + /// yields `0o775` (issue #29723). pub fn make_dir(&self, sub_path: &[u8]) -> core::result::Result<(), bun_core::Error> { let mut buf = bun_paths::PathBuffer::default(); let len = sub_path.len().min(buf.0.len() - 1); @@ -339,7 +343,7 @@ impl Dir { buf.0[len] = 0; // SAFETY: NUL-terminated above. let z = ZStr::from_buf(&buf.0[..], len); - match mkdirat(self.fd, z, 0o755) { + match mkdirat(self.fd, z, UMASK_MKDIR_MODE) { Ok(()) => Ok(()), Err(e) if e.get_errno() == E::EEXIST => Err(bun_core::err!("PathAlreadyExists")), Err(e) => Err(e.into()), diff --git a/src/sys/lib.rs b/src/sys/lib.rs index bda5fb865ce..382323f9248 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -976,6 +976,15 @@ impl AsFd for &Dir { } } +/// 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 `mkdir(1)` +/// (issue #29723). +pub const UMASK_MKDIR_MODE: Mode = 0o777; + // Raw Linux syscalls via rustix (linux_raw backend). Hot-path I/O on Linux // routes through here instead of glibc — see module doc. Android: same kernel, // same syscall ABI; `linux_syscall.rs` carries its own @@ -2438,10 +2447,16 @@ mod posix_impl { Ok(()) } /// `bun.makePath` — `mkdirat` walking up parents on ENOENT, like `mkdir -p`. + /// + /// Passes `UMASK_MKDIR_MODE` (0o777) so the kernel applies the process + /// umask to the final permissions, matching `mkdir(1)`, Node, npm, and + /// pnpm. Default umask `0o022` still yields `0o755`; `umask 0o002` + /// yields `0o775` — letting multi-user repos work with bun install + /// (issue #29723). #[inline] pub fn mkdir_recursive_at(dir: impl AsFd, sub_path: &[u8]) -> Maybe<()> { let dir = dir.as_fd(); - mkdir_recursive_at_mode(dir, sub_path, 0o755) + mkdir_recursive_at_mode(dir, sub_path, UMASK_MKDIR_MODE) } /// `mkdir_recursive_at` with an explicit `mode` for created directories /// (matches `bun.api.node.fs.NodeFS.mkdirRecursive`'s `mode` arg). From 7136d14c542b34ad6aea9f84bae4fbe97d49ef71 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 23:34:53 +0000 Subject: [PATCH 15/21] [autofix.ci] apply automated fixes --- src/install/isolated_install.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 832cc85577f..84d7b56cf19 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -1717,7 +1717,9 @@ pub(crate) fn install_isolated_packages( .assume_ok(); // 1 - if sys::mkdirat(Fd::cwd(), rename_path.slice_z(), sys::UMASK_MKDIR_MODE).is_err() { + if sys::mkdirat(Fd::cwd(), rename_path.slice_z(), sys::UMASK_MKDIR_MODE) + .is_err() + { break 'is_new_bun_modules true; } @@ -1827,12 +1829,16 @@ pub(crate) fn install_isolated_packages( } // 2 - if let Err(err) = sys::mkdirat(Fd::cwd(), node_modules_path, sys::UMASK_MKDIR_MODE) { + if let Err(err) = + sys::mkdirat(Fd::cwd(), node_modules_path, sys::UMASK_MKDIR_MODE) + { Output::err(err, "failed to create './node_modules'", format_args!("")); Global::exit(1); } - if let Err(err) = sys::mkdirat(Fd::cwd(), bun_modules_path, sys::UMASK_MKDIR_MODE) { + if let Err(err) = + sys::mkdirat(Fd::cwd(), bun_modules_path, sys::UMASK_MKDIR_MODE) + { Output::err( err, "failed to create './node_modules/.bun'", From 569d60245f1a7d87e8e32cb1f8200d92d26bab39 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 16 May 2026 23:58:54 +0000 Subject: [PATCH 16/21] install(bin): port debug assert to chmod_on_ok, drop stale doc wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] caught two nits after the rebase onto the Rust port: - src/sys/lib.rs: the UMASK_MKDIR_MODE doc said "install-owned directories" but the constant is the default for the general-purpose mkdir_recursive_at / Dir::make_dir helpers, which the bundler, DevServer, router, bunx, profilers, etc. all reach via make_path / make_open_path. The Zig sibling in the same PR already says "when creating directories" — match that wording. - src/install/bin.rs: the bun.assertWithLocation(has_set_umask) guard added in commit 1a9d44b only landed in bin.zig. After the rebase that code is unreachable; the live path is chmod_on_ok in bin.rs. Add the equivalent debug_assert!(HAS_SET_UMASK.load(..)) before the sys::chmod so a future caller that skips ensure_umask() trips loudly instead of silently widening bin perms to 0o777. --- src/install/bin.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/install/bin.rs b/src/install/bin.rs index 39655707bda..673f7f1c485 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -1317,6 +1317,16 @@ impl<'a> Linker<'a> { fn chmod_on_ok(err: Option, 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); } From c866e5df29d2b5d111e46e03fa4c6960913c8833 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 17 May 2026 01:20:16 +0000 Subject: [PATCH 17/21] install(test): point umask regression comment at .rs paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] flagged one more rotation of the same comment-rot fixed in 65fe9985: the parametrized-test block still referenced isolated_install.zig, isolated_install/Installer.zig, and hoisted_install.zig. Per src/CLAUDE.md those are uncompiled reference files — the live paths are the .rs siblings. Rewrite to match. --- test/cli/install/bun-install.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index c2b09c56a88..3bbc2324c57 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -8887,12 +8887,12 @@ describe.concurrent("bun-install", () => { // different code path there. // // Parametrized over the two linkers because they share almost no directory- - // creation code — isolated goes through `isolated_install.zig` / - // `isolated_install/Installer.zig`, hoisted through `hoisted_install.zig` - // and `PackageInstall.installWithHardlink`. Bin linking in particular - // relied on `Bin.Linker.ensureUmask()` being primed by the hoisted entry - // point; the isolated path never called it, so bin targets would chmod to - // 0o777 regardless of umask. + // creation code — isolated goes through `isolated_install.rs` / + // `isolated_install/Installer.rs`, hoisted through `hoisted_install.rs` + // and `PackageInstall::install_with_hardlink`. Bin linking in particular + // relied on `Bin::Linker::ensure_umask()` being primed by the hoisted + // entry point; the isolated path never called it, so bin targets would + // chmod to 0o777 regardless of umask. for (const linker of ["hoisted", "isolated"] as const) { it.skipIf(isWindows)( `${linker} install respects process umask for directories and bin targets (#29723)`, From 0955a41fce6d8590b88ce45294ae45541988d2f9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 25 May 2026 12:30:03 +0000 Subject: [PATCH 18/21] install(isolated): gate bin import on #[cfg(unix)] for Windows build --- src/install/isolated_install.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 84d7b56cf19..82713993090 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -40,6 +40,7 @@ use bun_sys::{self as sys, Fd}; use bun_wyhash::{Wyhash, Wyhash11}; use crate::analytics; +#[cfg(unix)] use crate::bin_real as bin; use crate::bun_bunfig::Arguments as Command; use crate::bun_progress::{Node as ProgressNode, Progress}; From b7cdcb01644f4b40bd8eca09bc427ce4ccac4b33 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:02:14 +0000 Subject: [PATCH 19/21] install(test): don't pipe unread stdout in umask test --- test/cli/install/bun-install.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 3bbc2324c57..5a2a2850cbd 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -8923,7 +8923,7 @@ describe.concurrent("bun-install", () => { cwd: pkgDir, env: { ...env, BUN_INSTALL_CACHE_DIR: cacheDir }, stderr: "pipe", - stdout: "pipe", + stdout: "ignore", stdin: "ignore", }); const [errText, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); From 7a7fc50e3fee67e5bf740a7a59cdd575d7818cc8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:26:20 +0000 Subject: [PATCH 20/21] bunx: pin cache root mode to 0o755 so permissive umasks don't trip the trust check --- src/runtime/cli/bunx_command.rs | 6 ++++ test/cli/install/bunx.test.ts | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 92c834d0ec8..28ddbd2f071 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -1231,6 +1231,12 @@ impl BunxCommand { Global::exit(1); } + // The bunx cache root lives in the shared temp directory, and + // `is_trusted_cache_root` refuses group/other-writable roots. Request + // 0o755 explicitly instead of the umask-honoring default (0o777) so a + // permissive umask like 0o002 cannot widen the root into a state bunx + // itself rejects; the kernel can only subtract bits from 0o755. + bun_sys::mkdir_recursive_at_mode(Fd::cwd(), bunx_cache_dir, 0o755)?; let bunx_install_dir = Fd::cwd().make_open_path(bunx_cache_dir)?; 'create_package_json: { diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index cb99066c38c..1b899ed6d7b 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -817,6 +817,69 @@ console.log("EXECUTED: multi-tool-alt (alternate binary)"); expect(out).not.toContain("EXECUTED: multi-tool (main binary)"); expect(exited).toBe(0); }); + + // bunx creates its cache root in the shared temp dir, and + // `is_trusted_cache_root` refuses roots that are group/other-writable. A + // permissive umask must not widen the root bunx creates for itself, or + // the second invocation refuses the cache the first one made (#29723). + it.skipIf(isWindows)("reuses its own cache when created under a permissive umask", async () => { + const urls: string[] = []; + setHandler( + dummyRegistry(urls, { + "1.0.0": { + bin: { + "multi-tool": "bin/multi-tool.js", + }, + as: "1.0.0", + }, + }), + ); + + const run = () => { + const subprocess = spawn({ + cmd: [ + "sh", + "-c", + `umask 0002 && exec "$@"`, + "sh", + bunExe(), + "x", + "--package", + "multi-tool-pkg", + "multi-tool", + ], + cwd: x_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env: { + ...env, + npm_config_registry: `http://localhost:${port}/`, + }, + }); + return Promise.all([ + subprocess.stderr.text(), + subprocess.stdout.text(), + subprocess.exited, + ] as const); + }; + + // First run creates the cache root under umask 0o002 and installs into it. + { + const [err, out, exited] = await run(); + expect(err).not.toContain("refusing to use bunx cache directory"); + expect(out).toContain("EXECUTED: multi-tool (main binary)"); + expect(exited).toBe(0); + } + + // Second run must accept the cache root the first run just created. + { + const [err, out, exited] = await run(); + expect(err).not.toContain("refusing to use bunx cache directory"); + expect(out).toContain("EXECUTED: multi-tool (main binary)"); + expect(exited).toBe(0); + } + }); }); }); From 255a0354538d95a3c69434a5c5e80d90127db672 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:28:23 +0000 Subject: [PATCH 21/21] [autofix.ci] apply automated fixes --- test/cli/install/bunx.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index 1b899ed6d7b..b9f09099db4 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -857,11 +857,7 @@ console.log("EXECUTED: multi-tool-alt (alternate binary)"); npm_config_registry: `http://localhost:${port}/`, }, }); - return Promise.all([ - subprocess.stderr.text(), - subprocess.stdout.text(), - subprocess.exited, - ] as const); + return Promise.all([subprocess.stderr.text(), subprocess.stdout.text(), subprocess.exited] as const); }; // First run creates the cache root under umask 0o002 and installs into it.