Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions docs/runtime/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ process.env.BAR; // => "hello$FOO"

Bun reads `.env` files automatically, so `dotenv` and `dotenv-expand` are unnecessary.

### Enumeration

Values that Bun auto-loads from `.env` files are readable via `process.env.NAME` but are **not enumerable**: `Object.keys(process.env)`, `for..in`, and `{ ...process.env }` list only variables from the OS environment and from explicit `--env-file` arguments. This keeps `process.env` enumeration Node-compatible so tools with their own `.env.{mode}` loading (such as Vite's `loadEnv`) do not mistake Bun's auto-loaded values for shell-provided overrides. Assigning to such a key from JavaScript promotes it to an enumerable own property.

## Reading environment variables

Read the current environment variables from `process.env`.
Expand Down
60 changes: 49 additions & 11 deletions src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,14 +449,18 @@ impl Loader {
*cxx_gop.key_ptr = Box::<[u8]>::from(&**cxx_gop.key_ptr);
*cxx_gop.value_ptr = HashTableValue {
value: ccache_path.clone(),
conditional: false,
};
}
let c_gop = self
.map
.get_or_put_without_value(b"CMAKE_C_COMPILER_LAUNCHER")?;
if !c_gop.found_existing {
*c_gop.key_ptr = Box::<[u8]>::from(&**c_gop.key_ptr);
*c_gop.value_ptr = HashTableValue { value: ccache_path };
*c_gop.value_ptr = HashTableValue {
value: ccache_path,
conditional: false,
};
}
}
Ok(())
Expand Down Expand Up @@ -611,7 +615,11 @@ impl Loader {
// `Source.contents: &'static [u8]` lifetime constraint (callers like
// `node:util.parseEnv` pass JS-owned non-'static buffers).
let mut value_buffer: Vec<u8> = Vec::new();
Parser::parse_bytes::<OVERWRITE, false, EXPAND>(str, &mut self.map, &mut value_buffer)
Parser::parse_bytes::<OVERWRITE, false, EXPAND, false>(
str,
&mut self.map,
&mut value_buffer,
)
}

pub fn load<D: DirEntryProbe + ?Sized>(
Expand Down Expand Up @@ -870,7 +878,11 @@ impl Loader {
}
}
ReadEnvFile::Bytes(buf) => {
Parser::parse_bytes::<OVERRIDE, false, true>(&buf, &mut self.map, value_buffer)?;
Parser::parse_bytes::<OVERRIDE, false, true, true>(
&buf,
&mut self.map,
value_buffer,
)?;
}
}

Expand Down Expand Up @@ -917,7 +929,11 @@ impl Loader {
}
}
ReadEnvFile::Bytes(buf) => {
Parser::parse_bytes::<OVERRIDE, false, true>(&buf, &mut self.map, value_buffer)?;
Parser::parse_bytes::<OVERRIDE, false, true, false>(
&buf,
&mut self.map,
value_buffer,
)?;
}
}

Expand Down Expand Up @@ -1207,7 +1223,12 @@ impl<'a> Parser<'a> {
Ok(Some(self.value_buffer.as_slice()))
}

