Skip to content

Read os.userInfo() from the passwd database - #33481

Open
robobun wants to merge 4 commits into
mainfrom
farm/f0de4f78/os-userinfo-passwd-lookup
Open

Read os.userInfo() from the passwd database#33481
robobun wants to merge 4 commits into
mainfrom
farm/f0de4f78/os-userinfo-passwd-lookup

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

os.userInfo() was assembled from $USER and $SHELL, and its options argument was discarded (let _ = options; // TODO:). Any caller that trusts it for the real login shell or account name gets whatever the parent process put in the environment, and when the effective uid has no passwd entry (docker run --user 12345, distroless images, OpenShift arbitrary-uid) it fabricates {username: "unknown", shell: "unknown", ...} where node throws.

Repro

import os from "node:os";
import { spawnSync } from "node:child_process";

if (process.env.STAGE2) {
  const u = os.userInfo();
  const b = os.userInfo({ encoding: "buffer" });
  console.log(JSON.stringify(u));
  console.log("keys:", Object.keys(u).join(","));
  console.log("buffer opt honored:", [b.username, b.homedir, b.shell].map(Buffer.isBuffer).join(","));
  process.exit(0);
}
const r = spawnSync(process.execPath, [new URL(import.meta.url).pathname], {
  env: { ...process.env, STAGE2: "1", SHELL: "/not-a-real-shell", USER: "nobody-x", LOGNAME: "nobody-x" },
  encoding: "utf8",
});
process.stdout.write(r.stdout);

Running as uid 0, whose real passwd row is root ... /root /bin/bash:

node: {"uid":0,"gid":0,"username":"root","homedir":"/root","shell":"/bin/bash"}
      keys: uid,gid,username,homedir,shell
      buffer opt honored: true,true,true
bun : {"homedir":"/root","username":"nobody-x","shell":"/not-a-real-shell","uid":0,"gid":0}
      keys: homedir,username,shell,uid,gid
      buffer opt honored: false,false,false

And under a uid with no passwd entry:

P='const os=require("os");try{console.log("RETURNED",JSON.stringify(os.userInfo()))}catch(e){console.log("THREW",e.name,e.code,e.syscall)}'
env -i HOME=/hx PATH=$PATH setpriv --reuid=54321 --regid=54321 --clear-groups node -e "$P"
# THREW SystemError ERR_SYSTEM_ERROR uv_os_get_passwd
env -i HOME=/hx PATH=$PATH setpriv --reuid=54321 --regid=54321 --clear-groups bun  -e "$P"
# RETURNED {"homedir":"/hx","username":"unknown","shell":"unknown","uid":54321,"gid":54321}

os.userInfo(42) also threw a TypeError where node ignores a non-object options.

Cause

node_os.rs's user_info() read env_var::USER / env_var::SHELL, took homedir from os.homedir() (which checks $HOME first), and never looked at options. node's GetUserInfo calls uv_os_get_passwd(), which is getpwuid_r(geteuid()) on POSIX and GetUserProfileDirectoryW + GetUserNameW on Windows, then encodes each string field with StringBytes::Encode(..., encoding).

Only os.homedir() legitimately consults the environment; os.userInfo().homedir is documented as coming from the OS.

Fix

  • POSIX: getpwuid_r(geteuid()), reusing the EINTR/ERANGE retry loop that os.homedir() already had. It is extracted into with_euid_passwd() so there is one copy. uid/gid now come from pw_uid/pw_gid, and shell is null when the entry has none, as in uv__getpwuid_r.
  • Windows: uv_os_get_passwd() + uv_os_free_passwd(), so username and homedir stop tracking %USERNAME% / %USERPROFILE%. uid/gid stay -1 and shell stays null, matching node's static_cast<int32_t>(pwd.uid & 0xFFFFFFFF).
  • No passwd entry for the effective uid now throws SystemError with code: 'ERR_SYSTEM_ERROR', syscall: 'uv_os_get_passwd' and an info object carrying {errno, code: 'ENOENT', message, syscall}, matching node's lib/os.js throw new ERR_SYSTEM_ERROR(ctx) path. os.homedir() with $HOME unset and no passwd entry gets the same treatment (it previously threw a plain Error with code: 'ENOENT').
  • encoding is honored for username, homedir, and shell, including "buffer". An unrecognized or non-string encoding falls back to utf8, like node's ParseEncoding(..., UTF8).
  • The result is a null-prototype object with node's key order (uid, gid, username, homedir, shell), and a non-object options is ignored instead of rejected. os.userInfo.length is now 1, also matching node.

bun_sys::Tag gains uv_os_get_passwd so the thrown err.syscall reads the same as node's.

Verification

test/js/node/os/os.test.js grows a userInfo block. The load-bearing test spawns two children whose account environment variables disagree on everything (USER, LOGNAME, USERNAME, SHELL, HOME, USERPROFILE) and asserts they still report the same account; on Linux a second test pins the result against the effective uid's /etc/passwd row. Two setpriv-based tests (Linux + root only) cover the no-passwd-entry throw for both userInfo() and homedir(). The rest cover the encoding matrix, the object shape, and the options-coercion cases.

Seven of the eight new tests fail on the unfixed build and pass with the fix. test/js/node/test/parallel/test-os.js, test-os-homedir-no-envvar.js and test-os-userinfo-handles-getter-errors.js still pass, and all 10 rust target triples compile (bun run rust:check-all).

Output is now byte-identical to node v26.3.0 across every encoding and options shape
                         node                       bun
proto-null:              true                       true
keys:                    uid,gid,username,homedir,shell  (same)
len/name:                1 userInfo                  1 userInfo
plain:                   {"uid":0,...,"shell":"/bin/bash"}       (same)
{encoding:"buffer"}:     Buffers for all three       (same)
{encoding:"BUFFER"}:     Buffers for all three       (same)
{encoding:"hex"}:        "726f6f74" ...              (same)
{encoding:"base64"}:     "cm9vdA==" ...              (same)
{encoding:"latin1"}:     "root" ...                  (same)
{encoding:"bogus"}:      utf8 fallback               (same)
{encoding:42}:           utf8 fallback               (same)
userInfo(42):            ignored                     (same)
userInfo("hex"):         ignored                     (same)
userInfo(function(){}):  ignored                     (same)
userInfo([]):            ignored                     (same)
throwing encoding getter: propagates                 (same)
no passwd entry:         ERR_SYSTEM_ERROR/uv_os_get_passwd  (same)

Run against the real passwd row while $HOME, $SHELL, $USER and $LOGNAME were all poisoned.

Not fixed here

os.userInfo({ encoding: "utf-16le" }) still falls back to utf8. That spelling is missing from the shared ENCODING_MAP in src/runtime/node/types.rs (which instead carries a utf16-le alias node rejects), so fs.readFileSync(f, "utf-16le") and CryptoHasher are wrong in the same way. Fixing the table touches those surfaces and wants its own tests, so it is left for a follow-up. ucs2, ucs-2 and utf16le all work.

#29248 also touches os.homedir(); it is an independent fix for $HOME being cached, and it calls out this Windows userInfo divergence as a follow-up.

Fixes #25171

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:56 PM PT - Jul 23rd, 2026

@autofix-ci[bot], your commit 1101c82 has 1 failures in Build #79165 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33481

That installs a local version of the PR into your bun-33481 executable, so you can run:

bun-33481 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. os.userInfo().shell returns "unknown" instead of reading from /etc/passwd in containers #25171 - os.userInfo().shell returns "unknown" in containers because Bun reads from $USER/$SHELL env vars instead of the passwd database; this PR switches to getpwuid_r()

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #25171

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR changes os.userInfo to derive user information from OS passwd entries rather than environment variables. The JS wrapper, code-gen bindings, native Rust implementation, and libuv FFI signatures are updated to pass encoding directly instead of an options struct, with a new syscall tag and expanded tests.

Changes

userInfo passwd rewrite

Layer / File(s) Summary
JS-level userInfo option handling
src/js/node/os.ts
Wraps userInfo binding call to extract encoding from options only when valid, forwarding it to the native binding.
Binding definition and codegen dispatch
src/runtime/node/node_os.bind.ts, src/runtime/hw_exports.rs
Removes UserInfoOptions dictionary, adds direct encoding argument to the binding, and updates the generated dispatch function to accept an encoding string pointer.
libuv FFI return type and syscall tag
src/libuv_sys/libuv.rs, src/sys/lib.rs
Changes uv_os_get_passwd FFI return type to ReturnCode and adds a new uv_os_get_passwd syscall tag with a corresponding name table entry.
Native passwd lookup and user_info/homedir rewrite
src/runtime/node/node_os.rs
Removes UserInfoOptions struct, adds with_euid_passwd/passwd_field helpers for safe passwd lookups, refactors homedir to use them, and rewrites user_info to build results from passwd data (uid/gid/username/homedir/shell) with encoding-aware null-prototype JS object output; Windows path uses uv_os_get_passwd.
Encoding helper simplification
src/runtime/node/types.rs
Replaces encode_with_size with a simplified encode method used for encoding string fields.
userInfo test suite
test/js/node/os/os.test.js
Adds tests covering env-var poisoning, passwd-entry matching on Linux, return shape, encoding option behavior, invalid input handling, and error propagation from a throwing encoding getter.

Suggested reviewers: alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: sourcing os.userInfo() from the passwd database.
Description check ✅ Passed The description explains the change and verification, though it does not use the exact requested section headings.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/os/os.test.js`:
- Around line 205-208: The os.userInfo encoding coverage for the hex case is
incomplete because it asserts only username and homedir, unlike the buffer case.
Update the hex test in os.test.js to also verify hex.shell, using the same
pattern as the existing username and homedir assertions, so the hex path is
covered consistently.
- Around line 139-167: The subprocess-spawning tests in this block are still
running sequentially; update the affected `it(...)` cases that call
`userInfoWithPoisonedEnv` to run concurrently. Use `test.concurrent` or the
equivalent concurrent variant for the `is read from the operating system, not
the environment` and `reports the passwd entry of the effective uid` tests so
they can execute in parallel. Keep the existing assertions and `passwdEntry`
setup unchanged, only adjust the test declarations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 94e08828-736e-47bd-bf40-ff48ade1c661

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and ba1cb04.

📒 Files selected for processing (8)
  • src/js/node/os.ts
  • src/libuv_sys/libuv.rs
  • src/runtime/hw_exports.rs
  • src/runtime/node/node_os.bind.ts
  • src/runtime/node/node_os.rs
  • src/runtime/node/types.rs
  • src/sys/lib.rs
  • test/js/node/os/os.test.js

Comment thread test/js/node/os/os.test.js Outdated
Comment thread test/js/node/os/os.test.js
Comment thread src/runtime/node/node_os.rs
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

For reference: farm/3158eafe/posix-userinfo-passwd-fallback implements the narrower alternative (env-first with a getpwuid_r fallback, the same approach #33705 takes on Windows) in case the smaller behaviour change is preferred. This PR's node-exact approach is the more complete fix; the branch is just the 2-file fallback variant with tests that fail before and pass after.

robobun added 2 commits July 24, 2026 02:01
os.userInfo() built its result out of $USER and $SHELL, and discarded the
options argument entirely. node reads the passwd entry of the effective
uid (uv_os_get_passwd), so a caller could be handed whatever the parent
process put in the environment.

Replace it with getpwuid_r(geteuid()) on POSIX and uv_os_get_passwd on
Windows, sharing the lookup loop with os.homedir(). Honor the encoding
option, including encoding: "buffer". Match node's key order, its
null-prototype result object, and its tolerance of a non-object options
argument.
When the effective uid has no passwd entry (docker --user N, distroless,
OpenShift arbitrary-uid), os.userInfo() and os.homedir() now throw a
SystemError with code ERR_SYSTEM_ERROR and an info object, matching
node's lib/os.js ERR_SYSTEM_ERROR(ctx) path. Previously they threw a
plain Error with code ENOENT.

Adds a setpriv-based regression test for the no-passwd-entry case.
@robobun
robobun force-pushed the farm/f0de4f78/os-userinfo-passwd-lookup branch from ba1cb04 to 0f101bb Compare July 24, 2026 02:21
@robobun
robobun force-pushed the farm/f0de4f78/os-userinfo-passwd-lookup branch from 4a9caba to 1101c82 Compare July 24, 2026 02:25
Comment on lines +788 to +792
fn throw_uv_error(global: &JSGlobalObject, err: bun_sys::Error) -> bun_jsc::JsError {
global.throw_value(
SystemError::from(err.to_system_error()).to_error_instance_with_info_object(global),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 throw_uv_error feeds err.to_system_error() into to_error_instance_with_info_object(), but those two disagree on what .message holds: to_system_error() produces the pre-formatted UVException string ("ENOENT: no such file or directory, uv_os_get_passwd"), while toErrorInstanceWithInfoObject expects the bare libuv description and wraps it again — yielding err.message = "A system error occurred: uv_os_get_passwd returned ENOENT (ENOENT: no such file or directory, uv_os_get_passwd)" and err.info.message = "ENOENT: no such file or directory, uv_os_get_passwd" instead of Node's "...(no such file or directory)" / "no such file or directory". Build the SystemError with just the bare label, the way the sibling get_priority path does (message: BunString::static_("no such process"), asserted byte-for-byte at the bottom of os.test.js).

Extended reasoning...

What the bug is. The new throw_uv_error helper (src/runtime/node/node_os.rs:788-792) composes two functions that have incompatible contracts on SystemError.message:

fn throw_uv_error(global: &JSGlobalObject, err: bun_sys::Error) -> bun_jsc::JsError {
    global.throw_value(
        SystemError::from(err.to_system_error()).to_error_instance_with_info_object(global),
    )
}

bun_sys::Error::to_system_error() (src/sys/Error.rs:409-469) writes .message in Node's UVException format — "{CODE}: {label}, {syscall}" — via the cursor at lines 419-439. Its doc comment says "format taken from Node.js 'exceptions.cc' … Local<Value> UVException". That format is meant to be used verbatim by to_error_instance() (the non-info-object path).

SystemError__toErrorInstanceWithInfoObject (src/jsc/bindings/bindings.cpp:2337-2372), on the other hand, treats err.message as the bare libuv description and wraps it at line 2347:

auto message = makeString("A system error occurred: "_s, syscallString, " returned "_s, codeString, " ("_s, messageString, ")"_s);

and copies messageString verbatim into err.info.message at line 2366.

Step-by-step trace. Take the Ok(None) arm of user_info() on POSIX (no passwd entry for the effective uid — the docker run --user 12345 / distroless case the PR's own setpriv test exercises):

  1. bun_sys::Error::from_code(ENOENT, Tag::uv_os_get_passwd)throw_uv_error.
  2. to_system_error() runs fill_system_error_common (sets .code = "ENOENT", .syscall = "uv_os_get_passwd", .errno), then formats .message = "ENOENT: no such file or directory, uv_os_get_passwd" (the LIBUV_ERROR_MAP label for ENOENT is "no such file or directory").
  3. SystemError::from(...) is a straight field copy.
  4. to_error_instance_with_info_object builds err.message = "A system error occurred: uv_os_get_passwd returned ENOENT (ENOENT: no such file or directory, uv_os_get_passwd)" and err.info.message = "ENOENT: no such file or directory, uv_os_get_passwd".

Node.js produces err.message = "A system error occurred: uv_os_get_passwd returned ENOENT (no such file or directory)" and err.info.message = "no such file or directory" (Node's lib/internal/errors.js SystemError reads ctx.message = uvErrmapGet(ctx.errno)?.[1], which is the bare libuv description).

The same double-formatting applies to the Err(errno) arm of user_info, both arms of homedir (POSIX, when $HOME is unset), and the Windows uv_os_homedir / uv_os_get_passwd failure paths — all four call sites route through throw_uv_error.

Why existing code doesn't prevent it. The PR's own setpriv test only inspects e.name, e.code, e.syscall, and e.info?.code, never e.message or e.info.message, so it passes. Compare with the sibling get_priority path in the same file (node_os.rs:700-712), which hand-builds the SystemError with message: BunString::static_("no such process") — just the bare label — and whose output is asserted byte-for-byte in os.test.js (expect(err.message).toBe("A system error occurred: uv_os_getpriority returned ESRCH (no such process)") and expect(err.info).toEqual({ ..., message: "no such process", ... })). throw_uv_error is the first caller to feed to_system_error()'s output into the info-object path.

Impact. Only the human-readable err.message and err.info.message diverge; the structured fields callers actually branch on (name, code, syscall, errno, info.code, info.errno, info.syscall) are all correct. This is a rarely-hit error path (missing passwd entry / libuv failure). Filed as a nit — worth fixing given the PR description's "byte-identical to node" claim and REVIEW.md's "error messages are reviewed word-for-word", and to stay consistent with the neighboring getPriority path, but nothing functionally breaks.

Fix. Have throw_uv_error build the SystemError with .message set to just the libuv description label (what LIBUV_ERROR_MAP[errno] returns). Either hand-build it like get_priority/set_priority1 do, or add a helper on bun_sys::Error that returns the bare label alongside code/syscall/errno (analogous to to_shell_system_error but keyed by LIBUV_ERROR_MAP) — to_system_error() is the wrong producer for this consumer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os.userInfo().shell returns "unknown" instead of reading from /etc/passwd in containers

1 participant