diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index a418fb37d92a..fa402ea6364f 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -833,27 +833,13 @@ mod _impl { pub(crate) fn hostname(global: &JSGlobalObject) -> JsResult { #[cfg(windows)] { - let mut name_buffer: [u16; 130] = [0; 130]; // [129:0]u16 → 130 u16s with NUL at [129] - // SAFETY: valid buffer - if unsafe { windows::GetHostNameW(name_buffer.as_mut_ptr(), 129) } == 0 { - let str = BunString::clone_utf16(slice_to_nul_u16(&name_buffer)); + let mut name_buffer: [u16; 256] = [0; 256]; + if let Some(name) = windows::gethostname_w(&mut name_buffer) { + let str = BunString::clone_utf16(name); let js = str.to_js(global); str.deref(); return js; } - - let mut result: windows::ws2_32::WSADATA = bun_core::ffi::zeroed(); - // SAFETY: valid out-pointer - if unsafe { windows::ws2_32::WSAStartup(0x202, &mut result) } == 0 { - // SAFETY: valid buffer - if unsafe { windows::GetHostNameW(name_buffer.as_mut_ptr(), 129) } == 0 { - let y = BunString::clone_utf16(slice_to_nul_u16(&name_buffer)); - let js = y.to_js(global); - y.deref(); - return js; - } - } - return Ok(ZigString::init(b"unknown").with_encoding().to_js(global)); } #[cfg(not(windows))] @@ -1731,10 +1717,3 @@ fn parse_u64(s: &[u8]) -> crate::Result { fn parse_u32(s: &[u8]) -> crate::Result { bun_core::fmt::parse_int(s, 10).map_err(|_| crate::Error::InvalidCharacter) } - -#[cfg(windows)] -#[inline] -fn slice_to_nul_u16(buf: &[u16]) -> &[u16] { - let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); - &buf[..nul] -} diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index e9bbdcab23f4..388b2b611abb 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -588,6 +588,7 @@ impl Interpreter { __cwd: cwd_arr, cwd_fd, async_pids: SmolList::default(), + start_instant: std::time::Instant::now(), }), root_io: JsCell::new(IO { stdin: crate::shell::io::InKind::Fd(stdin_reader), @@ -1793,6 +1794,9 @@ pub struct ShellExecEnv { pub __cwd: Vec, pub cwd_fd: Fd, pub async_pids: SmolList, + /// For `$SECONDS`. Copied (not reset) by `dupe_for_subshell` so a subshell + /// sees the parent's elapsed time, matching bash. + pub start_instant: std::time::Instant, } pub enum Bufio { @@ -1958,6 +1962,7 @@ impl ShellExecEnv { __cwd: self.__cwd.clone(), cwd_fd: dupedfd, async_pids: SmolList::default(), + start_instant: self.start_instant, }); Ok(bun_core::heap::into_raw(duped)) } @@ -2167,6 +2172,89 @@ impl ShellExecEnv { }) }) } + + /// Fallback for bash's dynamic / auto-initialized shell variables when + /// `label` was not found in `shell_env` or `export_env`. Appends the + /// expansion to `out`; an unknown name appends nothing. A user assignment + /// (which lands in one of the env maps) therefore shadows these. + pub fn append_special_var(&self, label: &[u8], out: &mut Vec) { + use std::io::Write as _; + match label { + b"RANDOM" => { + // bash: 15-bit value, fresh on every reference. + let n = (bun_core::fast_random() & 0x7fff) as u32; + let _ = write!(out, "{n}"); + } + b"SECONDS" => { + let secs = self.start_instant.elapsed().as_secs(); + let _ = write!(out, "{secs}"); + } + b"IFS" => out.extend_from_slice(b" \t\n"), + b"SHLVL" => out.push(b'1'), + b"LINENO" => out.push(b'0'), + b"OSTYPE" => out.extend_from_slice(bash_ostype()), + b"HOSTNAME" => append_hostname(out), + #[cfg(unix)] + b"UID" => { + let _ = write!(out, "{}", bun_sys::c::getuid()); + } + #[cfg(unix)] + b"EUID" => { + let _ = write!(out, "{}", bun_sys::c::geteuid()); + } + #[cfg(unix)] + b"GROUPS" => { + let _ = write!(out, "{}", bun_sys::c::getgid()); + } + #[cfg(unix)] + b"PPID" => { + // SAFETY: getppid has no preconditions and never fails. + let _ = write!(out, "{}", unsafe { libc::getppid() }); + } + #[cfg(windows)] + b"PPID" => { + // SAFETY: uv_os_getppid has no preconditions. + let _ = write!(out, "{}", unsafe { bun_libuv_sys::uv_os_getppid() }); + } + _ => {} + } + } +} + +/// bash's `$OSTYPE` for the host target. bash derives this from +/// `config.guess`; these are the strings scripts match against. +const fn bash_ostype() -> &'static [u8] { + if cfg!(target_os = "macos") { + b"darwin" + } else if cfg!(target_os = "android") { + b"linux-android" + } else if cfg!(target_env = "musl") { + b"linux-musl" + } else if cfg!(target_os = "linux") { + b"linux-gnu" + } else if cfg!(target_os = "freebsd") { + b"freebsd" + } else if cfg!(windows) { + b"msys" + } else { + b"unknown" + } +} + +#[cfg(unix)] +fn append_hostname(out: &mut Vec) { + let mut buf = [0u8; 256]; + if bun_sys::posix::gethostname(&mut buf).is_ok() { + out.extend_from_slice(bun_core::slice_to_nul(&buf)); + } +} + +#[cfg(windows)] +fn append_hostname(out: &mut Vec) { + let mut buf = [0u16; 256]; + if let Some(name) = bun_sys::windows::gethostname_w(&mut buf) { + bun_core::strings::to_utf8_append_to_list(out, name); + } } // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs index b012aced1c57..4869b617ff5f 100644 --- a/src/runtime/shell/states/Expansion.rs +++ b/src/runtime/shell/states/Expansion.rs @@ -485,7 +485,9 @@ impl Expansion { *has_quoted_empty = true; } ast::SimpleAtom::Var(label) => { - // Spec `expandVar`: shell_env first, then export_env, else "". + // Spec `expandVar`: shell_env first, then export_env, then the + // dynamic-special-variable fallback (`$RANDOM`, `$UID`, ...), + // else "". let key = EnvStr::init_slice(label); if let Some(v) = shell.shell_env.get(key) { out.extend_from_slice(v.slice()); @@ -493,6 +495,8 @@ impl Expansion { } else if let Some(v) = shell.export_env.get(EnvStr::init_slice(label)) { out.extend_from_slice(v.slice()); v.deref(); + } else { + shell.append_special_var(label, out); } } ast::SimpleAtom::VarArgv(int) => { diff --git a/src/sys/windows/mod.rs b/src/sys/windows/mod.rs index af445ecaec1d..78a52d59a0fc 100644 --- a/src/sys/windows/mod.rs +++ b/src/sys/windows/mod.rs @@ -3433,6 +3433,30 @@ pub fn translate_nt_status_to_errno(err: NTSTATUS) -> E { pub use bun_windows_sys::externs::GetHostNameW; +/// `GetHostNameW` with the `WSAStartup` retry it needs when winsock hasn't +/// been initialized yet. Returns the hostname slice (without the trailing +/// NUL) on success. +pub fn gethostname_w(buf: &mut [u16]) -> Option<&[u16]> { + // `namelen` counts WCHARs including the NUL; MSDN bounds the result at + // 256, so both callers pass `[u16; 256]`. + let cap = core::ffi::c_int::try_from(buf.len()).ok()?; + // SAFETY: `buf` is valid for `cap` writes. + let mut rc = unsafe { GetHostNameW(buf.as_mut_ptr(), cap) }; + if rc != 0 { + let mut wsa: ws2_32::WSADATA = bun_core::ffi::zeroed(); + // SAFETY: valid out-pointer. + if unsafe { ws2_32::WSAStartup(0x202, &mut wsa) } == 0 { + // SAFETY: as above. + rc = unsafe { GetHostNameW(buf.as_mut_ptr(), cap) }; + } + } + if rc != 0 { + return None; + } + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + Some(&buf[..end]) +} + /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppathw pub use bun_windows_sys::externs::GetTempPathW; diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 24c152de773a..55a0ce9790eb 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; import { chmodSync, mkdirSync } from "fs"; import { mkdir, rm, stat } from "fs/promises"; import { bunExe, isPosix, isWindows, runWithErrorPromise, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; +import { hostname as osHostname } from "node:os"; import { join, sep } from "path"; import { createTestBuilder, sortedShellOutput } from "./util"; const TestBuilder = createTestBuilder(import.meta.path); @@ -1330,6 +1331,110 @@ describe("deno_task", () => { .runAsTest("exported vars 2"); }); + // bash's auto-initialized / dynamic shell variables: these used to fall + // through the env-map lookup and expand to the empty string. + describe("special shell variables", () => { + // `bunEnv` above is `{ ...process.env, ... }`, so HOSTNAME / SHLVL / + // OSTYPE may already be inherited. Strip every name under test so the + // fallback path is what's exercised. + const envNoSpecials: Record = { ...bunEnv }; + for (const k of [ + "RANDOM", + "UID", + "EUID", + "GROUPS", + "PPID", + "SECONDS", + "LINENO", + "IFS", + "SHLVL", + "HOSTNAME", + "OSTYPE", + ]) { + delete envNoSpecials[k]; + } + + test("$RANDOM is a fresh 15-bit integer on each reference", async () => { + const out = await $`${{ raw: `printf '%s %s %s' "$RANDOM" "$RANDOM" "$RANDOM"` }}`.env(envNoSpecials).text(); + const parts = out.split(" "); + expect(parts).toHaveLength(3); + for (const p of parts) { + expect(p).toMatch(/^\d+$/); + const n = Number(p); + expect(n).toBeGreaterThanOrEqual(0); + expect(n).toBeLessThanOrEqual(32767); + } + // Three independent 15-bit draws: P(all equal) is 2^-30. + expect(new Set(parts).size).toBeGreaterThan(1); + }); + + test("tmp.$RANDOM.$RANDOM yields distinct names across runs", async () => { + const run = () => $`${{ raw: `n=tmp.$RANDOM.$RANDOM; printf '%s' "$n"` }}`.env(envNoSpecials).text(); + const [a, b] = await Promise.all([run(), run()]); + expect(a).toMatch(/^tmp\.\d+\.\d+$/); + expect(b).toMatch(/^tmp\.\d+\.\d+$/); + expect(a).not.toBe(b); + expect(a).not.toBe("tmp.."); + }); + + test("$IFS defaults to ", async () => { + expect(await $`${{ raw: `printf '[%s]' "$IFS"` }}`.env(envNoSpecials).text()).toBe("[ \t\n]"); + }); + + test("$SECONDS expands to the interpreter's elapsed whole seconds", async () => { + const out = await $`${{ raw: `printf '%s' "$SECONDS"` }}`.env(envNoSpecials).text(); + expect(out).toMatch(/^\d+$/); + // Interpreter-start → expansion is in-process work with no I/O; bound + // generously so a loaded debug+ASAN runner never spuriously fails. + expect(Number(out)).toBeLessThan(5); + }); + + test("$SHLVL defaults to 1 when unset in the environment", async () => { + expect(await $`${{ raw: `printf '%s' "$SHLVL"` }}`.env(envNoSpecials).text()).toBe("1"); + }); + + test("$LINENO expands to a number", async () => { + expect(await $`${{ raw: `printf '%s' "$LINENO"` }}`.env(envNoSpecials).text()).toMatch(/^\d+$/); + }); + + test("$OSTYPE is a non-empty platform string", async () => { + const out = await $`${{ raw: `printf '%s' "$OSTYPE"` }}`.env(envNoSpecials).text(); + expect(out.length).toBeGreaterThan(0); + if (process.platform === "darwin") expect(out).toStartWith("darwin"); + else if (process.platform === "linux") expect(out).toStartWith("linux"); + else if (process.platform === "win32") expect(out).toBe("msys"); + }); + + test("$HOSTNAME matches os.hostname()", async () => { + expect(await $`${{ raw: `printf '%s' "$HOSTNAME"` }}`.env(envNoSpecials).text()).toBe(osHostname()); + }); + + test("$PPID is the parent process id", async () => { + const out = await $`${{ raw: `printf '%s' "$PPID"` }}`.env(envNoSpecials).text(); + expect(out).toMatch(/^\d+$/); + // The shell runs in-process, so its PPID is this process's PPID. + expect(out).toBe(String(process.ppid)); + }); + + if (isPosix) { + test("$UID / $EUID / $GROUPS match the real process credentials", async () => { + const out = await $`${{ raw: `printf '%s %s %s' "$UID" "$EUID" "$GROUPS"` }}`.env(envNoSpecials).text(); + expect(out).toBe(`${process.getuid!()} ${process.geteuid!()} ${process.getgid!()}`); + }); + } + + test("user assignment shadows the special-variable fallback", async () => { + const out = await $`${{ raw: `RANDOM=pinned; IFS=:; printf '%s %s' "$RANDOM" "$IFS"` }}` + .env(envNoSpecials) + .text(); + expect(out).toBe("pinned :"); + }); + + test("unknown variable still expands to the empty string", async () => { + expect(await $`${{ raw: `printf '[%s]' "$DEFINITELY_NOT_SET_123"` }}`.env(envNoSpecials).text()).toBe("[]"); + }); + }); + describe("pipeline", async () => { TestBuilder.command`echo 1 | BUN_TEST_VAR=1 ${BUN} -e 'process.stdin.pipe(process.stdout)'` .stdout("1\n")