Repository: denoland/deno
Pull request: fix(ext/node): pass uid/gid to spawn and implement process.getgroups

Two fixes for child_process uid/gid support:

1. `uid` and `gid` options were destructured but not passed to `Deno.Command` in async `spawn()` (they were already passed in `spawnSync`). Also converts `PermissionDenied` errors to `EPERM` and throws synchronously, matching Node.js behavior.
2. Implements `process.getgroups()` using libc `getgroups()` syscall, which was missing entirely.

Changed files:
filename: ext/node/lib.rs
patch: @@ -272,6 +272,7 @@ deno_core::extension!(deno_node,
     ops::os::op_node_os_user_info,
     ops::os::op_geteuid,
     ops::os::op_getegid,
+    ops::os::op_getgroups,
     ops::os::op_cpus,
     ops::os::op_homedir,
     op_node_build_os,

filename: ext/node/ops/os/mod.rs
patch: @@ -31,6 +31,13 @@ pub enum OsError {
     #[inherit]
     std::io::Error,
   ),
+  #[class(inherit)]
+  #[error("Failed to get groups")]
+  FailedToGetGroups(
+    #[source]
+    #[inherit]
+    std::io::Error,
+  ),
 }
 
 #[op2(fast, stack_trace)]
@@ -246,6 +253,43 @@ pub fn op_getegid(state: &mut OpState) -> Result<u32, PermissionCheckError> {
   Ok(egid)
 }
 
+#[op2(stack_trace)]
+#[serde]
+pub fn op_getgroups(state: &mut OpState) -> Result<Vec<u32>, OsError> {
+  {
+    let permissions = state.borrow_mut::<PermissionsContainer>();
+    permissions.check_sys("gid", "node:process.getgroups()")?;
+  }
+
+  #[cfg(windows)]
+  {
+    Ok(vec![])
+  }
+  #[cfg(unix)]
+  {
+    // SAFETY: Call to libc getgroups with 0/null to query group count.
+    let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
+    if ngroups < 0 {
+      return Err(OsError::FailedToGetGroups(std::io::Error::last_os_error()));
+    }
+    if ngroups == 0 {
+      return Ok(vec![]);
+    }
+    let mut groups: Vec<libc::gid_t> = vec![0; ngroups as usize];
+    // SAFETY: Call to libc getgroups with properly sized buffer.
+    let ngroups = unsafe { libc::getgroups(ngroups, groups.as_mut_ptr()) };
+    if ngroups < 0 {
+      return Err(OsError::FailedToGetGroups(std::io::Error::last_os_error()));
+    }
+    groups.truncate(ngroups as usize);
+    #[allow(
+      clippy::unnecessary_cast,
+      reason = "gid_t may not be u32 on all platforms"
+    )]
+    Ok(groups.iter().map(|&g| g as u32).collect())
+  }
+}
+
 #[op2(stack_trace)]
 #[serde]
 pub fn op_cpus(state: &mut OpState) -> Result<Vec<cpus::CpuInfo>, OsError> {

filename: ext/node/polyfills/internal/child_process.ts
patch: @@ -342,6 +342,8 @@ export class ChildProcess extends EventEmitter {
       windowsVerbatimArguments = false,
       detached,
       envPairs,
+      uid,
+      gid,
     } = options;
 
     // Convert envPairs array to env object
@@ -386,6 +388,11 @@ export class ChildProcess extends EventEmitter {
       extraStdioNormalized.push(toDenoStdio(extraStdio[i]));
     }
 
+    // Windows does not support uid/gid options - throw ENOTSUP synchronously.
+    if (isWindows && (uid != null || gid != null)) {
+      throw _createSpawnError("ENOTSUP", command, args.slice(1));
+    }
+
     try {
       this.#process = new Deno.Command(cmd, {
         args: cmdArgs,
@@ -396,6 +403,8 @@ export class ChildProcess extends EventEmitter {
         stdout: toDenoStdio(stdout),
         stderr: toDenoStdio(stderr),
         windowsRawArguments: windowsVerbatimArguments,
+        uid,
+        gid,
         detached,
         [kSerialization]: serialization,
         [kIpc]: ipc, // internal
@@ -573,6 +582,9 @@ export class ChildProcess extends EventEmitter {
       if (e instanceof Deno.errors.NotFound) {
         // args.slice(1) to exclude argv0 (prepended by normalizeSpawnArguments)
         e = _createSpawnError("ENOENT", command, args.slice(1));
+      } else if (e instanceof Deno.errors.PermissionDenied) {
+        // Node.js throws EPERM synchronously for uid/gid permission errors.
+        throw _createSpawnError("EPERM", command, args.slice(1));
       }
 
       // Set up stdio streams even when spawn fails (Node.js creates pipes

filename: ext/node/polyfills/process.ts
patch: @@ -12,6 +12,7 @@ import {
   op_fs_umask,
   op_getegid,
   op_geteuid,
+  op_getgroups,
   op_node_load_env_file,
   op_node_process_constrained_memory,
   op_node_process_kill,
@@ -997,6 +998,9 @@ process.getgid = getgid;
 /** This method is removed on Windows */
 process.getuid = getuid;
 
+/** This method is removed on Windows */
+process.getgroups = () => op_getgroups();
+
 /** This method is removed on Windows */
 process.getegid = getegid;
 
@@ -1113,6 +1117,7 @@ if (isWindows) {
   delete process.getuid;
   delete process.getegid;
   delete process.geteuid;
+  delete process.getgroups;
 }
 
 Object.defineProperty(process, Symbol.toStringTag, {

filename: tests/node_compat/config.jsonc
patch: @@ -263,6 +263,7 @@
     "parallel/test-child-process-stdio-overlapped.js": {},
     "parallel/test-child-process-stdout-flush-exit.js": {},
     "parallel/test-child-process-stdout-flush.js": {},
+    "parallel/test-child-process-uid-gid.js": {},
     "parallel/test-child-process-validate-stdio.js": {},
     "parallel/test-cli-bad-options.js": {
       "ignore": true,