Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
15 changes: 5 additions & 10 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,10 +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.
// 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::<c_char>(),
string_bytes.len(),
Expand Down
121 changes: 120 additions & 1 deletion test/js/node/os/os.test.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Outdated
//
// 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.
Comment thread
robobun marked this conversation as resolved.
Outdated
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);
});
});
Loading