diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index bdcff90c1b17..d1ce0514ac10 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -1,6 +1,6 @@ use core::ffi::c_int; #[cfg(not(windows))] -use core::ffi::{c_char, c_uint, c_void}; +use core::ffi::{c_uint, c_void}; use bun_core::String as BunString; use bun_jsc::bindgen::JsScope; @@ -574,81 +574,31 @@ mod _impl { } } - // 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 - // > will not usually be large. - // Instead of always using an allocation, first try a stack allocation - // of 4096, then fallback to heap. - let mut stack_string_bytes = [0u8; 4096]; - let mut heap_bytes: Vec; - let mut string_bytes: &mut [u8] = &mut stack_string_bytes[..]; - let mut using_heap = false; - - let mut pw: libc::passwd = bun_core::ffi::zeroed(); - let mut result: *mut libc::passwd = core::ptr::null_mut(); - - let ret: c_int = loop { - // SAFETY: valid buffers and out-pointer - let ret = unsafe { - libc::getpwuid_r( - libc::geteuid(), - &raw mut pw, - string_bytes.as_mut_ptr().cast::(), - string_bytes.len(), - &raw mut result, - ) - }; - - if ret == bun_sys::E::EINTR as c_int { - continue; - } - - // If the system call wants more memory, double it. - if ret == bun_sys::E::ERANGE as c_int { - let len = string_bytes.len(); - heap_bytes = vec![0u8; len * 2]; - string_bytes = &mut heap_bytes[..]; - using_heap = true; - continue; - } - - break ret; - }; - let _ = using_heap; - - if ret != 0 { - return Err(global.throw_value( + return match bun_sys::os::passwd_home_dir() { + Err(errno) => Err(global.throw_value( bun_sys::Error::from_code( - // `ret` is a libc errno; a code outside the table is `EUNKNOWN`. - bun_sys::E::from_raw(ret as u16), + // `errno` is a libc errno; a code outside the table is `EUNKNOWN`. + bun_sys::E::from_raw(errno as u16), bun_sys::Tag::uv_os_homedir, ) .to_js(global), - )); - } - - if result.is_null() { - // bionic has no passwd entries for app uids; with HOME also unset - // (zygote/run-as), return a usable default rather than throwing. - #[cfg(target_os = "android")] - { - return Ok(BunString::static_("/data/local/tmp")); + )), + Ok(None) => { + // bionic has no passwd entry for app uids: give a usable default, not ENOENT. + #[cfg(target_os = "android")] + { + return Ok(BunString::static_("/data/local/tmp")); + } + // in uv__getpwuid_r, null result throws UV_ENOENT. + #[cfg(not(target_os = "android"))] + return Err(global.throw_value( + bun_sys::Error::from_code(bun_sys::E::ENOENT, bun_sys::Tag::uv_os_homedir) + .to_js(global), + )); } - // in uv__getpwuid_r, null result throws UV_ENOENT. - #[cfg(not(target_os = "android"))] - return Err(global.throw_value( - bun_sys::Error::from_code(bun_sys::E::ENOENT, bun_sys::Tag::uv_os_homedir) - .to_js(global), - )); - } - - return Ok(if !pw.pw_dir.is_null() { - // SAFETY: pw_dir is a NUL-terminated C string from getpwuid_r - BunString::clone_utf8(unsafe { bun_core::ffi::cstr(pw.pw_dir) }.to_bytes()) - } else { - BunString::EMPTY - }); + Ok(Some(dir)) if dir.is_empty() => Ok(BunString::EMPTY), + Ok(Some(dir)) => Ok(BunString::clone_utf8(&dir)), + }; } } diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 20a35999b659..1d198d77b9f1 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -2025,25 +2025,46 @@ impl ShellExecEnv { } /// Looks up `$HOME` (`$USERPROFILE` on Windows) in `shell_env` first, then - /// `export_env`. Falls back to `""` (or `/data/local/tmp` on Android) so - /// `cd` with no args / `~` expansion never sees a null. - pub(crate) fn get_homedir(&self) -> crate::shell::env_str::EnvStr { + /// `export_env`. + fn home_env(&self) -> Option { use crate::shell::env_str::EnvStr; let key = if cfg!(windows) { EnvStr::init_slice(b"USERPROFILE") } else { EnvStr::init_slice(b"HOME") }; - self.shell_env - .get(key) - .or_else(|| self.export_env.get(key)) - .unwrap_or_else(|| { - EnvStr::init_slice(if bun_core::env::IS_ANDROID { - b"/data/local/tmp" - } else { - b"" - }) + self.shell_env.get(key).or_else(|| self.export_env.get(key)) + } + + /// Where `cd` with no args goes. `""` means `$HOME` is not set. + pub(crate) fn get_homedir(&self) -> crate::shell::env_str::EnvStr { + use crate::shell::env_str::EnvStr; + self.home_env().unwrap_or_else(|| { + EnvStr::init_slice(if bun_core::env::IS_ANDROID { + b"/data/local/tmp" + } else { + b"" }) + }) + } + + /// The value of `~`. `None` means no home is known and the `~` stays. + pub(crate) fn get_tilde_home(&self) -> Option { + use crate::shell::env_str::EnvStr; + if let Some(home) = self.home_env() { + return Some(home); + } + if bun_core::env::IS_ANDROID { + // bionic synthesizes a passwd entry with `/` or `/data` for every uid. + return Some(EnvStr::init_slice(b"/data/local/tmp")); + } + #[cfg(unix)] + if let Ok(Some(dir)) = bun_sys::os::passwd_home_dir() + && !dir.is_empty() + { + return Some(EnvStr::init_ref_counted(dir.into_boxed_slice())); + } + None } } diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs index 12b615773815..6169d52ef77b 100644 --- a/src/runtime/shell/states/Expansion.rs +++ b/src/runtime/shell/states/Expansion.rs @@ -225,17 +225,17 @@ impl Expansion { // All sub-atoms expanded — post-process leading tilde then finish. if leading_tilde { - let home = me.base.shell().get_homedir(); + let home = me.base.shell().get_tilde_home(); + let home_bytes: &[u8] = home.as_ref().map_or(b"~", |home| home.slice()); let len_before = me.current_out.len(); match me.current_out.first() { Some(b'/') | Some(b'\\') => { - me.current_out.splice(0..0, home.slice().iter().copied()); + me.current_out.splice(0..0, home_bytes.iter().copied()); } Some(_) => me.current_out.insert(0, b'~'), - // `~""` expands to $HOME, - // but `~$unset` expands to nothing (word is dropped). - None if me.has_quoted_empty => { - me.current_out.extend_from_slice(home.slice()); + // `~""` is $HOME. `~$unset` is dropped, or stays `~` when no home is known. + None if me.has_quoted_empty || home.is_none() => { + me.current_out.extend_from_slice(home_bytes); } None => {} } @@ -250,7 +250,9 @@ impl Expansion { *off += prepended; } } - home.deref(); + if let Some(home) = home { + home.deref(); + } } // Brace expansion // first, then glob, else flush as a single word. @@ -528,12 +530,16 @@ impl Expansion { out.push(b','); } ast::SimpleAtom::Tilde => { - if expand_tilde { - let home = shell.get_homedir(); - out.extend_from_slice(home.slice()); - home.deref(); + match if expand_tilde { + shell.get_tilde_home() } else { - out.push(b'~'); + None + } { + Some(home) => { + out.extend_from_slice(home.slice()); + home.deref(); + } + None => out.push(b'~'), } } ast::SimpleAtom::CmdSubst(_) => return true, diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 96395ca9df62..0582bcff0d23 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -912,6 +912,7 @@ pub use signal_code::SignalCode; pub mod beneath; pub mod tmp; pub use tmp::Tmpfile; +pub mod os; // `windows/mod.rs` is `#![cfg(windows)]`-gated internally; on POSIX this // declares an empty module so `bun_sys::windows::*` paths still resolve under // `#[cfg(windows)]` arms in dependents. diff --git a/src/sys/os.rs b/src/sys/os.rs new file mode 100644 index 000000000000..01e34dd62267 --- /dev/null +++ b/src/sys/os.rs @@ -0,0 +1,52 @@ +//! `node:os` platform queries. + +#[cfg(unix)] +use core::ffi::{c_char, c_int}; + +/// The effective user's home from the passwd database. `Ok(None)`: no entry. `Err`: the errno. +#[cfg(unix)] +pub fn passwd_home_dir() -> Result>, c_int> { + // Like libuv's uv__getpwuid_r: start with 4096 bytes, double on ERANGE. + let mut stack = [0u8; 4096]; + let mut heap: Vec; + let mut buf: &mut [u8] = &mut stack; + let mut pw: libc::passwd = bun_core::ffi::zeroed(); + let mut result: *mut libc::passwd = core::ptr::null_mut(); + loop { + // SAFETY: `pw`/`result` are stack out-params; `buf` is writable for `buf.len()`. + let rc = unsafe { + libc::getpwuid_r( + libc::geteuid(), + &raw mut pw, + buf.as_mut_ptr().cast::(), + buf.len(), + &raw mut result, + ) + }; + if rc == libc::EINTR { + continue; + } + if rc == libc::ERANGE { + let len = buf.len(); + heap = vec![0u8; len * 2]; + buf = &mut heap; + continue; + } + if rc != 0 { + return Err(rc); + } + break; + } + if result.is_null() { + return Ok(None); + } + if pw.pw_dir.is_null() { + return Ok(Some(Vec::new())); + } + // SAFETY: on success `pw_dir` is a NUL-terminated string inside `buf`. + Ok(Some( + unsafe { core::ffi::CStr::from_ptr(pw.pw_dir) } + .to_bytes() + .to_vec(), + )) +} diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 4093aeb1c6f8..c51b8ab32663 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -6,7 +6,7 @@ */ import { $ } from "bun"; import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; -import { chmodSync, mkdirSync, symlinkSync } from "fs"; +import { chmodSync, mkdirSync, readFileSync, statSync, symlinkSync } from "fs"; import { mkdir, rm, stat } from "fs/promises"; import { bunExe, @@ -19,10 +19,23 @@ import { tempDirWithFiles, tmpdirSync, } from "harness"; -import { join, sep } from "path"; +import { dirname, join, sep } from "path"; import { createTestBuilder, sortedShellOutput } from "./util"; const TestBuilder = createTestBuilder(import.meta.path); +// True when a process of another uid can traverse to `path` and execute it. +function othersCanExecute(path: string) { + try { + if ((statSync(path).mode & 0o005) !== 0o005) return false; + for (let dir = dirname(path); ; dir = dirname(dir)) { + if ((statSync(dir).mode & 0o001) === 0) return false; + if (dir === dirname(dir)) return true; + } + } catch { + return false; + } +} + afterAll(() => console.error("After all RSS", rss() / 1024 / 1024)); beforeAll(() => console.error("Before all RSS", rss() / 1024 / 1024)); @@ -765,6 +778,53 @@ describe("bunshell", () => { .stdout("\n/Documents\n") .runAsTest("empty $HOME or $USERPROFILE"); + // Runs `script` in the Bun shell with $HOME unset. Prints [home, output]: `home` is what + // os.homedir() gives, or null when the uid has no passwd entry. + async function tildeWithHomeUnset(script: string, spawnOptions: { uid?: number; gid?: number; cwd?: string } = {}) { + const env: Record = { ...bunEnv }; + delete env.HOME; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let home = null; try { home = require("os").homedir(); } catch {} + console.log(JSON.stringify([home, (await Bun.$\`${script}\`.text()).trim()]))`, + ], + env, + stderr: "pipe", + ...spawnOptions, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.skipIf(isWindows)("unset $HOME falls back to the passwd home like os.homedir()", async () => { + const { stdout, stderr, exitCode } = await tildeWithHomeUnset("echo ~ a ~/x"); + expect(stderr).toBe(""); + const [home, expanded] = JSON.parse(stdout); + // A uid with no passwd entry has no home, and the ~ stays. + expect(expanded).toBe(home ? `${home} a ${home}/x` : "~ a ~/x"); + expect(exitCode).toBe(0); + }); + + // Needs root: the child runs as a uid that has no passwd entry, and that uid must reach the binary. + test.skipIf(isWindows || !isRoot || !othersCanExecute(bunExe()))( + "unset $HOME and no passwd entry keeps the ~ instead of expanding it to nothing", + async () => { + const passwd = readFileSync("/etc/passwd", "utf8"); + let uid = 60123; + while (passwd.includes(`:${uid}:`)) uid++; + const { stdout, stderr, exitCode } = await tildeWithHomeUnset(`echo ~ a ~/x ~"" ~$UNSET_VARIABLE`, { + uid, + gid: uid, + cwd: "/", + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([null, "~ a ~/x ~ ~"]); + expect(exitCode).toBe(0); + }, + ); + describe("modified $HOME or $USERPROFILE", async () => { TestBuilder.command`HOME=lmao USERPROFILE=lmao && echo ~`.stdout("lmao\n").runAsTest("1");