Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
99 changes: 99 additions & 0 deletions src/runtime/cli/Arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,12 @@
);
Global::exit(1);
}
// Keep `PWD` in sync with the new cwd so tools that read
// `process.env.PWD` (TypeScript / vue-tsc for module resolution,
// shell scripts, subprocess children, etc.) see the directory
// we just chdir'd into instead of the inherited parent. bash's
// builtin `cd` does the same — only on success.
set_pwd_env(&out_z);
Box::<[u8]>::from(out_z.as_bytes())
} else {
let mut temp = PathBuffer::uninit();
Expand Down Expand Up @@ -1565,6 +1571,99 @@
Ok(opts)
}

/// Publish `cwd` as the `PWD` environment variable. Used after `--cwd`
/// successfully chdir's so that `process.env.PWD` and spawned children see
/// the new directory. bash's builtin `cd` does the same — only on success.
///
/// Two writes are needed:
/// 1. OS env (`setenv` / `SetEnvironmentVariableW`) — so `Bun.spawn` children
/// inherit the new `PWD` via the OS-level env block.
/// 2. Frozen WTF-8 snapshot on Windows — `DotEnv::load_process` iterates
/// `bun_sys::environ()` which on Windows reads
/// `bun_core::os::environ()`, a snapshot taken once at startup by
/// `convert_env_to_wtf8` (`src/sys/windows/env.rs`); `SetEnvironmentVariableW`
/// doesn't update it, so we replace the `PWD=` slot in place or grow
/// the slice by one. POSIX is fine without this step — `bun_sys::environ`
/// reads libc's live `environ` pointer each call, which `setenv` updates.
fn set_pwd_env(cwd: &bun_core::ZBox) {
#[cfg(windows)]
{
patch_windows_environ_snapshot(cwd.as_bytes());
let mut wbuf = bun_paths::WPathBuffer::uninit();
let wcwd = bun_paths::strings::to_w_path(wbuf.as_mut_slice(), cwd.as_bytes());
const PWD_W: [u16; 4] = [b'P' as u16, b'W' as u16, b'D' as u16, 0];
// SAFETY: PWD_W is NUL-terminated; to_w_path writes a NUL-terminated WStr.
unsafe {
let _ = SetEnvironmentVariableW(PWD_W.as_ptr(), wcwd.as_ptr());
}
}
#[cfg(not(windows))]
{
// SAFETY: literal is NUL-terminated; ZBox::as_ptr() points to NUL-terminated bytes.
unsafe {
let _ = setenv(c"PWD".as_ptr(), cwd.as_ptr(), 1);
}
}
}

