Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
27 changes: 3 additions & 24 deletions src/runtime/node/node_os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,27 +833,13 @@ mod _impl {
pub(crate) fn hostname(global: &JSGlobalObject) -> JsResult<JSValue> {
#[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))]
Expand Down Expand Up @@ -1731,10 +1717,3 @@ fn parse_u64(s: &[u8]) -> crate::Result<u64> {
fn parse_u32(s: &[u8]) -> crate::Result<u32> {
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]
}
89 changes: 89 additions & 0 deletions src/runtime/shell/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -1793,6 +1794,9 @@ pub struct ShellExecEnv {
pub __cwd: Vec<u8>,
pub cwd_fd: Fd,
pub async_pids: SmolList<PidT, 4>,
/// 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 {
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -2167,6 +2172,90 @@ 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` and returns `true` on a hit. 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<u8>) -> bool {
Comment thread
robobun marked this conversation as resolved.
Outdated
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() });
}
_ => return false,
}
true
}
}

/// 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<u8>) {
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<u8>) {
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

// ────────────────────────────────────────────────────────────────────────────
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/shell/states/Expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,14 +485,18 @@ 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());
v.deref();
} 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) => {
Expand Down
22 changes: 22 additions & 0 deletions src/sys/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3433,6 +3433,28 @@ 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 NUL-terminated hostname slice on success.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn gethostname_w(buf: &mut [u16]) -> Option<&[u16]> {
debug_assert!(buf.len() >= 2);
let cap = (buf.len() - 1) as core::ffi::c_int;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
// SAFETY: `buf` is valid for `cap` writes (NUL reserved).
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) };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
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;

Expand Down
101 changes: 101 additions & 0 deletions test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1330,6 +1331,106 @@ 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<string, string> = { ...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 <space><tab><newline>", async () => {
expect(await $`${{ raw: `printf '[%s]' "$IFS"` }}`.env(envNoSpecials).text()).toBe("[ \t\n]");
});

test("$SECONDS starts at 0", async () => {
expect(await $`${{ raw: `printf '%s' "$SECONDS"` }}`.env(envNoSpecials).text()).toBe("0");
});
Comment thread
robobun marked this conversation as resolved.
Outdated

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+$/);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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")
Expand Down
Loading