Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
35 changes: 20 additions & 15 deletions src/js/builtins/ProcessObjectInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,24 +453,24 @@
//
// it throws "Cannot convert a Symbol value to a string"

(internalEnv as any)[Bun.inspect.custom] = () => {
// envMapList now includes auto-loaded .env keys (DontEnum on internalEnv) so
// getOwnPropertyNames can see them; inspection and toJSON mirror Object.keys
// by skipping keys whose storage property is non-enumerable.
Comment thread
robobun marked this conversation as resolved.
Outdated
const enumerableView = () => {
let o = {};
for (let k of envMapList) {
o[k] = internalEnv[k.toUpperCase()];
}
return o;
};

(internalEnv as any).toJSON = () => {
// Mirror enumeration: original-case key names, case-insensitive values.
// Spreading internalEnv directly would leak the canonical UPPERCASE
// storage keys into JSON.stringify(process.env) and IPC env echoes.
let o = {};
for (let k of envMapList) {
o[k] = internalEnv[k.toUpperCase()];
const up = k.toUpperCase();
if ($Object.getOwnPropertyDescriptor(internalEnv, up)?.enumerable) {
o[k] = internalEnv[up];
}
}
return o;
};
(internalEnv as any)[Bun.inspect.custom] = enumerableView;
// Mirror enumeration: original-case key names, case-insensitive values.
// Spreading internalEnv directly would leak the canonical UPPERCASE
// storage keys into JSON.stringify(process.env) and IPC env echoes.
Comment thread
robobun marked this conversation as resolved.
(internalEnv as any).toJSON = enumerableView;

return new Proxy(internalEnv, {
get(_, p) {
Expand Down Expand Up @@ -505,8 +505,10 @@
}
if (internalEnv[k] !== value) {
editWindowsEnvVar(k, value);
internalEnv[k] = value;
}
// Unconditional so a same-value write to a DontEnum auto-loaded .env key
// still promotes it to an enumerable data property via the custom setter.
Comment thread
robobun marked this conversation as resolved.
internalEnv[k] = value;
return true;
},
has(_, p) {
Expand All @@ -530,7 +532,10 @@
defineProperty(_, p, attributes) {
const k = String(p).toUpperCase();
$assert(typeof p === "string"); // proxy is only string and symbol. the symbol would have thrown by now
if (!(k in internalEnv) && !envMapList.includes(p)) {
// Gate on envMapList membership, not `k in internalEnv`: DontEnum
// auto-loaded .env keys and the always-present TZ/proxy accessors are
// own properties of internalEnv while correctly absent from envMapList.

Check warning on line 537 in src/js/builtins/ProcessObjectInternals.ts

View check run for this annotation

Claude / Claude Code Review

Stale comment: claims auto-loaded .env keys are absent from envMapList

This comment says auto-loaded `.env` keys are "correctly absent from envMapList", but after commit d667f354 `keyArray->push` runs unconditionally in the per-key loop — auto-loaded `.env` keys are now *in* `envMapList` (the comment at line 456-458 says exactly that). Only the "always-present TZ/proxy accessors" half is still accurate. The guard code is correct; just drop the "DontEnum auto-loaded .env keys and" clause so this comment doesn't contradict the sibling comment 80 lines up.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
if (!envMapList.includes(p) && !envMapList.some(x => x.toUpperCase() === k)) {
envMapList.push(p);
}
editWindowsEnvVar(k, internalEnv[k]);
Expand Down
12 changes: 10 additions & 2 deletions src/js/node/child_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,8 +1020,16 @@
// copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env);

let envKeys: string[] = [];
for (const key in env) {
ArrayPrototypePush.$call(envKeys, key);
if (env === process.env) {
// Auto-loaded .env values are DontEnum on process.env; getOwnPropertyNames
// includes them (and runtime mutations) so children keep inheriting both.
Comment thread
robobun marked this conversation as resolved.
Outdated
for (const key of $Object.getOwnPropertyNames(env)) {
ArrayPrototypePush.$call(envKeys, key);
}
} else {
for (const key in env) {
ArrayPrototypePush.$call(envKeys, key);
}

Check failure on line 1032 in src/js/node/child_process.ts

View check run for this annotation

Claude / Claude Code Review

Bun.$ shell drops auto-loaded .env values from subprocess env and $VAR expansion

The same compensation applied here for `node:child_process` is missing for `Bun.$`: `shell.ts:252/348` set `BunShell[envSymbol] = process.env`, so line 312 always calls `parsed_shell_script.setEnv(process.env)`; `ParsedShellScript::set_env` iterates via `JSPropertyIterator`, whose every branch (JSPropertyIterator.cpp:65/83/85/88, including the Windows process.env-Proxy special case) uses `DontEnumPropertiesMode::Exclude`; and `interpreter.rs:478-479` uses the resulting `export_env` verbatim with
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}

if (process.platform === "win32") {
Expand Down
Loading
Loading