#[cfg(windows)]
fn patch_windows_environ_snapshot(cwd: &[u8]) {
// Mirror Zig's invariant in `convert_env_to_wtf8`: the allocation is
// leaked for the process lifetime, so no `Drop` worries here. Replace
// the existing `PWD=` entry in place when present, otherwise grow the
// slice by one — the `set_environ(ptr, len)` call publishes the new
// (ptr, len) pair atomically for subsequent readers.
let mut entry: Vec<u8> = Vec::with_capacity(4 + cwd.len() + 1);
entry.extend_from_slice(b"PWD=");
entry.extend_from_slice(cwd);
entry.push(0);
let entry_ptr: *mut core::ffi::c_char = Box::leak(entry.into_boxed_slice()).as_mut_ptr().cast();

// SAFETY: single-threaded argv parse; no other thread reads `environ` yet.
let env = unsafe { bun_core::os::environ() };
for (i, slot) in env.iter().enumerate() {
// SAFETY: entries are NUL-terminated by construction in convert_env_to_wtf8.
let bytes = unsafe { bun_core::ffi::cstr_bytes(*slot as *const _) };
if bytes.starts_with(b"PWD=") {
// SAFETY: `env.as_ptr()` is a `*const *mut c_char` into the Box::leak'd
// slice from convert_env_to_wtf8; we have exclusive access at argv-parse
// time (before any other thread exists).
unsafe {
let mut_base = env.as_ptr() as *mut *mut core::ffi::c_char;
*mut_base.add(i) = entry_ptr;
}
Comment on lines +1632 to +1635

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.

🔴 Writing through env.as_ptr() as *mut *mut c_char is UB under Stacked Borrows / Tree Borrows: env is a &'static [*mut c_char] built via slice::from_raw_parts (src/bun_core/lib.rs:245-253), so .as_ptr() carries SharedReadOnly provenance and the cast doesn't change that — Miri will reject the write at line 1634. The bun_core::os module already stores the raw *mut *mut c_char in ENVIRON precisely to avoid this round-trip; either expose an environ_raw()/environ_mut() accessor and write through that, or just rebuild + set_environ() here the same way the no-PWD branch already does.

Extended reasoning...

What the bug is

patch_windows_environ_snapshot obtains the envp slice via bun_core::os::environ(), which returns a shared reference:

// src/bun_core/lib.rs:245-253
pub unsafe fn environ() -> &'static [*mut c_char] {
    let (p, n) = core::ptr::read(&raw const ENVIRON);
    if p.is_null() { &[] } else { core::slice::from_raw_parts(p, n) }
}

The PR then writes through a pointer derived from that shared reference:

// src/runtime/cli/Arguments.rs:1632-1635
let mut_base = env.as_ptr() as *mut *mut core::ffi::c_char;
*mut_base.add(i) = entry_ptr;

<&[T]>::as_ptr() yields a *const T whose provenance is inherited from the &[T] borrow. Under Stacked Borrows that tag is SharedReadOnly; under Tree Borrows the shared reborrow creates a Frozen node. An as-cast to *mut does not grant write permission — it preserves the same tag — so the store at *mut_base.add(i) writes through a read-only tag, which is undefined behavior. Miri will flag this as "attempting a write access using … but that tag only grants SharedReadOnly".

Why existing code doesn't prevent it

The SAFETY: comment on the block argues exclusive access ("single-threaded argv parse … no other thread reads environ yet"). That justifies the absence of data races, but provenance is a per-pointer property independent of threading: even with truly exclusive access, a pointer derived from &T (no UnsafeCell) may not be used to write T. The underlying allocation is in fact writable (it came from Box::leak in convert_env_to_wtf8), but that mutable provenance was discarded the moment the access went through slice::from_raw_parts::<*mut c_char>&[*mut c_char].

The codebase documents exactly this hazard at src/CLAUDE.md:272-276 ("Pointer provenance at FFI boundaries": "a &self-derived raw pointer carries SharedReadOnly provenance"), and bun_core::os was deliberately designed around it — the comment at lib.rs:221-225 explains that ENVIRON is stored as a raw (*mut *mut c_char, usize) pair specifically so writers don't have to alias a live &mut or round-trip through a shared borrow.

Step-by-step proof

  1. On Windows, convert_env_to_wtf8 builds a Box<[*mut c_char]>, leaks it, and stores (ptr, len) in ENVIRON via set_environ. The leaked allocation has a Unique root tag with full read/write permission.
  2. bun --cwd=subdir runs with PWD inherited (the common case). Arguments::parse calls set_pwd_envpatch_windows_environ_snapshot.
  3. Line 1624: environ() reads (p, n) from ENVIRON and calls core::slice::from_raw_parts(p, n). This creates a new SharedReadOnly borrow (Stacked Borrows) / Frozen child (Tree Borrows) of the allocation, covering elements [0, n), and returns env: &'static [*mut c_char] carrying that tag.
  4. The loop finds env[i] starting with b"PWD=".
  5. Line 1633: env.as_ptr() returns *const *mut c_char with the same SharedReadOnly tag as env. Casting it as *mut *mut c_char changes only the static type; the tag is unchanged.
  6. Line 1634: *mut_base.add(i) = entry_ptr performs a write at offset i using a tag that grants only read permission → UB. Under Miri this aborts; under rustc the optimizer is free to assume env[i] is unchanged for the lifetime of env (it's still live through the return) and could e.g. reorder or eliminate the store.

Impact

This is textbook aliasing-model UB in new unsafe code introduced by this PR, on the path taken by every Windows --cwd invocation where PWD is inherited. Practical miscompilation risk with current rustc/LLVM is low (the &[T]-derived noalias is read-only and there's no competing read after the write here), but the project's own conventions explicitly forbid this pattern, the surrounding module was designed to make the correct approach easy, and the new tests skipIf(isWindows) so this path has zero CI coverage.

Suggested fix

Don't round-trip through the &[..] view for the write. Two easy options:

  • Use the existing primitive: the no-PWD branch in this same function already does it correctly — build a fresh Box::leak'd array and publish it via bun_core::os::set_environ(ptr, len). The in-place branch can do the same (copy env, replace slot i, push the trailing null, set_environ). This costs one small allocation on a one-time startup path.
  • Add a raw accessor: expose pub unsafe fn environ_raw() -> (*mut *mut c_char, usize) in bun_core::os (it's just core::ptr::read(&raw const ENVIRON)) and write through that pointer, which retains the original Box::leak mutable provenance:
    let (base, n) = unsafe { bun_core::os::environ_raw() };
    for i in 0..n {
        let bytes = unsafe { bun_core::ffi::cstr_bytes(*base.add(i) as *const _) };
        if bytes.starts_with(b"PWD=") {
            unsafe { *base.add(i) = entry_ptr; }
            return;
        }
    }

return;
}
}

// No existing PWD entry — grow the envp slice by one. We rebuild the
// full NUL-terminated array (Box::leak'd) and swap it in via
// `set_environ`.
let mut new: Vec<*mut core::ffi::c_char> = Vec::with_capacity(env.len() + 2);
new.extend_from_slice(env);
new.push(entry_ptr);
new.push(core::ptr::null_mut());
let new = Box::leak(new.into_boxed_slice());
// SAFETY: single-threaded startup; `new` is live for the process lifetime.
unsafe {
bun_core::os::set_environ(new.as_mut_ptr(), new.len() - 1);
}
}

#[cfg(not(windows))]
unsafe extern "C" {
fn setenv(
name: *const core::ffi::c_char,
value: *const core::ffi::c_char,
overwrite: core::ffi::c_int,
) -> core::ffi::c_int;
}

#[cfg(windows)]
unsafe extern "C" {
fn SetEnvironmentVariableW(name: *const u16, value: *const u16) -> i32;
}

Check warning on line 1665 in src/runtime/cli/Arguments.rs

View check run for this annotation

Claude / Claude Code Review

SetEnvironmentVariableW declared with extern "C" instead of extern "system"

nit: `SetEnvironmentVariableW` is a kernel32 stdcall API, so this should be `unsafe extern "system"` rather than `extern "C"` — matching the documented convention at `src/windows_sys/externs.rs:1306` ("kernel32 stdcall — use extern \"system\"") and the sibling `GetEnvironmentVariableW` declaration at `src/sys/windows/mod.rs:4886`. On x86_64/aarch64 Windows the two ABIs are identical so there's no runtime impact on supported targets, but it's a one-word fix for consistency. (The same pre-existing
Comment on lines +1662 to +1665

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.

🟡 nit: SetEnvironmentVariableW is a kernel32 stdcall API, so this should be unsafe extern "system" rather than extern "C" — matching the documented convention at src/windows_sys/externs.rs:1306 ("kernel32 stdcall — use extern "system"") and the sibling GetEnvironmentVariableW declaration at src/sys/windows/mod.rs:4886. On x86_64/aarch64 Windows the two ABIs are identical so there's no runtime impact on supported targets, but it's a one-word fix for consistency. (The same pre-existing pattern exists at src/runtime/node/node_process.rs:138-143, which is probably worth fixing alongside.)

Extended reasoning...

What

The new declaration at src/runtime/cli/Arguments.rs:1662-1665 is:

#[cfg(windows)]
unsafe extern "C" {
    fn SetEnvironmentVariableW(name: *const u16, value: *const u16) -> i32;
}

SetEnvironmentVariableW is a kernel32 Win32 API, and Win32 APIs use the stdcall calling convention. In Rust, the correct ABI specifier for this is extern "system", which resolves to stdcall on i686-pc-windows and to the platform C ABI on x86_64/aarch64 (where stdcall and the C ABI coincide). The codebase already documents and follows this convention:

  • src/windows_sys/externs.rs:1306 carries an explicit comment: "kernel32 stdcall — use extern "system" so the callconv is correct on all targets", and every kernel32 declaration in that file uses extern "system".
  • src/sys/windows/mod.rs:4886 declares the sibling GetEnvironmentVariableW / GetEnvironmentStringsW / FreeEnvironmentStringsW under unsafe extern "system".
  • src/jsc/btjs.rs:484 declares GetEnvironmentVariableW under unsafe extern "system".

Why this is only a nit

On the Windows targets Bun actually ships (x86_64-pc-windows-msvc and aarch64-pc-windows-msvc), extern "C" and extern "system" lower to the same Win64 calling convention — there is exactly one calling convention on those platforms. So this declaration produces correct code today and there is zero runtime impact on any supported target. Additionally, the same crate already has a pre-existing unsafe extern "C" { fn SetEnvironmentVariableW(...) } at src/runtime/node/node_process.rs:138-143, so the PR is following local (if non-ideal) precedent rather than introducing a brand-new inconsistency.

The only target where the distinction matters is 32-bit x86 Windows, where extern "C" = cdecl (caller cleans stack) and extern "system" = stdcall (callee cleans stack). Calling a stdcall function through a cdecl prototype there would mis-balance the stack by 8 bytes (two pointer args). Bun does not target i686-pc-windows, so this is a latent portability hazard rather than a live bug.

Step-by-step

  1. bun --cwd=subdir ... runs on Windows; Arguments::parse chdir's successfully and calls set_pwd_env (line 821).
  2. set_pwd_env enters the #[cfg(windows)] arm and calls SetEnvironmentVariableW(PWD_W.as_ptr(), wcwd.as_ptr()) (line 1597).
  3. The call site is compiled against the extern "C" prototype at line 1664. On x86_64/aarch64, rustc emits the standard Win64 callconv for both "C" and "system", so the args land in RCX/RDX and the call works correctly — identical machine code to the extern "system" spelling.
  4. On a hypothetical i686 build, rustc would emit a cdecl call (push args, caller pops 8 bytes after return) while kernel32's SetEnvironmentVariableW@8 is stdcall (callee already popped 8 bytes via ret 8). The double-pop misaligns ESP by 8 bytes and corrupts the caller's stack frame.

Impact

None on shipped binaries. This is purely a documented-convention violation in new code, with a one-word fix.

Suggested fix

#[cfg(windows)]
unsafe extern "system" {
    fn SetEnvironmentVariableW(name: *const u16, value: *const u16) -> i32;
}

Optionally also fix the pre-existing copy at src/runtime/node/node_process.rs:138-143 while in the area.


/// Cold path: `bun test` option-group parsing — timeout / coverage / reporter /
/// shard / parallel / seed / etc. Split out of [`parse`] so the `bun run <script>`
/// and bare-`bun <file>` hot path (`USES_GLOBAL_OPTIONS` ⇒ `parse` runs on every
Expand Down
106 changes: 106 additions & 0 deletions test/cli/install/bun-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,112 @@ describe.concurrent("bun run", () => {
expect(exitCode).toBe(0);
});

// https://github.com/oven-sh/bun/issues/30456
//
// The reported repro is `bun --cwd=frontend run lint`, so cover both the
// bare-entrypoint form (`bun --cwd=subdir test.js`) and the `run`
// subcommand form (`bun --cwd=subdir run test.js`) — each hits a different
// Arguments.parse branch.
//
// `PWD` is a POSIX shell convention — these tests exist for the tools that
// read it (vue-tsc via TypeScript's module resolution, shell scripts,
// subprocess children). On Windows the path / env conventions differ enough
// (case-insensitive keys, backslash vs forward-slash in `process.cwd()` vs
// the `.loose`-normalised path we store, `%CD%` as the native equivalent)
// that a strict equality test would produce false failures; skip there.
for (const withRun of [false, true]) {
const label = withRun ? "bun --cwd run" : "bun --cwd";
const buildCmd = (...extra: string[]) => [bunExe(), "--cwd=subdir", ...(withRun ? ["run"] : []), ...extra];
Comment on lines +345 to +347

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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'for \(const withRun of \[false, true\]\)' test/cli/install/bun-run.test.ts
rg -n 'describe\.each\(' test/cli/install/bun-run.test.ts

Repository: oven-sh/bun

Length of output: 178


🏁 Script executed:

sed -n '340,370p' test/cli/install/bun-run.test.ts

Repository: oven-sh/bun

Length of output: 1504


🏁 Script executed:

sed -n '340,437p' test/cli/install/bun-run.test.ts

Repository: oven-sh/bun

Length of output: 3794


🏁 Script executed:

sed -n '140,160p' test/cli/install/bun-run.test.ts

Repository: oven-sh/bun

Length of output: 923


Use describe.each() for the withRun parameterization instead of a manual loop.

The test block at line 345 uses for (const withRun of [false, true]) to parameterize three test cases. Convert to describe.each([false, true])("withRun=%s", (withRun) => { ... }) to align with test guidelines and the existing pattern in this file (see line 146).

🤖 Prompt for 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.

In `@test/cli/install/bun-run.test.ts` around lines 345 - 347, Replace the manual
loop over withRun with a Jest parameterized describe block: change the for
(const withRun of [false, true]) { ... } pattern to describe.each([false,
true])("withRun=%s", (withRun) => { ... }) and move the existing body (including
label and buildCmd definitions and the three test cases) inside that describe
callback so tests run with both parameter values; keep the variables label and
buildCmd as-is inside the new describe to preserve scope and behavior.


it.skipIf(isWindows)(`${label} updates process.env.PWD to match the new cwd`, async () => {
using dir = tempDir(`bun-run-cwd-pwd-${withRun ? "run" : "bare"}`, {
"subdir/test.js": `console.log(JSON.stringify({ pwd: process.env.PWD, cwd: process.cwd() }));`,
});

await using proc = Bun.spawn({
cmd: buildCmd("test.js"),
cwd: String(dir),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: {
...bunEnv,
// Set a known PWD so we detect when it's not overwritten — don't
// rely on the inherited parent PWD (Bun's test harness can leave
// it pointing at an unexpected directory on some platforms).
PWD: String(dir),
},
});

const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

const { pwd, cwd } = JSON.parse(stdout);
// realpath-resolved subdir under the tempdir, since macOS tempdirs
// symlink through /private.
expect(cwd).toMatch(/subdir$/);
// PWD must match cwd — tools like vue-tsc / TypeScript's module
// resolution read PWD and use it as the resolution root.
expect(pwd).toBe(cwd);
expect(exitCode).toBe(0);
});

it.skipIf(isWindows)(`${label} PWD is inherited by spawned child processes`, async () => {
using dir = tempDir(`bun-run-cwd-pwd-child-${withRun ? "run" : "bare"}`, {
"subdir/test.js": `
import { spawnSync } from "child_process";
const out = spawnSync(process.execPath, ["-e", "console.log(process.env.PWD)"], {
env: process.env,
encoding: "utf8",
});
process.stdout.write(out.stdout);
`,
});

await using proc = Bun.spawn({
cmd: buildCmd("test.js"),
cwd: String(dir),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: {
...bunEnv,
PWD: String(dir),
},
});

const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

expect(stdout.trim()).toMatch(/subdir$/);
expect(exitCode).toBe(0);
});

// When PWD is absent from the inherited env (env -u PWD, cron, systemd,
// minimal Docker), libc's setenv reallocates the environ array and the
// naive `std.os.environ` snapshot misses the addition. Make sure --cwd
// still publishes PWD through to `process.env` in that case.
it.skipIf(isWindows)(`${label} adds PWD when parent had none`, async () => {
using dir = tempDir(`bun-run-cwd-pwd-unset-${withRun ? "run" : "bare"}`, {
"subdir/test.js": `console.log(process.env.PWD ?? "<unset>");`,
});

const { PWD, ...envNoPwd } = bunEnv;

await using proc = Bun.spawn({
cmd: buildCmd("test.js"),
cwd: String(dir),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: envNoPwd,
});

const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

expect(stdout.trim()).toMatch(/subdir$/);
expect(exitCode).toBe(0);
});
}

it("DCE annotations are respected", async () => {
using dir = tempDir("test", {
"index.ts": `
Expand Down
Loading