Compare commits

...

3 Commits

Author SHA1 Message Date
Jarred-Sumner
43c7cd2ba2 bun run prettier 2025-06-28 06:25:49 +00:00
Jarred-Sumner
d545f30676 bun run zig-format 2025-06-28 06:24:34 +00:00
Jarred Sumner
c4dba4c61e Implement gid & uid in Bun.spawn 2025-06-27 23:17:09 -07:00
7 changed files with 205 additions and 10 deletions

View File

@@ -6963,6 +6963,40 @@ declare module "bun" {
* @default undefined (no limit)
*/
maxBuffer?: number;
/**
* The user ID to run the subprocess as (Linux only).
*
* On macOS and Windows, this option is silently ignored.
*
* @platform Linux
* @example
* ```ts
* // Run as user with UID 1000
* const subprocess = Bun.spawn({
* cmd: ["id"],
* uid: 1000,
* });
* ```
*/
uid?: number;
/**
* The group ID to run the subprocess as (Linux only).
*
* On macOS and Windows, this option is silently ignored.
*
* @platform Linux
* @example
* ```ts
* // Run as group with GID 1000
* const subprocess = Bun.spawn({
* cmd: ["id"],
* gid: 1000,
* });
* ```
*/
gid?: number;
}
type ReadableIO = ReadableStream<Uint8Array> | number | undefined;

View File

