diff --git a/src/js/node/os.ts b/src/js/node/os.ts index 3326d8093d54..746dc26b6be8 100644 --- a/src/js/node/os.ts +++ b/src/js/node/os.ts @@ -128,7 +128,14 @@ function bound(binding) { : $bundleError("TODO: type"); }, uptime: binding.uptime, - userInfo: binding.userInfo, + userInfo: function userInfo(options) { + let encoding; + if (typeof options === "object" && options !== null) { + const value = options.encoding; + if (typeof value === "string") encoding = value; + } + return binding.userInfo(encoding); + }, version: binding.version, machine: function () { // TODO: linux arm64 should also return "aarch64" (Node/uname compat) — diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 17f19a46b62b..7497b978a9c8 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -1952,6 +1952,16 @@ pub struct uv_interface_address_t { pub address: addr_union, pub netmask: netmask_union, } +/// Strings are one allocation owned by `username`; free via `uv_os_free_passwd`. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct uv_passwd_t { + pub username: *mut c_char, + pub uid: c_ulong, + pub gid: c_ulong, + pub shell: *mut c_char, + pub homedir: *mut c_char, +} #[repr(C)] pub struct uv_utsname_t { pub sysname: [u8; 256], @@ -2761,6 +2771,8 @@ unsafe extern "C" { pub fn uv_uptime(uptime: *mut f64) -> c_int; pub fn uv_getrusage(rusage: *mut uv_rusage_t) -> c_int; pub fn uv_os_homedir(buffer: *mut u8, size: *mut usize) -> ReturnCode; + pub fn uv_os_get_passwd(pwd: *mut uv_passwd_t) -> ReturnCode; + pub fn uv_os_free_passwd(pwd: *mut uv_passwd_t); pub fn uv_os_getppid() -> uv_pid_t; pub fn uv_os_getpriority(pid: uv_pid_t, priority: *mut c_int) -> c_int; pub fn uv_translate_sys_error(sys_errno: c_int) -> c_int; @@ -3041,6 +3053,12 @@ const _: () = { assert_size!(uv_rusage_t, 128); assert_size!(uv_cpu_info_t, 56); assert_size!(uv_interface_address_t, 80); + // `unsigned long` is 4 bytes on Windows, so uid/gid pack into one word. + assert_size!(uv_passwd_t, 32); + assert_offset!(uv_passwd_t, uid, 8); + assert_offset!(uv_passwd_t, gid, 12); + assert_offset!(uv_passwd_t, shell, 16); + assert_offset!(uv_passwd_t, homedir, 24); // `UV_REQ_FIELDS` header — every req-derived struct shares this prefix, // so asserting `uv_req_t` and field offsets covers all of them. diff --git a/src/runtime/hw_exports.rs b/src/runtime/hw_exports.rs index c3158beb8a39..ebfb94c0b06a 100644 --- a/src/runtime/hw_exports.rs +++ b/src/runtime/hw_exports.rs @@ -651,18 +651,18 @@ pub fn bindgen_node_os_dispatch_uptime(global: &JSGlobalObject, out: *mut f64) - } /// # Safety -/// `arg_options` must be a valid C++ stack local. +/// `arg_encoding` must be a valid C++ stack local. // HOST_EXPORT(bindgen_Node_os_dispatchUserInfo1, c) // Called only from the generated `extern "C"` thunk; C++ guarantees non-null stack locals. #[allow(clippy::not_unsafe_ptr_arg_deref)] pub fn bindgen_node_os_dispatch_user_info( global: &JSGlobalObject, - arg_options: *const crate::node::os::gen_::UserInfoOptions, + arg_encoding: *const bun_core::String, ) -> JSValue { - // SAFETY: `arg_options` is a valid C++ stack local; `UserInfoOptions` is - // `#[repr(C)]` matching the bindgen `extern struct`. - let options = unsafe { core::ptr::read(arg_options) }; - bun_jsc::host_fn::to_js_host_call(global, || node_os::user_info(global, &options)) + // SAFETY: `arg_encoding` is a valid C++ stack local, borrowed (not owned) + // for the duration of the call. + let encoding = unsafe { &*arg_encoding }; + bun_jsc::host_fn::to_js_host_call(global, || node_os::user_info(global, encoding)) } // HOST_EXPORT(bindgen_Node_os_dispatchVersion1, c) diff --git a/src/runtime/node/node_os.bind.ts b/src/runtime/node/node_os.bind.ts index db3082c4b7a6..1aa893867302 100644 --- a/src/runtime/node/node_os.bind.ts +++ b/src/runtime/node/node_os.bind.ts @@ -57,13 +57,11 @@ export const uptime = fn({ }, ret: t.f64, }); -export const UserInfoOptions = t.dictionary({ - encoding: t.DOMString.default(""), -}); +// os.ts unwraps `options`; node ignores (rather than rejects) non-string encodings, which bindgen cannot express. export const userInfo = fn({ args: { global: t.globalObject, - options: UserInfoOptions.default({}), + encoding: t.DOMString.default(""), }, ret: t.any, }); diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index a3d49aafa5e6..66f69061701b 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -1,6 +1,6 @@ -use core::ffi::c_int; +use core::ffi::{c_char, c_int}; #[cfg(not(windows))] -use core::ffi::{c_char, c_uint, c_void}; +use core::ffi::{c_uint, c_void}; use bun_core; use bun_core::String as BunString; @@ -35,18 +35,23 @@ pub(crate) fn freemem() -> u64 { mod _impl { use super::*; + use crate::node::types::Encoding; #[cfg(any(target_os = "linux", target_os = "android"))] use bun_core::ZStr; use bun_core::ZigString; #[cfg(not(windows))] + use bun_core::env_var; + use bun_core::fmt as bun_fmt; + #[cfg(not(windows))] use bun_core::strings; - use bun_core::{env_var, fmt as bun_fmt}; - use bun_jsc::{CallFrame, JSArray, StringJsc as _, SysErrorJsc as _, SystemError}; + #[cfg(windows)] + use bun_jsc::StringJsc as _; + use bun_jsc::{CallFrame, JSArray, SystemError}; #[cfg(windows)] use bun_paths::PathBuffer; #[cfg(windows)] use bun_sys::ReturnCodeExt as _; - #[cfg(not(windows))] + #[cfg(any(target_os = "macos", target_os = "freebsd"))] use bun_sys::c; #[cfg(windows)] use bun_sys::windows::{self, libuv}; @@ -113,10 +118,9 @@ mod _impl { // (`GeneratedBindings.cpp`) defines the SYSV-ABI `bindgen_Node_os_js*` host // functions, which validate/decode arguments and call back into the // `bindgen_Node_os_dispatch*` entry points. This module provides the - // public surface: `js*` extern pointers + `create*Callback` wrappers - // + the `UserInfoOptions` dictionary. + // public surface: `js*` extern pointers + `create*Callback` wrappers. pub mod gen_ { - use super::{BunString, CallFrame, JSGlobalObject, JSValue, ZigString}; + use super::{CallFrame, JSGlobalObject, JSValue, ZigString}; use bun_jsc::host_fn; // C++-side host fns (GeneratedBindings.cpp). `bindgen.ts` emits these as @@ -171,15 +175,6 @@ mod _impl { create_version_callback, "version", 0, bindgen_Node_os_jsVersion; create_set_priority_callback, "setPriority", 2, bindgen_Node_os_jsSetPriority; } - - /// `t.dictionary({ encoding: t.DOMString.default("") })` from - /// `node_os.bind.ts`. Mirrors the extern struct emitted by bindgen; - /// the C++ side passes a pointer to this layout, so it must stay - /// `#[repr(C)]`. - #[repr(C)] - pub struct UserInfoOptions { - pub(crate) encoding: BunString, - } } pub(crate) fn create_node_os_binding(global: &JSGlobalObject) -> JsResult { @@ -700,6 +695,78 @@ mod _impl { Ok(result) } + /// libuv's `uv__getpwuid_r`. `Ok(None)`: the effective uid has no entry. + #[cfg(not(windows))] + fn with_euid_passwd(f: impl FnOnce(&libc::passwd) -> T) -> Result, bun_sys::E> { + // From libuv: + // > Calling sysconf(_SC_GETPW_R_SIZE_MAX) would get the suggested size, but it + // > is frequently 1024 or 4096, so we can just use that directly. The pwent + // > will not usually be large. + // Instead of always using an allocation, first try a stack allocation + // of 4096, then fallback to heap. + let mut stack_string_bytes = [0u8; 4096]; + let mut heap_bytes: Vec; + let mut string_bytes: &mut [u8] = &mut stack_string_bytes[..]; + + // SAFETY: zeroed POD + let mut pw: libc::passwd = bun_core::ffi::zeroed(); + let mut result: *mut libc::passwd = core::ptr::null_mut(); + + let ret: c_int = loop { + // SAFETY: valid buffers and out-pointer + let ret = unsafe { + libc::getpwuid_r( + libc::geteuid(), + &raw mut pw, + string_bytes.as_mut_ptr().cast::(), + string_bytes.len(), + &raw mut result, + ) + }; + + if ret == bun_sys::E::EINTR as c_int { + continue; + } + + // If the system call wants more memory, double it. + if ret == bun_sys::E::ERANGE as c_int { + let len = string_bytes.len(); + heap_bytes = vec![0u8; len * 2]; + string_bytes = &mut heap_bytes[..]; + continue; + } + + break ret; + }; + + if ret != 0 { + // `ret` is a libc errno; `E::from_raw` is the centralized + // `@enumFromInt` (debug-asserts the discriminant). + return Err(bun_sys::E::from_raw(ret as u16)); + } + if result.is_null() { + return Ok(None); + } + // `pw` borrows `string_bytes`, which outlives the call to `f`. + Ok(Some(f(&pw))) + } + + /// Copies, since the entry's strings die with the `getpwuid_r` buffer. + fn passwd_field(field: *const c_char) -> Vec { + if field.is_null() { + return Vec::new(); + } + // SAFETY: non-null NUL-terminated C string from the passwd entry + unsafe { bun_core::ffi::cstr(field) }.to_bytes().to_vec() + } + + /// `lib/os.js`'s `throw new ERR_SYSTEM_ERROR(ctx)`. + fn throw_uv_error(global: &JSGlobalObject, err: &bun_sys::Error) -> bun_jsc::JsError { + global.throw_value( + SystemError::from(err.to_uv_system_error()).to_error_instance_with_info_object(global), + ) + } + pub(crate) fn homedir(global: &JSGlobalObject) -> JsResult { // In Node.js, this is a wrapper around uv_os_homedir. #[cfg(windows)] @@ -710,7 +777,7 @@ mod _impl { if let Some(err) = unsafe { libuv::uv_os_homedir(out.as_mut_ptr(), &mut size) } .to_error(bun_sys::Tag::uv_os_homedir) { - return Err(global.throw_value(err.to_js(global))); + return Err(throw_uv_error(global, &err)); } return Ok(BunString::clone_utf8(&out[0..size])); } @@ -724,83 +791,31 @@ mod _impl { } } - // From libuv: - // > Calling sysconf(_SC_GETPW_R_SIZE_MAX) would get the suggested size, but it - // > is frequently 1024 or 4096, so we can just use that directly. The pwent - // > will not usually be large. - // Instead of always using an allocation, first try a stack allocation - // of 4096, then fallback to heap. - let mut stack_string_bytes = [0u8; 4096]; - let mut heap_bytes: Vec; - let mut string_bytes: &mut [u8] = &mut stack_string_bytes[..]; - let mut using_heap = false; - - // SAFETY: zeroed POD - let mut pw: libc::passwd = bun_core::ffi::zeroed(); - let mut result: *mut libc::passwd = core::ptr::null_mut(); - - let ret: c_int = loop { - // SAFETY: valid buffers and out-pointer - let ret = unsafe { - libc::getpwuid_r( - libc::geteuid(), - &raw mut pw, - string_bytes.as_mut_ptr().cast::(), - string_bytes.len(), - &raw mut result, - ) - }; - - if ret == bun_sys::E::EINTR as c_int { - continue; + let dir = match with_euid_passwd(|pw| passwd_field(pw.pw_dir)) { + Ok(Some(dir)) => dir, + Ok(None) => { + // bionic has no passwd entries for app uids; with HOME also unset + // (zygote/run-as), return a usable default rather than throwing. + #[cfg(target_os = "android")] + { + return Ok(BunString::static_("/data/local/tmp")); + } + // in uv__getpwuid_r, null result throws UV_ENOENT. + #[cfg(not(target_os = "android"))] + return Err(throw_uv_error( + global, + &bun_sys::Error::from_code(bun_sys::E::ENOENT, bun_sys::Tag::uv_os_homedir), + )); } - - // If the system call wants more memory, double it. - if ret == bun_sys::E::ERANGE as c_int { - let len = string_bytes.len(); - heap_bytes = vec![0u8; len * 2]; - string_bytes = &mut heap_bytes[..]; - using_heap = true; - continue; + Err(errno) => { + return Err(throw_uv_error( + global, + &bun_sys::Error::from_code(errno, bun_sys::Tag::uv_os_homedir), + )); } - - break ret; }; - let _ = using_heap; - - if ret != 0 { - return Err(global.throw_value( - bun_sys::Error::from_code( - // `ret` is a libc errno; `E::from_raw` is the centralized - // `@enumFromInt` (debug-asserts the discriminant). - bun_sys::E::from_raw(ret as u16), - bun_sys::Tag::uv_os_homedir, - ) - .to_js(global), - )); - } - - if result.is_null() { - // bionic has no passwd entries for app uids; with HOME also unset - // (zygote/run-as), return a usable default rather than throwing. - #[cfg(target_os = "android")] - { - return Ok(BunString::static_("/data/local/tmp")); - } - // in uv__getpwuid_r, null result throws UV_ENOENT. - #[cfg(not(target_os = "android"))] - return Err(global.throw_value( - bun_sys::Error::from_code(bun_sys::E::ENOENT, bun_sys::Tag::uv_os_homedir) - .to_js(global), - )); - } - return Ok(if !pw.pw_dir.is_null() { - // SAFETY: pw_dir is a NUL-terminated C string from getpwuid_r - BunString::clone_utf8(unsafe { bun_core::ffi::cstr(pw.pw_dir) }.to_bytes()) - } else { - BunString::empty() - }); + return Ok(BunString::clone_utf8(&dir)); } } @@ -1560,53 +1575,95 @@ mod _impl { } } + /// `uv_passwd_t`; `shell` is `None` where libuv leaves it null. + struct UserInfo { + uid: f64, + gid: f64, + username: Vec, + homedir: Vec, + shell: Option>, + } + + /// In Node.js, this is a wrapper around uv_os_get_passwd. pub(crate) fn user_info( global_this: &JSGlobalObject, - options: &gen_::UserInfoOptions, + encoding: &BunString, ) -> JsResult { - let _ = options; // TODO: - - let result = JSValue::create_empty_object(global_this, 5); - - let home = homedir(global_this)?; - let home = scopeguard::guard(home, |h| h.deref()); - - result.put(global_this, b"homedir", home.to_js(global_this)?); + // node's `ParseEncoding(..., UTF8)`: an unrecognized name is utf8. + let encoding = Encoding::from_bun_string(encoding).unwrap_or(Encoding::Utf8); #[cfg(windows)] - { - result.put( - global_this, - b"username", - ZigString::init(env_var::USER.get().unwrap_or(b"unknown")) - .with_encoding() - .to_js(global_this), - ); - result.put(global_this, b"uid", JSValue::js_number(-1.0)); - result.put(global_this, b"gid", JSValue::js_number(-1.0)); - result.put(global_this, b"shell", JSValue::NULL); - } + let info = { + // SAFETY: zeroed POD (all-zero is a valid `uv_passwd_t`) + let mut pwd: libuv::uv_passwd_t = unsafe { bun_core::ffi::zeroed_unchecked() }; + // SAFETY: valid out-pointer + if let Some(err) = unsafe { libuv::uv_os_get_passwd(&raw mut pwd) } + .to_error(bun_sys::Tag::uv_os_get_passwd) + { + return Err(throw_uv_error(global_this, &err)); + } + // SAFETY: `uv_os_get_passwd` succeeded, so `pwd` owns one allocation + // reachable from `pwd.username`; `uv_os_free_passwd` releases it. + let pwd = scopeguard::guard(pwd, |mut pwd| unsafe { + libuv::uv_os_free_passwd(&raw mut pwd) + }); + UserInfo { + // libuv stores -1 in an `unsigned long`; node reads the low 32 + // bits back as a signed int32. + uid: f64::from(pwd.uid as u32 as i32), + gid: f64::from(pwd.gid as u32 as i32), + username: passwd_field(pwd.username), + homedir: passwd_field(pwd.homedir), + shell: None, + } + }; + #[cfg(not(windows))] - { - let username = env_var::USER.get().unwrap_or(b"unknown"); + let info = match with_euid_passwd(|pw| UserInfo { + uid: f64::from(pw.pw_uid), + gid: f64::from(pw.pw_gid), + username: passwd_field(pw.pw_name), + homedir: passwd_field(pw.pw_dir), + shell: (!pw.pw_shell.is_null()).then(|| passwd_field(pw.pw_shell)), + }) { + Ok(Some(info)) => info, + // in uv__getpwuid_r, null result throws UV_ENOENT. + Ok(None) => { + return Err(throw_uv_error( + global_this, + &bun_sys::Error::from_code(bun_sys::E::ENOENT, bun_sys::Tag::uv_os_get_passwd), + )); + } + Err(errno) => { + return Err(throw_uv_error( + global_this, + &bun_sys::Error::from_code(errno, bun_sys::Tag::uv_os_get_passwd), + )); + } + }; - result.put( - global_this, - b"username", - ZigString::init(username).with_encoding().to_js(global_this), - ); - result.put( - global_this, - b"shell", - ZigString::init(env_var::SHELL.get().unwrap_or(b"unknown")) - .with_encoding() - .to_js(global_this), - ); - // `bun_sys::c::{getuid,getgid}` are declared `safe fn` (no args, never - // fail) — discharges the per-site proof the raw `libc` re-export needed. - result.put(global_this, b"uid", JSValue::js_number(c::getuid() as f64)); - result.put(global_this, b"gid", JSValue::js_number(c::getgid() as f64)); - } + // node builds a null-prototype object with these keys in this order. + let result = JSValue::create_empty_object_with_null_prototype(global_this); + result.put(global_this, b"uid", JSValue::js_number(info.uid)); + result.put(global_this, b"gid", JSValue::js_number(info.gid)); + result.put( + global_this, + b"username", + encoding.encode(global_this, &info.username)?, + ); + result.put( + global_this, + b"homedir", + encoding.encode(global_this, &info.homedir)?, + ); + result.put( + global_this, + b"shell", + match &info.shell { + Some(shell) => encoding.encode(global_this, shell)?, + None => JSValue::NULL, + }, + ); Ok(result) } diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index 465c7c6f07b1..88f674b680b2 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -794,7 +794,13 @@ impl Encoding { .throw() } - /// `max_size` is a runtime arg (see `encode_with_size`); callers pass + /// node's `StringBytes::Encode`: a JS string, or a `Buffer` for `Self::Buffer`. + #[inline] + pub(crate) fn encode(self, global_object: &JSGlobalObject, input: &[u8]) -> JsResult { + self.encode_with_max_size(global_object, input.len(), input) + } + + /// `max_size` is a runtime arg (see `encode`); callers pass /// `EVP_MAX_MD_SIZE` etc. pub(crate) fn encode_with_max_size( self, diff --git a/src/sys/Error.rs b/src/sys/Error.rs index 9ed1d497815d..54786759b6e9 100644 --- a/src/sys/Error.rs +++ b/src/sys/Error.rs @@ -411,6 +411,15 @@ impl Error { err } + /// Bare `uv_strerror` label as `message`, for `to_error_instance_with_info_object` + /// (which formats it again; [`to_system_error`]'s message would be wrapped twice). + pub fn to_uv_system_error(&self) -> SystemError { + let (mut err, looked_up) = self.fill_system_error_common(&libuv_error_map::LIBUV_ERROR_MAP); + let label = looked_up.map_or("unknown error", |(_, label)| label); + err.message = BunString::static_(label.as_bytes()).into(); + err + } + /// More complex formatting to precisely match the printing that Node.js emits. /// Use this whenever the error will be sent to JavaScript instead of the shell variant above. pub fn to_system_error(&self) -> SystemError { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 76c28c6b7102..0f487555ec91 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1406,6 +1406,7 @@ impl Tag { #[cfg(not(windows))] pub(crate) const setrlimit: Tag = Tag(106); pub const clone3: Tag = Tag(107); + pub const uv_os_get_passwd: Tag = Tag(108); // `inotify_init1`/`inotify_add_watch` fold under the generic `.watch` // tag; `INotifyWatcher.rs` spells it `.inotify`. Alias to `.watch` // so the JS-facing `err.syscall == "watch"` string stays node-compatible. @@ -1413,7 +1414,7 @@ impl Tag { /// The tag name — spelling is frozen (JS-facing /// `err.syscall` string; node-compat code matches on it). pub fn name(self) -> &'static str { - const NAMES: [&str; 108] = [ + const NAMES: [&str; 109] = [ "TODO", "dup", "access", @@ -1523,6 +1524,7 @@ impl Tag { "getrlimit", "setrlimit", "clone3", + "uv_os_get_passwd", ]; NAMES.get(self.0 as usize).copied().unwrap_or("unknown") } diff --git a/test/js/node/os/os.test.js b/test/js/node/os/os.test.js index ef9f6d6e5202..a98c88ee8491 100644 --- a/test/js/node/os/os.test.js +++ b/test/js/node/os/os.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { realpathSync } from "fs"; -import { isWindows } from "harness"; +import { readFileSync, realpathSync } from "fs"; +import { bunEnv, bunExe, isLinux, isWindows } from "harness"; import { isIPv4, isIPv6 } from "node:net"; import * as os from "node:os"; @@ -113,20 +113,214 @@ it("version", () => { } }); -it("userInfo", () => { - const info = os.userInfo(); +describe("userInfo", () => { + // Runs `os.userInfo()` in a child whose account-related environment variables + // are poisoned with `marker`. node reads the passwd database instead, so the + // result must not depend on any of them. + async function userInfoWithPoisonedEnv(marker) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `process.stdout.write(JSON.stringify(require("node:os").userInfo()))`], + env: { + ...bunEnv, + USER: `nobody-${marker}`, + LOGNAME: `nobody-${marker}`, + USERNAME: `nobody-${marker}`, + SHELL: `/not-a-real-shell-${marker}`, + HOME: `/not-a-real-home-${marker}`, + USERPROFILE: `/not-a-real-home-${marker}`, + }, + stderr: "pipe", + }); - if (process.platform !== "win32") { - expect(info.username).toBe(process.env.USER); - expect(info.shell).toBe(process.env.SHELL || "unknown"); - expect(info.uid >= 0).toBe(true); - expect(info.gid >= 0).toBe(true); - } else { - expect(info.username).toBe(process.env.USERNAME); - expect(info.shell).toBe(null); - expect(info.uid).toBe(-1); - expect(info.gid).toBe(-1); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`child exited with ${exitCode}\n${stderr}`); + return JSON.parse(stdout); } + + it.concurrent("is read from the operating system, not the environment", async () => { + const [a, b] = await Promise.all([userInfoWithPoisonedEnv("a"), userInfoWithPoisonedEnv("b")]); + + // Two children that disagree on every relevant environment variable must + // still report the same account. + expect(a).toEqual(b); + expect(a.username).not.toBe("nobody-a"); + expect(a.homedir).not.toBe("/not-a-real-home-a"); + expect(a.shell).not.toBe("/not-a-real-shell-a"); + }); + + // getpwuid_r() goes through NSS, so /etc/passwd is only the source of truth + // when the effective uid actually has a row there. It does in Bun's CI images. + const passwdEntry = !isLinux + ? undefined + : readFileSync("/etc/passwd", "utf8") + .split("\n") + .map(line => line.split(":")) + .find(fields => fields.length >= 7 && Number(fields[2]) === process.geteuid()); + + it.concurrent.skipIf(!passwdEntry)("reports the passwd entry of the effective uid", async () => { + expect(await userInfoWithPoisonedEnv("passwd")).toEqual({ + uid: process.geteuid(), + gid: Number(passwdEntry[3]), + username: passwdEntry[0], + homedir: passwdEntry[5], + shell: passwdEntry[6], + }); + }); + + // The `docker run --user 12345` / distroless / OpenShift arbitrary-uid case: + // a uid with no passwd entry must throw the same `ERR_SYSTEM_ERROR` node does, + // not fabricate a record from the environment. Needs Linux `setpriv` + root. + const canSetpriv = isLinux && process.geteuid?.() === 0 && Bun.which("setpriv") != null; + it.concurrent.skipIf(!canSetpriv)("throws ERR_SYSTEM_ERROR when the effective uid has no passwd entry", async () => { + // A uid that almost certainly has no /etc/passwd row in any CI image. + const uid = "54321"; + await using proc = Bun.spawn({ + cmd: [ + "setpriv", + `--reuid=${uid}`, + `--regid=${uid}`, + "--clear-groups", + bunExe(), + "-e", + `const os = require("node:os"); + const out = {}; + for (const [name, fn] of [["userInfo", () => os.userInfo()], ["homedir", () => os.homedir()]]) { + try { + out[name] = { returned: fn() }; + } catch (e) { + out[name] = { + threw: { name: e.name, code: e.code, message: e.message, errno: e.errno, syscall: e.syscall, info: e.info }, + }; + } + } + process.stdout.write(JSON.stringify(out));`, + ], + env: { + ...bunEnv, + // Poison $HOME / $USER / $SHELL so a fabricating implementation is + // visibly wrong if it returns instead of throwing. + HOME: "/not-a-real-home", + USER: "not-a-real-user", + SHELL: "/not-a-real-shell", + }, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + userInfo: { + threw: { + name: "SystemError", + code: "ERR_SYSTEM_ERROR", + message: "A system error occurred: uv_os_get_passwd returned ENOENT (no such file or directory)", + errno: -2, + syscall: "uv_os_get_passwd", + info: { errno: -2, code: "ENOENT", message: "no such file or directory", syscall: "uv_os_get_passwd" }, + }, + }, + // os.homedir() checks $HOME first; with $HOME set it returns that verbatim. + homedir: { returned: "/not-a-real-home" }, + }); + expect(exitCode).toBe(0); + }); + + it.concurrent.skipIf(!canSetpriv)( + "homedir() throws ERR_SYSTEM_ERROR when $HOME is unset and no passwd entry", + async () => { + const uid = "54321"; + const { HOME, USERPROFILE, ...envWithoutHome } = bunEnv; + await using proc = Bun.spawn({ + cmd: [ + "setpriv", + `--reuid=${uid}`, + `--regid=${uid}`, + "--clear-groups", + bunExe(), + "-e", + `try { require("node:os").homedir() } + catch (e) { process.stdout.write(JSON.stringify({ name: e.name, code: e.code, message: e.message, info: e.info })) }`, + ], + env: envWithoutHome, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + name: "SystemError", + code: "ERR_SYSTEM_ERROR", + message: "A system error occurred: uv_os_homedir returned ENOENT (no such file or directory)", + info: { errno: -2, code: "ENOENT", message: "no such file or directory", syscall: "uv_os_homedir" }, + }); + expect(exitCode).toBe(0); + }, + ); + + it("has node's shape", () => { + const info = os.userInfo(); + + expect(Object.getPrototypeOf(info)).toBe(null); + expect(Object.keys(info)).toEqual(["uid", "gid", "username", "homedir", "shell"]); + expect(typeof info.username).toBe("string"); + expect(typeof info.homedir).toBe("string"); + + if (isWindows) { + expect(info.uid).toBe(-1); + expect(info.gid).toBe(-1); + expect(info.shell).toBe(null); + } else { + expect(info.uid).toBe(process.geteuid()); + expect(typeof info.shell).toBe("string"); + } + }); + + it("honors the encoding option", () => { + const info = os.userInfo(); + const buf = os.userInfo({ encoding: "buffer" }); + + expect(buf.uid).toBe(info.uid); + expect(buf.gid).toBe(info.gid); + expect(buf.username).toBeInstanceOf(Buffer); + expect(buf.username.toString("utf8")).toBe(info.username); + expect(buf.homedir).toBeInstanceOf(Buffer); + expect(buf.homedir.toString("utf8")).toBe(info.homedir); + + if (isWindows) { + expect(buf.shell).toBe(null); + } else { + expect(buf.shell).toBeInstanceOf(Buffer); + expect(buf.shell.toString("utf8")).toBe(info.shell); + } + + const hex = os.userInfo({ encoding: "hex" }); + expect(hex.username).toBe(Buffer.from(info.username).toString("hex")); + expect(hex.homedir).toBe(Buffer.from(info.homedir).toString("hex")); + expect(hex.shell).toBe(isWindows ? null : Buffer.from(info.shell).toString("hex")); + }); + + it("ignores options it cannot use, like node", () => { + const info = os.userInfo(); + + // Non-object options and non-string encodings fall back to utf8. + expect(os.userInfo(42)).toEqual(info); + expect(os.userInfo(null)).toEqual(info); + expect(os.userInfo("buffer")).toEqual(info); + expect(os.userInfo(() => {})).toEqual(info); + expect(os.userInfo({ encoding: 42 })).toEqual(info); + expect(os.userInfo({ encoding: undefined })).toEqual(info); + expect(os.userInfo({ encoding: "not-an-encoding" })).toEqual(info); + }); + + it("propagates an encoding getter that throws", () => { + expect(() => + os.userInfo({ + get encoding() { + throw new Error("xyz"); + }, + }), + ).toThrow("xyz"); + }); }); it("cpus", () => {