Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4e3043b
process.env: replace plain object with Node-semantics exotic on POSIX
robobun Jul 26, 2026
cecf118
process.env (Windows proxy): throw on Symbol key/value, match set-tra…
robobun Jul 26, 2026
8023a21
env_var: drop unnecessary #[allow(dead_code)] on reset()
robobun Jul 26, 2026
c445be8
BunObject.rs: drop stale 'Returns the env_loader map entry count' doc…
robobun Jul 26, 2026
47ade95
address review: libcPathForDlopen for musl, skipIf(!cc), afterEach cl…
robobun Jul 26, 2026
fffc7ba
process-env-exotic.test: run freeze/seal probe in subprocess so a fai…
robobun Jul 26, 2026
1a17569
test/preload: skip CI when copying bunEnv into process.env
robobun Jul 26, 2026
808e952
address review: seqlock for string env_var cache, lock map.put/remove…
robobun Jul 26, 2026
210f25b
address review: environ RwLock around setenv/getenv_z, $-prefix read …
robobun Jul 26, 2026
1b2e74d
Bun__ProcessEnv__put: self-truncate NUL + filter empty/= keys; seqloc…
robobun Jul 26, 2026
5143c3e
env_var: store owned copy after runtime setenv (musl frees previous v…
robobun Jul 26, 2026
246257b
env_var::set_owned: lsan-ignore the leaked Box
robobun Jul 26, 2026
9e326a3
Bun__ProcessEnv__delete: clear matching proxy_env_storage slot; fix s…
robobun Jul 26, 2026
7425d37
env_var string cache: always store a leaked owned copy; drop dead res…
robobun Jul 26, 2026
b776d54
getenv_z: copy under ENVIRON_LOCK; worker loader skips load_process r…
robobun Jul 26, 2026
6130d1f
env_var: re-check get_cached() after getenv_z in get_force_reload/pla…
robobun Jul 26, 2026
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
151 changes: 124 additions & 27 deletions src/bun_core/env_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
// `New`/`PlatformSpecificNew` are `macro_rules!` that emit a module per env var; the macros
// must be defined (or `#[macro_use]`d) before the declarations.

use core::sync::atomic::{AtomicPtr, AtomicU8, AtomicU64, AtomicUsize, Ordering};
use core::sync::atomic::{AtomicPtr, AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering};

// MOVE_DOWN: bun_core::ZStr → bun_core (move-in pass).
use crate::ZStr;
Expand Down Expand Up @@ -309,7 +309,13 @@ pub(crate) mod kind {

// A single Cache struct; per-var uniqueness comes from each var owning its own
// `static CACHE: Cache`.
//
// `(ptr, len)` is a split-word cache of an owned leaked copy of the
// env value; `seq` is a seqlock so a concurrent reader never pairs a
// stale `len` with a new `ptr` when the cached slice changes (initial
// load racing a `process.env` write's `set_owned()`).
Comment thread
robobun marked this conversation as resolved.
pub(crate) struct Cache {
seq: AtomicU32,
ptr_value: AtomicPtr<u8>,
len_value: AtomicUsize,
}
Expand All @@ -325,48 +331,83 @@ pub(crate) mod kind {
impl Cache {
pub(crate) const fn new() -> Self {
Self {
seq: AtomicU32::new(0),
ptr_value: AtomicPtr::new(NOT_LOADED_PTR),
len_value: AtomicUsize::new(NOT_LOADED_LEN),
}
}

pub(crate) fn get_cached(&self) -> Output {
let len = self.len_value.load(Ordering::Acquire);

if len == NOT_LOADED_LEN {
return CacheOutput::Unknown;
loop {
let s1 = self.seq.load(Ordering::Acquire);
if s1 & 1 != 0 {
core::hint::spin_loop();
continue;
}
let len = self.len_value.load(Ordering::Acquire);
let ptr = self.ptr_value.load(Ordering::Acquire);
if self.seq.load(Ordering::Acquire) != s1 {
core::hint::spin_loop();
continue;
}
if len == NOT_LOADED_LEN {
return CacheOutput::Unknown;
}
if len == NOT_SET_LEN {
return CacheOutput::NotSet;
}
// SAFETY: (ptr, len) were stored together under the seqlock
// from a leaked Box<[u8]> (getenv_z and set_owned both
// leak before passing in). No pointer into libc environ is
// ever cached, so musl freeing a previous setenv-allocated
// string cannot dangle it.
return CacheOutput::Value(unsafe { core::slice::from_raw_parts(ptr, len) });
}
}

if len == NOT_SET_LEN {
return CacheOutput::NotSet;
#[inline]
fn write_under_seqlock(&self, f: impl FnOnce()) {
// Odd seq = write in progress; even = stable. CAS even→odd so
// concurrent writers exclude one another (fetch_add would let a
// second writer bump odd→even and run f() alongside the first).
Comment thread
robobun marked this conversation as resolved.
loop {
let s = self.seq.load(Ordering::Relaxed);
if s & 1 == 0
&& self
.seq
.compare_exchange_weak(s, s + 1, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
break;
}
core::hint::spin_loop();
}

let ptr = self.ptr_value.load(Ordering::Relaxed);

// SAFETY: ptr/len were stored together in deser_and_invalidate from a valid
// &'static [u8] returned by getenv_z (envp memory lives for process lifetime).
CacheOutput::Value(unsafe { core::slice::from_raw_parts(ptr, len) })
f();
self.seq.fetch_add(1, Ordering::Release);
}

#[inline]
pub(crate) fn deser_and_invalidate(
&self,
raw_env: Option<&'static [u8]>,
) -> Option<ValueType> {
// The implementation is racy and allows two threads to both set the value at
// the same time, as long as the value they are setting is the same. This is
// difficult to write an assertion for since it requires the DEV path take a
// .swap() path rather than a plain .store().

if let Some(ev) = raw_env {
self.ptr_value
.store(ev.as_ptr().cast_mut(), Ordering::Relaxed);
self.len_value.store(ev.len(), Ordering::Release);
} else {
self.ptr_value.store(NOT_SET_PTR, Ordering::Relaxed);
self.len_value.store(NOT_SET_LEN, Ordering::Release);
}

// The previous cached slice is deliberately never freed: a
// concurrent `get_cached()` may have already returned it past
// the seqlock (e.g. into `BunString::init`), so reclaiming it
// here would be UAF. Callers pass a leaked Box (`getenv_z`
// leaks under its read lock; `set_owned` leaks the
// process.env write's value), so the stored `(ptr, len)` is
// always Bun-owned and `&'static`.
Comment thread
robobun marked this conversation as resolved.
self.write_under_seqlock(|| {
if let Some(ev) = raw_env {
self.ptr_value
.store(ev.as_ptr().cast_mut(), Ordering::Release);
self.len_value.store(ev.len(), Ordering::Release);
} else {
self.ptr_value.store(NOT_SET_PTR, Ordering::Release);
self.len_value.store(NOT_SET_LEN, Ordering::Release);
}
});
raw_env
}
}
Expand Down Expand Up @@ -689,6 +730,12 @@ macro_rules! platform_specific_new {
$crate::hint::cold();

let env_var = $crate::getenv_z(k);
// A concurrent `set_owned()` may have filled the cache
// while getenv_z ran; don't overwrite it with the
// stale pre-write value.
Comment thread
robobun marked this conversation as resolved.
if !matches!(CACHE.get_cached(), CacheOutput::Unknown) {
return platform_get();
}
let maybe_reloaded = CACHE.deser_and_invalidate(env_var);

if let Some(v) = maybe_reloaded {
Expand Down Expand Up @@ -763,11 +810,29 @@ macro_rules! platform_specific_new {
}
}

/// Replace the cached value from the `process.env` write path.
/// Leaks a copy so the cache never holds a pointer into `environ`
/// (musl frees the previous setenv-allocated string on overwrite);
/// the previous cached slice is never freed because a concurrent
/// `get()` caller may already hold it past the seqlock. Bounded to
/// one small leak per runtime write to one of ten well-known keys.
Comment thread
robobun marked this conversation as resolved.
pub fn set_owned(value: Option<&[u8]>) {
let leaked: Option<&'static [u8]> = value.map(|v| {
let s: &'static [u8] = Box::leak(Box::<[u8]>::from(v));
$crate::asan::ignore_object(s.as_ptr());
s
});
CACHE.deser_and_invalidate(leaked);
}
Comment thread
robobun marked this conversation as resolved.

/// Retrieve the value of the environment variable, reloading it from the environment.
/// Fails if the current platform is unsupported.
fn get_force_reload() -> Option<K::ValueType> {
assert_platform_supported();
let env_var = $crate::getenv_z(key());
if !matches!(CACHE.get_cached(), CacheOutput::Unknown) {
return get();
}
let maybe_reloaded = CACHE.deser_and_invalidate(env_var);

if let Some(v) = maybe_reloaded {
Expand Down Expand Up @@ -951,3 +1016,35 @@ macro_rules! new_feature_flag {
};
}
pub(crate) use new_feature_flag;

/// Replace the cached value for the typed accessor whose key matches `name`
/// with an owned copy of `value` (or mark it unset). `process.env` writes call
/// `setenv()` and then this so `os.homedir()` etc. observe the change; the
/// cache stores a leaked owned slice rather than a pointer into `environ`,
/// because musl frees the previous setenv-allocated string on overwrite.
///
/// Only the string-kind accessors that are read at runtime (after VM startup)
/// are listed; the `BUN_*` flags are read once on boot and intentionally left
/// cached.
Comment thread
robobun marked this conversation as resolved.
pub fn invalidate_for_setenv(name: &[u8], value: Option<&[u8]>) {
macro_rules! try_var {
($v:ident) => {
if let Some(k) = $v::platform_key() {
if crate::strings::eql(k.as_bytes(), name) {
$v::set_owned(value);
return;
}
}
};
}
try_var!(HOME);
try_var!(PATH);
try_var!(USER);
try_var!(TMPDIR);
try_var!(TEMP);
try_var!(TMP);
try_var!(SHELL);
try_var!(XDG_CACHE_HOME);
try_var!(XDG_CONFIG_HOME);
try_var!(XDG_DATA_HOME);
}
11 changes: 11 additions & 0 deletions src/bun_core/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2813,6 +2813,7 @@ pub mod asan {
safe fn __asan_describe_address(ptr: *const c_void);
safe fn __lsan_register_root_region(ptr: *const c_void, size: usize);
safe fn __lsan_unregister_root_region(ptr: *const c_void, size: usize);
safe fn __lsan_ignore_object(ptr: *const c_void);
}

#[inline]
Expand Down Expand Up @@ -2850,6 +2851,16 @@ pub mod asan {
#[cfg(not(bun_asan))]
let _ = (ptr, size);
}
/// Tell LSAN the allocation containing `ptr` is an intentional
/// process-lifetime leak (e.g. a `Box::leak`'d static cache entry whose
/// pointer may later be overwritten and so become unreachable to LSAN).
Comment thread
robobun marked this conversation as resolved.
#[inline]
pub fn ignore_object<T>(ptr: *const T) {
#[cfg(bun_asan)]
__lsan_ignore_object(ptr.cast());
#[cfg(not(bun_asan))]
let _ = ptr;
}
/// Undo a prior `register_root_region(ptr, size)` with identical arguments.
#[inline]
pub fn unregister_root_region(ptr: *const c_void, size: usize) {
Expand Down
45 changes: 38 additions & 7 deletions src/bun_core/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,35 @@
}
}

/// `bun.getenvZ` — read an environment variable. Returns the value as borrowed
/// process-static bytes (env block lives for the process). On POSIX wraps
/// `libc::getenv`; on Windows scans `environ` case-insensitively.
/// Serialises Bun's own `libc::getenv` / `environ` readers against the
/// `process.env` write path's `libc::setenv`/`unsetenv`. `getenv()` does not
/// take glibc's internal envlock (it is async-signal-safe), so a concurrent
/// `setenv()` that `realloc`s `__environ` while the transpiler pool or a
/// worker is inside `getenv_z()` would be a UAF inside libc. Native addons'
/// direct `getenv()` calls are not covered (same limitation Node carries).
Comment thread
robobun marked this conversation as resolved.
static ENVIRON_LOCK: RwLock<()> = RwLock::new(());

/// Hold this write guard around `libc::setenv`/`unsetenv` so [`getenv_z`] and
/// [`getenv_z_any_case`] on other threads don't walk `__environ` mid-realloc.
Comment thread
robobun marked this conversation as resolved.
pub fn environ_write_lock() -> RwLockWriteGuard<'static, ()> {
ENVIRON_LOCK.write()
}

// Leak an owned copy of `src` as `&'static [u8]`, LSAN-ignored. Used so the
// returned slice outlives any later `setenv()` that (on musl) may free the
// source environ string.
Comment thread
robobun marked this conversation as resolved.
#[cfg(unix)]
#[inline]
fn leak_static_copy(src: &[u8]) -> &'static [u8] {
let s: &'static [u8] = Box::leak(Box::<[u8]>::from(src));
crate::asan::ignore_object(s.as_ptr());
s
}
Comment thread
robobun marked this conversation as resolved.

/// `bun.getenvZ` — read an environment variable. On POSIX wraps
/// `libc::getenv`; on Windows scans `environ` case-insensitively. Returns an
/// owned leaked copy so the `&'static [u8]` is valid regardless of a later
/// `setenv()` (musl frees the previous setenv-allocated string on overwrite).
Comment thread
robobun marked this conversation as resolved.
pub fn getenv_z(key: &ZStr) -> Option<&'static [u8]> {
#[cfg(not(any(unix, windows)))]
{
Expand All @@ -334,16 +360,20 @@
return None;
}
#[cfg(unix)]
unsafe {

Check failure on line 363 in src/bun_core/util.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment
let _g = ENVIRON_LOCK.read();
// SAFETY: key is NUL-terminated by ZStr invariant; getenv reads until NUL.
let p = libc::getenv(key.as_ptr());
if p.is_null() {
return None;
}
// SAFETY: getenv returns a pointer into the process env block, valid for
// process lifetime (modulo setenv races).
// SAFETY: getenv returns a pointer into the process env block; the
// read lock serialises against Bun's own setenv path while we copy.
let len = libc::strlen(p);
return Some(core::slice::from_raw_parts(p.cast::<u8>(), len));
return Some(leak_static_copy(core::slice::from_raw_parts(
p.cast::<u8>(),
len,
)));
}
#[cfg(windows)]
{
Expand Down Expand Up @@ -378,7 +408,8 @@
/// CI-detection vars where casing varies across providers).
pub fn getenv_z_any_case(key: &ZStr) -> Option<&'static [u8]> {
#[cfg(unix)]
unsafe {

Check failure on line 411 in src/bun_core/util.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment
let _g = ENVIRON_LOCK.read();
// SAFETY: `environ` is the C env block; entries are NUL-terminated `KEY=VALUE`.
let mut p = c_environ();
while !(*p).is_null() {
Expand All @@ -388,7 +419,7 @@
&line[..key_end],
key.as_bytes(),
) {
return Some(&line[(key_end + 1).min(line.len())..]);
return Some(leak_static_copy(&line[(key_end + 1).min(line.len())..]));
}
p = p.add(1);
}
Expand Down
15 changes: 15 additions & 0 deletions src/collections/array_hash_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,21 @@ impl<V, C: ArrayHashContext<[u8]> + Default, A: MapAllocator> StringArrayHashMap
true
}

/// O(n); preserves insertion order of remaining entries.
pub fn ordered_remove(&mut self, key: &[u8]) -> bool {
let Some(i) = self.find(key) else {
return false;
};
self.inner.keys.remove(i);
self.inner.hashes.remove(i);
self.inner.values.remove(i);
self.inner.drop_index();
if self.inner.keys.len() > INDEX_THRESHOLD {
self.inner.rebuild_index();
}
true
}

/// Removes the entry (swapping the last element into its slot) and
/// returns the owned key/value pair.
pub fn fetch_swap_remove(&mut self, key: &[u8]) -> Option<KV<Box<[u8], A>, V>> {
Expand Down
31 changes: 21 additions & 10 deletions src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,19 +583,30 @@ impl Loader {

let environ: &[*const c_char] = bun_sys::environ();
self.map.map.ensure_total_capacity(environ.len())?;
// The kernel accepts duplicate KEY= entries in environ. libc getenv()
// and Node both return the FIRST occurrence, so keep the first and
// skip later duplicates. Indices below `prior_count` were seeded
// before this scan (e.g. `bun test` pre-seeding NODE_ENV) and are
// still overwritten so the process environment retains priority.
Comment thread
robobun marked this conversation as resolved.
let prior_count = self.map.map.count();
for &_env in environ {
// SAFETY: environ entries are NUL-terminated C strings from the OS
let env = unsafe { bun_core::ffi::cstr(_env) }.to_bytes();
if let Some(i) = strings::index_of_char(env, b'=') {
let key = &env[..i as usize];
let value = &env[i as usize + 1..];
if !key.is_empty() {
self.map.put(key, value)?;
}
} else {
if !env.is_empty() {
self.map.put(env, b"")?;
}
// An entry without '=' is malformed per POSIX ("name=value"); libc
// getenv() and Node ignore it.
Comment thread
robobun marked this conversation as resolved.
let Some(i) = strings::index_of_char(env, b'=') else {
continue;
};
let key = &env[..i as usize];
if key.is_empty() {
continue;
}
let value = &env[i as usize + 1..];
let gop = self.map.get_or_put_without_value(key)?;
if !gop.found_existing || gop.index < prior_count {
*gop.value_ptr = HashTableValue {
value: Box::from(value),
};
}
Comment thread
robobun marked this conversation as resolved.
}
Comment thread
robobun marked this conversation as resolved.
self.did_load_process = true;
Expand Down
Loading
Loading