fn _parse<const OVERRIDE: bool, const IS_PROCESS: bool, const EXPAND: bool>(
fn _parse<
const OVERRIDE: bool,
const IS_PROCESS: bool,
const EXPAND: bool,
const CONDITIONAL: bool,
>(
&mut self,
map: &mut Map,
) -> Result<(), AllocError> {
Expand All @@ -1231,7 +1252,10 @@ impl<'a> Parser<'a> {
}
// else: previous value freed by Drop on assignment below
}
*entry.value_ptr = HashTableValue { value: value_owned };
*entry.value_ptr = HashTableValue {
value: value_owned,
conditional: CONDITIONAL,
};
}
if !IS_PROCESS && EXPAND {
// borrowck — index-based iteration: clone the value bytes, run
Expand All @@ -1243,9 +1267,7 @@ impl<'a> Parser<'a> {
while idx < total {
let current: Box<[u8]> = Box::from(&*map.map.values()[idx].value);
if let Some(expanded) = self.expand_value(map, &current)? {
map.map.values_mut()[idx] = HashTableValue {
value: Box::from(expanded),
};
map.map.values_mut()[idx].value = Box::from(expanded);
}
idx += 1;
}
Expand All @@ -1258,7 +1280,12 @@ impl<'a> Parser<'a> {
/// Same as [`parse`] but takes the source bytes directly. Exists so
/// `load_env_file*` can parse a transient `Vec<u8>` without constructing a
/// `bun_ast::Source` (whose `contents` field is currently `&'static [u8]`).
pub(crate) fn parse_bytes<const OVERRIDE: bool, const IS_PROCESS: bool, const EXPAND: bool>(
pub(crate) fn parse_bytes<
const OVERRIDE: bool,
const IS_PROCESS: bool,
const EXPAND: bool,
const CONDITIONAL: bool,
>(
src: &[u8],
map: &mut Map,
value_buffer: &mut Vec<u8>,
Expand All @@ -1272,7 +1299,7 @@ impl<'a> Parser<'a> {
src: strings::without_utf8_bom(src),
value_buffer,
};
parser._parse::<OVERRIDE, IS_PROCESS, EXPAND>(map)
parser._parse::<OVERRIDE, IS_PROCESS, EXPAND, CONDITIONAL>(map)
}
}

Expand All @@ -1281,6 +1308,13 @@ pub struct HashTableValue {
// `Box<[u8]>` is owned-by-default, trading some copies for uniform
// ownership.
pub value: Box<[u8]>,
/// Set for keys that exist only because Bun auto-discovered a `.env*` file,
/// not because the OS environment, `--env-file`, or an explicit `put()`
/// supplied them. `createEnvironmentVariablesMap` adds these with
/// `DontEnum` so tooling that re-reads `.env.{mode}` itself (Vite's
/// `loadEnv`, dotenv-flow, etc.) does not mistake them for process-level
/// overrides when enumerating `process.env`.
Comment thread
robobun marked this conversation as resolved.
pub conditional: bool,
}

// On Windows, environment variables are case-insensitive. So we use a case-insensitive hash map.
Expand Down Expand Up @@ -1411,6 +1445,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
)
}
Expand All @@ -1428,6 +1463,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
);
}
Expand All @@ -1437,6 +1473,7 @@ impl Map {
let gop = self.map.get_or_put(key)?;
*gop.value_ptr = HashTableValue {
value: Box::from(value),
conditional: false,
};
if !gop.found_existing {
*gop.key_ptr = Box::from(key);
Expand Down Expand Up @@ -1471,6 +1508,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
)?;
Ok(())
Expand Down
1 change: 1 addition & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@ fn configure_env_for_scripts_run(
value: Box::<[u8]>::from(strings::without_trailing_slash(
FileSystem::instance().top_level_dir(),
)),
conditional: false,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/install_jsc/ini_jsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ impl IniTestingAPIs {
&keyslice,
dotenv::map::Entry {
value: slice.into_boxed_slice(),
conditional: false,
},
)?;
}
Expand Down
51 changes: 48 additions & 3 deletions src/jsc/bindings/JSEnvironmentVariableMap.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

extern "C" size_t Bun__getEnvCount(JSGlobalObject* globalObject, void** list_ptr);
extern "C" size_t Bun__getEnvKey(void* list, size_t index, unsigned char** out);
extern "C" bool Bun__isEnvKeyConditional(JSGlobalObject* globalObject, size_t index);

extern "C" bool Bun__getEnvValue(JSGlobalObject* globalObject, const ZigString* name, ZigString* value);
extern "C" bool Bun__getEnvValueBunString(JSGlobalObject* globalObject, const BunString* name, BunString* value);
Expand Down Expand Up @@ -61,6 +62,32 @@
return JSValue::encode(result);
}

// Non-caching variant for keys auto-loaded from `.env*` files. Installed as a
// CustomAccessor with DontEnum so enumerating process.env matches Node's
// OS-only view; the accessor stays in place until user code writes to the key
// (jsSetterEnvironmentVariable promotes to an enumerable data property).
Comment thread
robobun marked this conversation as resolved.
JSC_DEFINE_CUSTOM_GETTER(jsGetterConditionalEnvironmentVariable, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName))
{
VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);

auto* thisObject = dynamicDowncast<JSObject>(JSValue::decode(thisValue));
if (!thisObject) [[unlikely]]
return JSValue::encode(jsUndefined());

ZigString name = toZigString(propertyName.publicName());
ZigString value = { nullptr, 0 };

if (name.len == 0) [[unlikely]]
return JSValue::encode(jsUndefined());

if (!Bun__getEnvValue(globalObject, &name, &value)) {
return JSValue::encode(jsUndefined());
}

return JSValue::encode(jsString(vm, Zig::toStringCopy(value)));
}

JSC_DEFINE_CUSTOM_SETTER(jsSetterEnvironmentVariable, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue value, PropertyName propertyName))
{
VM& vm = globalObject->vm();
Expand Down Expand Up @@ -759,7 +786,7 @@
}

#if OS(WINDOWS)
JSArray* keyArray = constructEmptyArray(globalObject, nullptr, count);
JSArray* keyArray = constructEmptyArray(globalObject, nullptr, 0);
RETURN_IF_EXCEPTION(scope, {});
#endif

Expand Down Expand Up @@ -795,15 +822,22 @@
};

