From 670d8ce1b4c16086ac2c8170e7e36ccb18edc5f6 Mon Sep 17 00:00:00 2001 From: robobun Date: Fri, 26 Jun 2026 05:14:50 +0000 Subject: [PATCH 1/7] Honor live $HOME mutations in os.homedir() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.homedir() snapshotted $HOME at first read via Bun's cached env-var accessor, so runtime mutations of process.env.HOME were invisible — even mutations before require('node:os'). Node's uv_os_homedir checks $HOME live on every call. - src/js/node/os.ts: read Bun.env["HOME"] live on every call (mirroring tmpdir), falling back to the native binding only when HOME is unset. HOME="" returns "" (matches Node/libuv: getenv non-NULL is returned verbatim; only absent HOME falls through to passwd). - src/runtime/node/node_os.rs: drop the cached HOME fast-path so the binding is the pure passwd fallback; os.userInfo().homedir keeps reading passwd (matches Node). Use getuid() not geteuid() for the passwd lookup so homedir and the reported uid stay consistent in a setuid process, matching libuv's uv__getpwuid_r. - On Windows, libuv's uv_os_homedir already reads USERPROFILE live, so the binding is used directly. Tests in test/js/node/os/os.test.js cover mutation before/after require, inherited HOME, HOME="", deleted HOME → passwd, and userInfo() ignoring HOME. Fixes #29244 --- src/js/node/os.ts | 24 ++++++- src/runtime/node/node_os.rs | 17 +++-- test/js/node/os/os.test.js | 121 +++++++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 11 deletions(-) diff --git a/src/js/node/os.ts b/src/js/node/os.ts index 3326d8093d54..500b249dbefe 100644 --- a/src/js/node/os.ts +++ b/src/js/node/os.ts @@ -24,6 +24,28 @@ var tmpdir = function () { return tmpdir(); }; +// os.homedir() must honor live mutations of $HOME (Node reads it via +// uv_os_homedir on every call). The HOME check runs here in JS because on +// POSIX process.env writes never reach the C environ, so the native binding +// cannot observe them. The binding is the passwd fallback when HOME is unset, +// and userInfo() calls it directly (Node's userInfo ignores HOME). +function homedirFactory(bindingHomedir) { + if (process.platform === "win32") { + // uv_os_homedir reads USERPROFILE live and falls back to + // GetUserProfileDirectoryW, so the binding alone is correct here. + return function homedir() { + return bindingHomedir(); + }; + } + return function homedir() { + // uv_os_homedir returns getenv("HOME") verbatim whenever it is non-NULL + // (including ""), and only falls through to passwd when HOME is absent. + 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 +125,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..b73578412f8f 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -713,6 +713,11 @@ mod _impl { pub(crate) fn homedir(global: &JSGlobalObject) -> JsResult { // In Node.js, this is a wrapper around uv_os_homedir. + // + // On POSIX the HOME env check lives in `src/js/node/os.ts` so it observes + // live `process.env.HOME` mutations; this function is the passwd fallback + // (and what `userInfo()` calls directly, matching Node's uv_os_get_passwd). + // On Windows uv_os_homedir already reads USERPROFILE live. #[cfg(windows)] { let mut out = PathBuffer::uninit(); @@ -727,14 +732,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,10 +748,12 @@ mod _impl { let mut result: *mut libc::passwd = core::ptr::null_mut(); let ret: c_int = loop { + // libuv's uv__getpwuid_r uses the real uid; userInfo() below reports + // uid = getuid(), so geteuid here would desync them under setuid. // SAFETY: valid buffers and out-pointer let ret = unsafe { libc::getpwuid_r( - libc::geteuid(), + libc::getuid(), &raw mut pw, string_bytes.as_mut_ptr().cast::(), string_bytes.len(), diff --git a/test/js/node/os/os.test.js b/test/js/node/os/os.test.js index ef9f6d6e5202..61b8948eee75 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 { bunEnv, bunExe, isWindows } from "harness"; import { isIPv4, isIPv6 } from "node:net"; import * as os from "node:os"; @@ -296,3 +296,122 @@ it("getPriority system error object", () => { expect(err.syscall).toBe("uv_os_getpriority"); } }); + +// https://github.com/oven-sh/bun/issues/29244 +// +// os.homedir() returned a stale value after process.env.HOME was mutated at +// runtime because the Zig binding read HOME via Bun's snapshot-on-first-read +// env-var cache. Node's posix uv_os_homedir checks HOME live on every call: +// it returns getenv("HOME") verbatim whenever it's non-NULL (so HOME="" → ""), +// and only falls back to the passwd entry when HOME is unset. +// os.userInfo().homedir reads passwd directly and does NOT honor HOME — that +// behavior must be preserved. +// +// Each test spawns its own subprocess so mutating process.env.HOME can't +// bleed into the test runner — so they run concurrently. +describe("homedir live $HOME mutations (#29244)", () => { + async function runBun(source, extraEnv = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: { ...bunEnv, ...extraEnv }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + return { stdout, exitCode }; + } + + it.concurrent.skipIf(isWindows)("reflects HOME mutation after require", async () => { + const { stdout, exitCode } = await runBun(` + const os = require('node:os'); + const before = os.homedir(); + process.env.HOME = '/tmp/test-home-29244'; + const after = os.homedir(); + console.log(JSON.stringify({ before, after, env: process.env.HOME })); + `); + const result = JSON.parse(stdout); + expect(result.after).toBe("/tmp/test-home-29244"); + expect(result.env).toBe("/tmp/test-home-29244"); + // Baseline came from the inherited HOME — non-empty, not the mutated value. + expect(typeof result.before).toBe("string"); + expect(result.before.length).toBeGreaterThan(0); + expect(result.before).not.toBe("/tmp/test-home-29244"); + expect(exitCode).toBe(0); + }); + + it.concurrent.skipIf(isWindows)("reflects HOME mutation before require", async () => { + const { stdout, exitCode } = await runBun(` + process.env.HOME = '/tmp/before-require-29244'; + const os = require('node:os'); + console.log(JSON.stringify({ homedir: os.homedir(), env: process.env.HOME })); + `); + expect(JSON.parse(stdout)).toEqual({ + homedir: "/tmp/before-require-29244", + env: "/tmp/before-require-29244", + }); + expect(exitCode).toBe(0); + }); + + it.concurrent.skipIf(isWindows)("honors HOME from parent env", async () => { + const { stdout, exitCode } = await runBun(`console.log(require('node:os').homedir());`, { + HOME: "/tmp/inherited-29244", + }); + expect(stdout.trim()).toBe("/tmp/inherited-29244"); + expect(exitCode).toBe(0); + }); + + it.concurrent.skipIf(isWindows)("returns '' when HOME is set to empty string", async () => { + // Match Node / libuv: uv_os_homedir returns whatever getenv("HOME") gives + // when non-NULL, including "". Only an absent HOME falls through to the + // passwd entry. Previously Bun treated "" as unset — divergent and now + // fixed. + const { stdout, exitCode } = await runBun(` + process.env.HOME = ''; + console.log(JSON.stringify(require('node:os').homedir())); + `); + expect(JSON.parse(stdout)).toBe(""); + expect(exitCode).toBe(0); + }); + + it.concurrent.skipIf(isWindows)("falls back to passwd when HOME is deleted", async () => { + // Deleted HOME (getenv returns NULL) is the one case that should fall + // through to the passwd entry, matching libuv's UV_ENOENT branch. + // + // Seed HOME with a sentinel value the passwd entry cannot possibly be, + // then delete it. If the delete were silently ignored (the regression + // class #29244 targets), homedir() would still return the sentinel. We + // also cross-check against os.userInfo().homedir — the passwd entry — + // to prove the passwd path was actually taken. + const sentinel = "/tmp/sentinel-deleted-29244"; + const { stdout, exitCode } = await runBun( + ` + delete process.env.HOME; + const os = require('node:os'); + console.log(JSON.stringify({ h: os.homedir(), passwd: os.userInfo().homedir })); + `, + { HOME: sentinel }, + ); + const result = JSON.parse(stdout); + expect(result.h).not.toBe(sentinel); // delete was honored + expect(result.h).toBe(result.passwd); // same source as userInfo + expect(result.h.length).toBeGreaterThan(0); + expect(result.h.startsWith("/")).toBe(true); + expect(exitCode).toBe(0); + }); + + it.concurrent.skipIf(isWindows)("userInfo().homedir ignores HOME mutation", async () => { + // Node's os.userInfo().homedir reads the passwd entry, NOT $HOME. + // The fix for os.homedir() must NOT leak into userInfo. + const { stdout, exitCode } = await runBun(` + process.env.HOME = '/tmp/should-not-appear-29244'; + const os = require('node:os'); + const passwd = os.userInfo().homedir; + console.log(JSON.stringify({ passwd, leaked: passwd === '/tmp/should-not-appear-29244' })); + `); + const result = JSON.parse(stdout); + expect(result.leaked).toBe(false); + expect(typeof result.passwd).toBe("string"); + expect(result.passwd.length).toBeGreaterThan(0); + expect(exitCode).toBe(0); + }); +}); From 5a280e5e10d71b70ae4515d72b86991b250cda50 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Jul 2026 04:41:50 +0000 Subject: [PATCH 2/7] homedir: drop the Windows pass-through wrapper, tighten comments On win32 the factory returned a function that only forwarded to the binding; return the binding itself instead. Compress the remaining comments to one sentence each. --- src/js/node/os.ts | 18 ++++++------------ src/runtime/node/node_os.rs | 12 ++++-------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/js/node/os.ts b/src/js/node/os.ts index 500b249dbefe..19e38e7c7eba 100644 --- a/src/js/node/os.ts +++ b/src/js/node/os.ts @@ -24,22 +24,16 @@ var tmpdir = function () { return tmpdir(); }; -// os.homedir() must honor live mutations of $HOME (Node reads it via -// uv_os_homedir on every call). The HOME check runs here in JS because on -// POSIX process.env writes never reach the C environ, so the native binding -// cannot observe them. The binding is the passwd fallback when HOME is unset, -// and userInfo() calls it directly (Node's userInfo ignores HOME). +// 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 reads USERPROFILE live and falls back to - // GetUserProfileDirectoryW, so the binding alone is correct here. - return function homedir() { - return bindingHomedir(); - }; + // uv_os_homedir already reads USERPROFILE live. + return bindingHomedir; } return function homedir() { - // uv_os_homedir returns getenv("HOME") verbatim whenever it is non-NULL - // (including ""), and only falls through to passwd when HOME is absent. + // Like libuv: HOME="" is returned verbatim; only absent HOME falls through. const home = Bun.env["HOME"]; if (home !== undefined) return home; return bindingHomedir(); diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index b73578412f8f..a8026484681f 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -712,12 +712,9 @@ mod _impl { } pub(crate) fn homedir(global: &JSGlobalObject) -> JsResult { - // In Node.js, this is a wrapper around uv_os_homedir. - // - // On POSIX the HOME env check lives in `src/js/node/os.ts` so it observes - // live `process.env.HOME` mutations; this function is the passwd fallback - // (and what `userInfo()` calls directly, matching Node's uv_os_get_passwd). - // On Windows uv_os_homedir already reads USERPROFILE live. + // 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(); @@ -748,8 +745,7 @@ mod _impl { let mut result: *mut libc::passwd = core::ptr::null_mut(); let ret: c_int = loop { - // libuv's uv__getpwuid_r uses the real uid; userInfo() below reports - // uid = getuid(), so geteuid here would desync them under setuid. + // Real uid, matching libuv and userInfo()'s uid field. // SAFETY: valid buffers and out-pointer let ret = unsafe { libc::getpwuid_r( From 0cd7f84a40ad3d0a88cd84a5ebc6e7d067fe2682 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Jul 2026 04:59:40 +0000 Subject: [PATCH 3/7] homedir: revert passwd lookup to geteuid(), matching libuv Verified against libuv v1.x src/unix/core.c: uv_os_get_passwd calls uv__getpwuid_r(pwd, geteuid()), and uv_os_homedir's fallback goes through it. The earlier switch to getuid() was based on an incorrect claim about libuv and diverged from Node in setuid processes. Also trim the test header to the issue URL plus the invariants and drop the stale Zig reference. --- src/runtime/node/node_os.rs | 4 ++-- test/js/node/os/os.test.js | 14 +++----------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index a8026484681f..56e32f146051 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -745,11 +745,11 @@ mod _impl { let mut result: *mut libc::passwd = core::ptr::null_mut(); let ret: c_int = loop { - // Real uid, matching libuv and userInfo()'s uid field. + // Effective uid: libuv's uv_os_get_passwd keys on geteuid(). // SAFETY: valid buffers and out-pointer let ret = unsafe { libc::getpwuid_r( - libc::getuid(), + libc::geteuid(), &raw mut pw, string_bytes.as_mut_ptr().cast::(), string_bytes.len(), diff --git a/test/js/node/os/os.test.js b/test/js/node/os/os.test.js index 61b8948eee75..11ed08d12301 100644 --- a/test/js/node/os/os.test.js +++ b/test/js/node/os/os.test.js @@ -298,17 +298,9 @@ it("getPriority system error object", () => { }); // https://github.com/oven-sh/bun/issues/29244 -// -// os.homedir() returned a stale value after process.env.HOME was mutated at -// runtime because the Zig binding read HOME via Bun's snapshot-on-first-read -// env-var cache. Node's posix uv_os_homedir checks HOME live on every call: -// it returns getenv("HOME") verbatim whenever it's non-NULL (so HOME="" → ""), -// and only falls back to the passwd entry when HOME is unset. -// os.userInfo().homedir reads passwd directly and does NOT honor HOME — that -// behavior must be preserved. -// -// Each test spawns its own subprocess so mutating process.env.HOME can't -// bleed into the test runner — so they run concurrently. +// os.homedir() must reflect live process.env.HOME (HOME="" → ""; only absent +// HOME falls through to passwd); os.userInfo().homedir must NOT honor HOME. +// Each test runs in a subprocess so mutating HOME can't leak into the runner. describe("homedir live $HOME mutations (#29244)", () => { async function runBun(source, extraEnv = {}) { await using proc = Bun.spawn({ From c06bfae9f9e7fe589e7e8d8298902f00e222c305 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 26 Jul 2026 05:24:38 +0000 Subject: [PATCH 4/7] test: drop bug-history sentence from empty-HOME comment --- test/js/node/os/os.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/js/node/os/os.test.js b/test/js/node/os/os.test.js index 11ed08d12301..a0beb33d8373 100644 --- a/test/js/node/os/os.test.js +++ b/test/js/node/os/os.test.js @@ -355,8 +355,7 @@ describe("homedir live $HOME mutations (#29244)", () => { it.concurrent.skipIf(isWindows)("returns '' when HOME is set to empty string", async () => { // Match Node / libuv: uv_os_homedir returns whatever getenv("HOME") gives // when non-NULL, including "". Only an absent HOME falls through to the - // passwd entry. Previously Bun treated "" as unset — divergent and now - // fixed. + // passwd entry. const { stdout, exitCode } = await runBun(` process.env.HOME = ''; console.log(JSON.stringify(require('node:os').homedir())); From 58e7023dfb5c94852610d5e664324a3adea11d91 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 12 Aug 2026 22:18:47 +0000 Subject: [PATCH 5/7] test: port homedir env tests to node:test for direct Node parity Move the six homedir//root tests into os-homedir-env.test.mjs using node:test + node:assert and process.execPath, so the identical file runs under Node.js: node test/js/node/os/os-homedir-env.test.mjs # 6 pass on v26.3.0 Verified fail-before/pass-after under bun bd (4/6 fail without the fix). --- test/js/node/os/os-homedir-env.test.mjs | 88 +++++++++++++++++++ test/js/node/os/os.test.js | 112 +----------------------- 2 files changed, 89 insertions(+), 111 deletions(-) create mode 100644 test/js/node/os/os-homedir-env.test.mjs diff --git a/test/js/node/os/os-homedir-env.test.mjs b/test/js/node/os/os-homedir-env.test.mjs new file mode 100644 index 000000000000..5c6e82f0f032 --- /dev/null +++ b/test/js/node/os/os-homedir-env.test.mjs @@ -0,0 +1,88 @@ +// 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.mjs +// Each check spawns process.execPath so HOME mutations can't leak out. +import assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; + +const isWindows = process.platform === "win32"; + +function runWithEnv(source, envOverride = {}) { + const env = { ...process.env }; + 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); +}); diff --git a/test/js/node/os/os.test.js b/test/js/node/os/os.test.js index a0beb33d8373..ef9f6d6e5202 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 { bunEnv, bunExe, isWindows } from "harness"; +import { isWindows } from "harness"; import { isIPv4, isIPv6 } from "node:net"; import * as os from "node:os"; @@ -296,113 +296,3 @@ it("getPriority system error object", () => { expect(err.syscall).toBe("uv_os_getpriority"); } }); - -// 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. -// Each test runs in a subprocess so mutating HOME can't leak into the runner. -describe("homedir live $HOME mutations (#29244)", () => { - async function runBun(source, extraEnv = {}) { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", source], - env: { ...bunEnv, ...extraEnv }, - stdout: "pipe", - stderr: "inherit", - }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - return { stdout, exitCode }; - } - - it.concurrent.skipIf(isWindows)("reflects HOME mutation after require", async () => { - const { stdout, exitCode } = await runBun(` - const os = require('node:os'); - const before = os.homedir(); - process.env.HOME = '/tmp/test-home-29244'; - const after = os.homedir(); - console.log(JSON.stringify({ before, after, env: process.env.HOME })); - `); - const result = JSON.parse(stdout); - expect(result.after).toBe("/tmp/test-home-29244"); - expect(result.env).toBe("/tmp/test-home-29244"); - // Baseline came from the inherited HOME — non-empty, not the mutated value. - expect(typeof result.before).toBe("string"); - expect(result.before.length).toBeGreaterThan(0); - expect(result.before).not.toBe("/tmp/test-home-29244"); - expect(exitCode).toBe(0); - }); - - it.concurrent.skipIf(isWindows)("reflects HOME mutation before require", async () => { - const { stdout, exitCode } = await runBun(` - process.env.HOME = '/tmp/before-require-29244'; - const os = require('node:os'); - console.log(JSON.stringify({ homedir: os.homedir(), env: process.env.HOME })); - `); - expect(JSON.parse(stdout)).toEqual({ - homedir: "/tmp/before-require-29244", - env: "/tmp/before-require-29244", - }); - expect(exitCode).toBe(0); - }); - - it.concurrent.skipIf(isWindows)("honors HOME from parent env", async () => { - const { stdout, exitCode } = await runBun(`console.log(require('node:os').homedir());`, { - HOME: "/tmp/inherited-29244", - }); - expect(stdout.trim()).toBe("/tmp/inherited-29244"); - expect(exitCode).toBe(0); - }); - - it.concurrent.skipIf(isWindows)("returns '' when HOME is set to empty string", async () => { - // Match Node / libuv: uv_os_homedir returns whatever getenv("HOME") gives - // when non-NULL, including "". Only an absent HOME falls through to the - // passwd entry. - const { stdout, exitCode } = await runBun(` - process.env.HOME = ''; - console.log(JSON.stringify(require('node:os').homedir())); - `); - expect(JSON.parse(stdout)).toBe(""); - expect(exitCode).toBe(0); - }); - - it.concurrent.skipIf(isWindows)("falls back to passwd when HOME is deleted", async () => { - // Deleted HOME (getenv returns NULL) is the one case that should fall - // through to the passwd entry, matching libuv's UV_ENOENT branch. - // - // Seed HOME with a sentinel value the passwd entry cannot possibly be, - // then delete it. If the delete were silently ignored (the regression - // class #29244 targets), homedir() would still return the sentinel. We - // also cross-check against os.userInfo().homedir — the passwd entry — - // to prove the passwd path was actually taken. - const sentinel = "/tmp/sentinel-deleted-29244"; - const { stdout, exitCode } = await runBun( - ` - delete process.env.HOME; - const os = require('node:os'); - console.log(JSON.stringify({ h: os.homedir(), passwd: os.userInfo().homedir })); - `, - { HOME: sentinel }, - ); - const result = JSON.parse(stdout); - expect(result.h).not.toBe(sentinel); // delete was honored - expect(result.h).toBe(result.passwd); // same source as userInfo - expect(result.h.length).toBeGreaterThan(0); - expect(result.h.startsWith("/")).toBe(true); - expect(exitCode).toBe(0); - }); - - it.concurrent.skipIf(isWindows)("userInfo().homedir ignores HOME mutation", async () => { - // Node's os.userInfo().homedir reads the passwd entry, NOT $HOME. - // The fix for os.homedir() must NOT leak into userInfo. - const { stdout, exitCode } = await runBun(` - process.env.HOME = '/tmp/should-not-appear-29244'; - const os = require('node:os'); - const passwd = os.userInfo().homedir; - console.log(JSON.stringify({ passwd, leaked: passwd === '/tmp/should-not-appear-29244' })); - `); - const result = JSON.parse(stdout); - expect(result.leaked).toBe(false); - expect(typeof result.passwd).toBe("string"); - expect(result.passwd.length).toBeGreaterThan(0); - expect(exitCode).toBe(0); - }); -}); From b9236a574c1a773bed2895364d994b0d5b73efc7 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 12 Aug 2026 22:22:23 +0000 Subject: [PATCH 6/7] test: rename homedir env test to .test.js (CJS) Plain .test.js runs under bun's test runner and directly under node (test/ is type: commonjs). 6/6 pass on Node v26.3.0 and bun bd. --- .../{os-homedir-env.test.mjs => os-homedir-env.test.js} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename test/js/node/os/{os-homedir-env.test.mjs => os-homedir-env.test.js} (94%) diff --git a/test/js/node/os/os-homedir-env.test.mjs b/test/js/node/os/os-homedir-env.test.js similarity index 94% rename from test/js/node/os/os-homedir-env.test.mjs rename to test/js/node/os/os-homedir-env.test.js index 5c6e82f0f032..a15282bf14cc 100644 --- a/test/js/node/os/os-homedir-env.test.mjs +++ b/test/js/node/os/os-homedir-env.test.js @@ -2,11 +2,11 @@ // 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.mjs +// node test/js/node/os/os-homedir-env.test.js // Each check spawns process.execPath so HOME mutations can't leak out. -import assert from "node:assert"; -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; +const assert = require("node:assert"); +const { spawnSync } = require("node:child_process"); +const { test } = require("node:test"); const isWindows = process.platform === "win32"; From 578bd78e9f9fdf2e13ab6680f976854008807469 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 12 Aug 2026 22:39:19 +0000 Subject: [PATCH 7/7] test: set quiet-log env vars in homedir test spawns No harness import is possible (the file runs under plain node), so set BUN_DEBUG_QUIET_LOGS/NO_COLOR explicitly like bunEnv would. --- test/js/node/os/os-homedir-env.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/js/node/os/os-homedir-env.test.js b/test/js/node/os/os-homedir-env.test.js index a15282bf14cc..fafe585b4a02 100644 --- a/test/js/node/os/os-homedir-env.test.js +++ b/test/js/node/os/os-homedir-env.test.js @@ -11,7 +11,9 @@ const { test } = require("node:test"); const isWindows = process.platform === "win32"; function runWithEnv(source, envOverride = {}) { - const env = { ...process.env }; + // 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;