@@ -971,6 +971,8 @@ pub const PosixSpawnOptions = struct {
detached: bool = false,
windows: void = {},
argv0: ?[*:0]const u8 = null,
uid: ?std.posix.uid_t = null,
gid: ?std.posix.gid_t = null,
stream: bool = true,
sync: bool = false,
can_block_entire_thread_to_reduce_cpu_usage_in_fast_path: bool = false,
@@ -1051,6 +1053,8 @@ pub const WindowsSpawnOptions = struct {
detached: bool = false,
windows: WindowsOptions = .{},
argv0: ?[*:0]const u8 = null,
uid: void = {},
gid: void = {},
stream: bool = true,
use_execve_on_macos: bool = false,
can_block_entire_thread_to_reduce_cpu_usage_in_fast_path: bool = false,
@@ -1231,6 +1235,16 @@ pub fn spawnProcessPosix(
var attr = try PosixSpawn.Attr.init();
defer attr.deinit();
// On Linux, set uid/gid in the attr struct
if (comptime Environment.isLinux) {
if (options.uid) |uid| {
attr.uid = uid;
}
if (options.gid) |gid| {
attr.gid = gid;
}
}
var flags: i32 = bun.c.POSIX_SPAWN_SETSIGDEF | bun.c.POSIX_SPAWN_SETSIGMASK;
if (comptime Environment.isMac) {
@@ -1283,6 +1297,8 @@ pub fn spawnProcessPosix(
attr.set(@intCast(flags)) catch {};
attr.resetSignals() catch {};
// uid/gid is only supported on Linux, silently ignored on other platforms
if (options.ipc) |ipc| {
try actions.inherit(ipc);
spawned.ipc = ipc;

View File

@@ -108,6 +108,8 @@ pub const BunSpawn = struct {
pub const Attr = struct {
detached: bool = false,
uid: if (Environment.isPosix) std.posix.uid_t else void = if (Environment.isPosix) std.math.maxInt(u32) else {},
gid: if (Environment.isPosix) std.posix.gid_t else void = if (Environment.isPosix) std.math.maxInt(u32) else {},
pub fn init() !Attr {
return Attr{};
@@ -183,6 +185,14 @@ pub const PosixSpawn = struct {
}
extern fn posix_spawnattr_reset_signals(attr: *system.posix_spawnattr_t) c_int;
pub fn setCredentials(self: *PosixSpawnAttr, uid: ?system.uid_t, gid: ?system.gid_t) !void {
// macOS doesn't support setting uid/gid through posix_spawn attributes
// This is only supported on Linux through our custom posix_spawn_bun implementation
_ = self;
_ = uid;
_ = gid;
}
};
pub const PosixSpawnActions = struct {
@@ -282,6 +292,8 @@ pub const PosixSpawn = struct {
chdir_buf: ?[*:0]u8 = null,
detached: bool = false,
actions: ActionsList = .{},
uid: std.posix.uid_t,
gid: std.posix.gid_t,
const ActionsList = extern struct {
ptr: ?[*]const BunSpawn.Action = null,
@@ -347,6 +359,8 @@ pub const PosixSpawn = struct {
},
.chdir_buf = if (actions) |a| a.chdir_buf else null,
.detached = if (attr) |a| a.detached else false,
.uid = if (attr) |a| a.uid else std.math.maxInt(u32),
.gid = if (attr) |a| a.gid else std.math.maxInt(u32),
},
argv,
envp,

View File

@@ -1968,6 +1968,8 @@ pub fn spawnMaybeSync(
var windows_hide: bool = false;
var windows_verbatim_arguments: bool = false;
var abort_signal: ?*JSC.WebCore.AbortSignal = null;
var uid: if (Environment.isPosix) ?std.posix.uid_t else void = if (Environment.isPosix) null else {};
var gid: if (Environment.isPosix) ?std.posix.gid_t else void = if (Environment.isPosix) null else {};
defer {
// Ensure we clean it up on error.
if (abort_signal) |signal| {
@@ -2179,6 +2181,15 @@ pub fn spawnMaybeSync(
}
}
}
if (comptime Environment.isPosix) {
if (try args.getTruthy(globalThis, "uid")) |uid_val| {
uid = @intCast(try globalThis.validateIntegerRange(uid_val, u32, 0, .{ .field_name = "uid" }));
}
if (try args.getTruthy(globalThis, "gid")) |gid_val| {
gid = @intCast(try globalThis.validateIntegerRange(gid_val, u32, 0, .{ .field_name = "gid" }));
}
}
} else {
try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv);
}
@@ -2310,13 +2321,15 @@ pub fn spawnMaybeSync(
},
.extra_fds = extra_fds.items,
.argv0 = argv0,
.uid = if (comptime Environment.isPosix) uid else {},
.gid = if (comptime Environment.isPosix) gid else {},
.can_block_entire_thread_to_reduce_cpu_usage_in_fast_path = can_block_entire_thread_to_reduce_cpu_usage_in_fast_path,
.windows = if (Environment.isWindows) .{
.hide_window = windows_hide,
.verbatim_arguments = windows_verbatim_arguments,
.loop = JSC.EventLoopHandle.init(jsc_vm),
},
} else {},
};
var spawned = switch (bun.spawn.spawnProcess(

View File

@@ -12,6 +12,7 @@
#include <signal.h>
#include <sys/syscall.h>
#include <sys/resource.h>
#include <cstdint>
extern char** environ;
@@ -45,6 +46,8 @@ typedef struct bun_spawn_request_t {
const char* chdir;
bool detached;
bun_spawn_file_action_list_t actions;
uid_t uid;
gid_t gid;
} bun_spawn_request_t;
extern "C" ssize_t posix_spawn_bun(
@@ -95,6 +98,20 @@ extern "C" ssize_t posix_spawn_bun(
}
}
// Set group ID before user ID when dropping privileges
// We use UINT32_MAX as the sentinel value for "not set"
if (request->gid != UINT32_MAX) {
if (setgid(request->gid) != 0) {
return childFailed();
}
}
if (request->uid != UINT32_MAX) {
if (setuid(request->uid) != 0) {
return childFailed();
}
}
const auto& actions = request->actions;
for (size_t i = 0; i < actions.len; i++) {

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isLinux, isMacOS } from "harness";
// uid/gid support is Linux-only due to macOS security restrictions
describe.if(isLinux)("Bun.spawn with uid and gid (Linux only)", () => {
// This test can only run as root, as only root can change user/group
test.if(process.getuid() === 0)("should spawn process with different uid and gid", async () => {
// 'nobody' user usually has a high UID, a safe non-root user.
// On macOS it's often -2 (65534), on Linux 65534.
const nobodyUser = await Bun.spawn({ cmd: ["id", "-u", "nobody"] });
const nobodyUid = parseInt(await new Response(nobodyUser.stdout).text());
const nobodyGroup = await Bun.spawn({ cmd: ["id", "-g", "nobody"] });
const nobodyGid = parseInt(await new Response(nobodyGroup.stdout).text());
// Test with spawn (async)
const procUid = Bun.spawn({
cmd: [bunExe(), "-e", "console.write(String(process.getuid()))"],
env: bunEnv,
uid: nobodyUid,
});
const uidOutput = await new Response(procUid.stdout).text();
expect(parseInt(uidOutput)).toBe(nobodyUid);
expect(await procUid.exited).toBe(0);
const procGid = Bun.spawn({
cmd: [bunExe(), "-e", "console.write(String(process.getgid()))"],
env: bunEnv,
gid: nobodyGid,
});
const gidOutput = await new Response(procGid.stdout).text();
expect(parseInt(gidOutput)).toBe(nobodyGid);
expect(await procGid.exited).toBe(0);
// Test with spawnSync
const { stdout: syncUidOut } = Bun.spawnSync({
cmd: [bunExe(), "-e", "console.write(String(process.getuid()))"],
env: bunEnv,
uid: nobodyUid,
});
expect(parseInt(syncUidOut.toString())).toBe(nobodyUid);
});
test("should fail with EPERM when not running as root", async () => {
// Skip if running as root, as this test would pass.
if (process.getuid() === 0) {
return;
}
const targetUid = process.getuid() + 1; // Any other UID
// Bun.spawn throws a system error on failure
expect(() => {
Bun.spawnSync({
cmd: ["echo", "hello"],
uid: targetUid,
});
}).toThrow("operation not permitted");
});
test("should throw for invalid uid/gid arguments", () => {
expect(() => {
Bun.spawnSync({ cmd: ["echo", "hello"], uid: "not-a-number" });
}).toThrow('Invalid value for option "uid"');
expect(() => {
Bun.spawnSync({ cmd: ["echo", "hello"], gid: -1 });
}).toThrow('Invalid value for option "gid"');
});
});
// Test that uid/gid is silently ignored on macOS
describe.if(isMacOS)("Bun.spawn with uid and gid (macOS)", () => {
test("should silently ignore uid/gid on macOS", async () => {
const currentUid = process.getuid();
const currentGid = process.getgid();
// Test with spawn (async) - should ignore uid/gid and run as current user
const procUid = Bun.spawn({
cmd: [bunExe(), "-e", "console.write(String(process.getuid()))"],
env: bunEnv,
uid: 9999, // Some arbitrary uid that would fail if actually used
});
const uidOutput = await new Response(procUid.stdout).text();
expect(parseInt(uidOutput)).toBe(currentUid);
expect(await procUid.exited).toBe(0);
// Test with spawnSync - should ignore gid and run as current group
const { stdout: gidOut } = Bun.spawnSync({
cmd: [bunExe(), "-e", "console.write(String(process.getgid()))"],
env: bunEnv,
gid: 9999, // Some arbitrary gid that would fail if actually used
});
expect(parseInt(gidOut.toString())).toBe(currentGid);
});
});

View File

@@ -275,18 +275,23 @@ describe("napi", () => {
runOn("node", "test_napi_async_work_complete_null_check", []),
runOn(bunExe(), "test_napi_async_work_complete_null_check", []),
]);
// Filter out debug logs and normalize
const cleanBunResult = bunResult
.replaceAll(/^\[\w+\].+$/gm, "")
.trim();
const cleanBunResult = bunResult.replaceAll(/^\[\w+\].+$/gm, "").trim();
// Both should contain these two lines, but order may vary
const expectedLines = ["execute called!", "resolved to undefined"];
const nodeLines = nodeResult.trim().split('\n').filter(line => line).sort();
const bunLines = cleanBunResult.split('\n').filter(line => line).sort();
const nodeLines = nodeResult
.trim()
.split("\n")
.filter(line => line)
.sort();
const bunLines = cleanBunResult
.split("\n")
.filter(line => line)
.sort();
expect(bunLines).toEqual(nodeLines);
expect(bunLines).toEqual(expectedLines.sort());
});