auto* cached_getter_setter = JSC::CustomGetterSetter::create(vm, jsGetterEnvironmentVariable, nullptr);
auto* conditional_getter_setter = JSC::CustomGetterSetter::create(vm, jsGetterConditionalEnvironmentVariable, jsSetterEnvironmentVariable);
auto* proxy_getter_setter = JSC::CustomGetterSetter::create(vm, jsGetterProxyEnvironmentVariable, jsSetterProxyEnvironmentVariable);

for (size_t i = 0; i < count; i++) {
unsigned char* chars;
size_t len = Bun__getEnvKey(list, i, &chars);
bool conditional = Bun__isEnvKeyConditional(globalObject, i);
// We can't really trust that the OS gives us valid UTF-8
auto name = String::fromUTF8ReplacingInvalidSequences(std::span { chars, len });
#if OS(WINDOWS)
keyArray->putByIndexInline(globalObject, (unsigned)i, jsString(vm, name), false);
// keyArray backs the Windows Proxy's ownKeys() trap; skip conditional
// keys to keep DontEnum semantics through the Proxy.
if (!conditional) {
keyArray->push(globalObject, jsString(vm, name));
RETURN_IF_EXCEPTION(scope, {});
}

Check warning on line 840 in src/jsc/bindings/JSEnvironmentVariableMap.cpp

View check run for this annotation

Claude / Claude Code Review

Windows Proxy set/defineProperty traps don't promote conditional keys on same-value write

Filtering conditional keys out of `keyArray` here isn't matched by the Windows Proxy traps in `ProcessObjectInternals.ts`: the `set` trap's `if (internalEnv[k] !== value)` guard (line 506) skips `internalEnv[k] = value` when the assigned value equals the `.env` value, so `process.env.API_KEY = process.env.API_KEY || 'default'` (or `dotenv.config()` re-writing the same value) never invokes `jsSetterEnvironmentVariable` and the property stays `DontEnum` — `Object.keys`/spread still exclude it whil
Comment thread
robobun marked this conversation as resolved.
Outdated
#endif
Comment thread
claude[bot] marked this conversation as resolved.
if (name == TZ) {
hasTZ = true;
Expand Down Expand Up @@ -849,7 +883,18 @@
// time) and then sets it onto the object, subsequent calls to the
// getter will not go through the getter and instead will just do the
// property lookup.
object->putDirectCustomAccessor(vm, identifier, cached_getter_setter, JSC::PropertyAttribute::CustomValue | 0);
//
// Keys that exist only because Bun auto-discovered a `.env*` file are
// added `DontEnum` so `Object.keys`/`for..in`/`{ ...process.env }` match
// Node's OS-only view. Direct reads still work; `jsSetterEnvironmentVariable`
// promotes to an enumerable data property on first write.
Comment thread
robobun marked this conversation as resolved.
if (conditional) {
object->putDirectCustomAccessor(vm, identifier, conditional_getter_setter,
JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DontEnum | 0);
} else {
Comment thread
claude[bot] marked this conversation as resolved.
object->putDirectCustomAccessor(vm, identifier, cached_getter_setter,
JSC::PropertyAttribute::CustomValue | 0);
}
Comment thread
claude[bot] marked this conversation as resolved.
}

unsigned int TZAttrs = JSC::PropertyAttribute::CustomAccessor | 0;
Expand Down
19 changes: 19 additions & 0 deletions src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2152,6 +2152,25 @@ pub mod environment_variables {
item.len()
}

/// Whether the env entry at index `i` came only from an auto-discovered
/// `.env*` file. `createEnvironmentVariablesMap` marks such keys `DontEnum`
/// so `process.env` enumeration matches Node's (OS-only) view.
///
/// # Safety
/// Same contract as `Bun__getEnvKey`: `i` must be less than the count
/// returned by `Bun__getEnvCount` for the same `globalObject`, with no map
/// mutation in between.
Comment thread
robobun marked this conversation as resolved.
#[unsafe(no_mangle)]
pub(crate) unsafe extern "C" fn Bun__isEnvKeyConditional(
global_object: &JSGlobalObject,
i: usize,
) -> bool {
let bun_vm = global_object.bun_vm().as_mut();
let values = bun_vm.env_loader().map.map.values();
debug_assert!(i < values.len());
values[i].conditional
}

#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__getEnvValue(
global_object: &JSGlobalObject,
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2327,6 +2327,7 @@ impl TestCommand {
*node_env_entry.key_ptr = Box::<[u8]>::from(&**node_env_entry.key_ptr);
*node_env_entry.value_ptr = DotEnv::HashTableValue {
value: Box::<[u8]>::from(b"test" as &[u8]),
conditional: false,
};
}

Expand Down
Loading
Loading