diff --git a/src/js/node/os.ts b/src/js/node/os.ts index 3326d8093d54..19e38e7c7eba 100644 --- a/src/js/node/os.ts +++ b/src/js/node/os.ts @@ -24,6 +24,22 @@ var tmpdir = function () { return tmpdir(); }; +// Node reads $HOME on every os.homedir() call, but POSIX process.env writes +// never reach the C environ, so the live check must happen here in JS. The +// native binding is the passwd fallback, which userInfo() also calls directly. +function homedirFactory(bindingHomedir) { + if (process.platform === "win32") { + // uv_os_homedir already reads USERPROFILE live. + return bindingHomedir; + } + return function homedir() { + // Like libuv: HOME="" is returned verbatim; only absent HOME falls through. + const home = Bun.env["HOME"]; + if (home !== undefined) return home; + return bindingHomedir(); + }; +} + // os.cpus() is super expensive // Specifically: getting the CPU speed on Linux is very expensive // Some packages like FastGlob only bother to read the length of the array @@ -103,7 +119,7 @@ function bound(binding) { }, freemem: binding.freemem, getPriority: binding.getPriority, - homedir: binding.homedir, + homedir: homedirFactory(binding.homedir), hostname: binding.hostname, loadavg: binding.loadavg, networkInterfaces: binding.networkInterfaces, diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index 01cc6ea7392d..56e32f146051 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -712,7 +712,9 @@ mod _impl { } pub(crate) fn homedir(global: &JSGlobalObject) -> JsResult { - // In Node.js, this is a wrapper around uv_os_homedir. + // In Node.js, this is a wrapper around uv_os_homedir. The live HOME + // check lives in `src/js/node/os.ts`; this is the passwd fallback, + // which `userInfo()` also calls directly. #[cfg(windows)] { let mut out = PathBuffer::uninit(); @@ -727,14 +729,6 @@ mod _impl { } #[cfg(not(windows))] { - // The posix implementation of uv_os_homedir first checks the HOME - // environment variable, then falls back to reading the passwd entry. - if let Some(home) = env_var::HOME.get() { - if !home.is_empty() { - return Ok(BunString::init(home)); - } - } - // 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 @@ -751,6 +745,7 @@ mod _impl { let mut result: *mut libc::passwd = core::ptr::null_mut(); let ret: c_int = loop { + // Effective uid: libuv's uv_os_get_passwd keys on geteuid(). // SAFETY: valid buffers and out-pointer let ret = unsafe { libc::getpwuid_r( diff --git a/test/js/node/os/os-homedir-env.test.js b/test/js/node/os/os-homedir-env.test.js new file mode 100644 index 000000000000..fafe585b4a02 --- /dev/null +++ b/test/js/node/os/os-homedir-env.test.js @@ -0,0 +1,90 @@ +// https://github.com/oven-sh/bun/issues/29244 +// os.homedir() must reflect live process.env.HOME (HOME="" → ""; only absent +// HOME falls through to passwd); os.userInfo().homedir must NOT honor HOME. +// Uses node:test so the same file runs under Node.js to verify parity: +// node test/js/node/os/os-homedir-env.test.js +// Each check spawns process.execPath so HOME mutations can't leak out. +const assert = require("node:assert"); +const { spawnSync } = require("node:child_process"); +const { test } = require("node:test"); + +const isWindows = process.platform === "win32"; + +function runWithEnv(source, envOverride = {}) { + // No harness import (the file must run under plain node), so set the + // quiet-log vars bunEnv would provide; node ignores them. + const env = { ...process.env, BUN_DEBUG_QUIET_LOGS: "1", NO_COLOR: "1" }; + for (const [key, value] of Object.entries(envOverride)) { + if (value === undefined) delete env[key]; + else env[key] = value; + } + const { stdout, stderr, status } = spawnSync(process.execPath, ["-e", source], { + env, + encoding: "utf8", + }); + assert.strictEqual(status, 0, stderr); + return JSON.parse(stdout); +} + +test("homedir() reflects HOME mutation after require", { skip: isWindows }, () => { + const result = runWithEnv(` + const os = require("node:os"); + const before = os.homedir(); + process.env.HOME = "/tmp/test-home-29244"; + console.log(JSON.stringify({ before, after: os.homedir() })); + `); + assert.strictEqual(result.after, "/tmp/test-home-29244"); + assert.notStrictEqual(result.before, "/tmp/test-home-29244"); + assert.ok(result.before.length > 0); +}); + +test("homedir() reflects HOME mutation before require", { skip: isWindows }, () => { + const result = runWithEnv(` + process.env.HOME = "/tmp/before-require-29244"; + console.log(JSON.stringify(require("node:os").homedir())); + `); + assert.strictEqual(result, "/tmp/before-require-29244"); +}); + +test("homedir() honors HOME from parent env", { skip: isWindows }, () => { + const result = runWithEnv(`console.log(JSON.stringify(require("node:os").homedir()));`, { + HOME: "/tmp/inherited-29244", + }); + assert.strictEqual(result, "/tmp/inherited-29244"); +}); + +test("homedir() returns '' when HOME is set to empty string", { skip: isWindows }, () => { + // uv_os_homedir returns getenv("HOME") verbatim whenever it is non-NULL, + // including ""; only an absent HOME falls through to the passwd entry. + const result = runWithEnv(` + process.env.HOME = ""; + console.log(JSON.stringify(require("node:os").homedir())); + `); + assert.strictEqual(result, ""); +}); + +test("homedir() falls back to passwd when HOME is deleted", { skip: isWindows }, () => { + // Seed a sentinel so a silently-ignored delete is detectable, and + // cross-check against userInfo().homedir (the passwd entry). + const sentinel = "/tmp/sentinel-deleted-29244"; + const result = runWithEnv( + ` + delete process.env.HOME; + const os = require("node:os"); + console.log(JSON.stringify({ h: os.homedir(), passwd: os.userInfo().homedir })); + `, + { HOME: sentinel }, + ); + assert.notStrictEqual(result.h, sentinel); + assert.strictEqual(result.h, result.passwd); + assert.ok(result.h.startsWith("/")); +}); + +test("userInfo().homedir ignores HOME mutation", { skip: isWindows }, () => { + const result = runWithEnv(` + process.env.HOME = "/tmp/should-not-appear-29244"; + console.log(JSON.stringify(require("node:os").userInfo().homedir)); + `); + assert.notStrictEqual(result, "/tmp/should-not-appear-29244"); + assert.ok(result.length > 0); +});