Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/js/node/os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 4 additions & 9 deletions src/runtime/node/node_os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,9 @@ mod _impl {
}

pub(crate) fn homedir(global: &JSGlobalObject) -> JsResult<BunString> {
// 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.
Comment thread
robobun marked this conversation as resolved.
#[cfg(windows)]
{
let mut out = PathBuffer::uninit();
Expand All @@ -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
Expand All @@ -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(
Expand Down
90 changes: 90 additions & 0 deletions test/js/node/os/os-homedir-env.test.js
Original file line number Diff line number Diff line change
@@ -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);
});