Skip to content
Open
92 changes: 92 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,93 @@ 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 = [0u8; 256];
let mut size: usize = buf.len();
// SAFETY: `buf` is valid for `size` writes; `size` is in-out. libuv
// handles WSAStartup itself, so this works before any socket init.
if unsafe { bun_libuv_sys::uv_os_gethostname(buf.as_mut_ptr().cast(), &mut size) } == 0 {
out.extend_from_slice(&buf[..size]);
}
}

// ────────────────────────────────────────────────────────────────────────────
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
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